superlocalmemory 4.0.10 → 4.1.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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -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_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -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")
@@ -330,3 +330,181 @@ def evolution_lineage(request: Request, skill_name: str = ""):
330
330
  finally:
331
331
  if conn is not None:
332
332
  conn.close()
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Approving a quarantined skill
337
+ #
338
+ # A mutation that passes blind verification stops at VERIFIED_QUARANTINED and
339
+ # waits. Nothing moved it from there: auto-approval is off by default, correctly
340
+ # — this system rewrites the instructions an AI follows, and doing that without
341
+ # a person saying yes is not a default anyone should ship. But there was no way
342
+ # for the person to say yes either, so every verified improvement sat in a
343
+ # quarantine directory permanently.
344
+ #
345
+ # This is that path. It does NOT change the default: approval remains explicit,
346
+ # per-candidate, and recorded in the transition chain with who did it.
347
+ # ---------------------------------------------------------------------------
348
+
349
+
350
+ class ApproveSkillRequest(BaseModel):
351
+ """Which candidate to approve. Named by record id, not by skill name.
352
+
353
+ A skill can have several candidates over time and only one of them is the
354
+ one being looked at. Approving "the latest candidate for skill X" would make
355
+ the outcome depend on when the call happened to arrive.
356
+ """
357
+
358
+ record_id: str
359
+
360
+
361
+ _APPROVABLE = ("verified_quarantined", "promoted")
362
+
363
+
364
+ @router.post("/api/evolution/approve")
365
+ def evolution_approve(request: Request, body: ApproveSkillRequest):
366
+ """Activate a quarantined skill mutation after human approval."""
367
+ _require_manage(request)
368
+ try:
369
+ from superlocalmemory.evolution.evolution_store import EvolutionStore
370
+ from superlocalmemory.evolution.skill_activator import SkillActivator
371
+ from superlocalmemory.evolution.types import EvolutionStatus
372
+
373
+ profile_id = get_active_profile()
374
+ store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
375
+
376
+ record = store.get_record(body.record_id, profile_id)
377
+ if record is None:
378
+ return {"success": False, "error": "No such evolution candidate"}
379
+
380
+ # Prefer the transition chain over the record's own column: the chain is
381
+ # the audited history and the column is a cache of its last entry.
382
+ latest = store.get_latest_status(body.record_id, profile_id)
383
+ current = (latest.value if latest is not None
384
+ else getattr(record.status, "value", ""))
385
+
386
+ if current not in _APPROVABLE:
387
+ # Refusing an already-active candidate matters: activating twice
388
+ # would overwrite the backup taken the first time with the mutation
389
+ # itself, and the rollback target would become the thing being
390
+ # rolled back.
391
+ return {
392
+ "success": False,
393
+ "error": (
394
+ f"Candidate is {current!r}; only a verified candidate "
395
+ "awaiting approval can be activated"
396
+ ),
397
+ "status": current,
398
+ }
399
+
400
+ if not record.quarantine_dir_name:
401
+ return {
402
+ "success": False,
403
+ "error": "Candidate has no quarantined artifact to activate",
404
+ }
405
+
406
+ actor = "dashboard"
407
+
408
+ # Approval is recorded BEFORE the file moves, activation after. The
409
+ # order matters in both directions:
410
+ #
411
+ # * recording both afterwards means a successful activation whose log
412
+ # write fails leaves the skill live and the record saying it is not —
413
+ # so the next approval attempt is allowed, and activating twice
414
+ # overwrites the backup that rollback restores from.
415
+ # * recording both beforehand would claim a mutation is live when the
416
+ # file move failed.
417
+ #
418
+ # Approved-then-nothing is a state a person can act on. Live-but-unknown
419
+ # is not.
420
+ store.append_transition(
421
+ body.record_id, profile_id,
422
+ EvolutionStatus(current), EvolutionStatus.APPROVED,
423
+ actor_id=actor, reason="approved by request",
424
+ )
425
+ try:
426
+ activation = SkillActivator().activate(
427
+ record.skill_name, record.quarantine_dir_name, actor_id=actor,
428
+ )
429
+ except Exception as exc:
430
+ # The approval stands and the mutation did not go live. Recorded as
431
+ # such rather than left dangling.
432
+ store.append_transition(
433
+ body.record_id, profile_id,
434
+ EvolutionStatus.APPROVED, EvolutionStatus.REJECTED,
435
+ actor_id=actor, reason=f"activation failed: {exc}",
436
+ )
437
+ raise
438
+ store.append_transition(
439
+ body.record_id, profile_id,
440
+ EvolutionStatus.APPROVED, EvolutionStatus.ACTIVE,
441
+ actor_id=actor, reason="activated from quarantine",
442
+ metadata={"content_hash": activation.get("content_hash", "")},
443
+ )
444
+
445
+ return {
446
+ "success": True,
447
+ "record_id": body.record_id,
448
+ "skill_name": record.skill_name,
449
+ "status": EvolutionStatus.ACTIVE.value,
450
+ "live_path": activation.get("live_path"),
451
+ "backup_path": activation.get("backup_path"),
452
+ "content_hash": activation.get("content_hash"),
453
+ # So the caller knows how to undo it without reading the source.
454
+ "rollback": "POST /api/evolution/rollback",
455
+ }
456
+ except FileNotFoundError as exc:
457
+ return {"success": False, "error": f"Quarantined artifact missing: {exc}"}
458
+ except Exception:
459
+ logger.exception("evolution_approve error")
460
+ return {"success": False, "error": "Internal server error"}
461
+
462
+
463
+ class RollbackSkillRequest(BaseModel):
464
+ skill_name: str
465
+ #: Optional, so the reversal can be recorded against the candidate it
466
+ #: reverses. Without it the file is restored and the log still says active,
467
+ #: which reads as "this mutation is live" forever.
468
+ record_id: str = ""
469
+
470
+
471
+ @router.post("/api/evolution/rollback")
472
+ def evolution_rollback(request: Request, body: RollbackSkillRequest):
473
+ """Restore a skill's previous instructions after an approval goes wrong.
474
+
475
+ Approval is reversible, and it has to be: the reason a person is in this
476
+ loop is that a verified mutation can still be a bad one, and finding that
477
+ out happens after it is live.
478
+ """
479
+ _require_manage(request)
480
+ try:
481
+ from superlocalmemory.evolution.evolution_store import EvolutionStore
482
+ from superlocalmemory.evolution.skill_activator import SkillActivator
483
+ from superlocalmemory.evolution.types import EvolutionStatus
484
+
485
+ result = SkillActivator().rollback(body.skill_name)
486
+
487
+ # Record the reversal. A restored file with the log still reading
488
+ # "active" says the mutation is live when it is not, and that is the
489
+ # record an audit would read.
490
+ if body.record_id:
491
+ try:
492
+ store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
493
+ pid = get_active_profile()
494
+ latest = store.get_latest_status(body.record_id, pid)
495
+ store.append_transition(
496
+ body.record_id, pid,
497
+ latest or EvolutionStatus.ACTIVE,
498
+ EvolutionStatus.ROLLED_BACK,
499
+ actor_id="dashboard", reason="rolled back by request",
500
+ )
501
+ except Exception as exc: # pragma: no cover — the file is restored
502
+ logger.warning("rollback recorded no transition: %s", exc)
503
+ result["transition_recorded"] = False
504
+
505
+ return {"success": True, **result}
506
+ except FileNotFoundError as exc:
507
+ return {"success": False, "error": f"No backup to restore: {exc}"}
508
+ except Exception:
509
+ logger.exception("evolution_rollback error")
510
+ return {"success": False, "error": "Internal server error"}
@@ -81,6 +81,14 @@ async def ingest(req: IngestRequest, request: Request):
81
81
  request,
82
82
  actor_kind="http-ingest",
83
83
  )
84
+ # Machine authentication says which process is calling. It does not say
85
+ # whether that caller's user may write to this workspace. Every other
86
+ # write route asks that question; this one did not, so a viewer could
87
+ # write through it in a workspace that requires a login.
88
+ from superlocalmemory.access.rbac import Permission
89
+ from superlocalmemory.server.rbac_enforce import require_permission
90
+
91
+ require_permission(request, Permission.WRITE, profile=engine._profile_id)
84
92
  command = build_engine_ingestion_command(engine)
85
93
  receipt, created = command.submit_with_status(IngestionRequest(
86
94
  content=req.content,
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import logging
8
8
  import sqlite3
9
9
  from pathlib import Path
10
+ from superlocalmemory.learning.signal_kinds import FEEDBACK_ONLY_SQL
10
11
 
11
12
  logger = logging.getLogger("superlocalmemory.routes.learning")
12
13
 
@@ -24,7 +25,7 @@ class ReadOnlyRankerStore:
24
25
  try:
25
26
  row = connection.execute(
26
27
  "SELECT COUNT(*) AS count FROM learning_signals "
27
- "WHERE profile_id = ?",
28
+ f"WHERE profile_id = ?{FEEDBACK_ONLY_SQL}",
28
29
  (profile_id,),
29
30
  ).fetchone()
30
31
  return int(row["count"] if row else 0)
@@ -13,6 +13,11 @@ from typing import Optional
13
13
 
14
14
  from fastapi import APIRouter, HTTPException, Query, Request
15
15
 
16
+ from superlocalmemory.core.config import BROWSE_PAGE_SIZE
17
+ from superlocalmemory.storage.database import (
18
+ visible_fact_clause_for_connection,
19
+ )
20
+
16
21
  from .helpers import (
17
22
  SearchRequest,
18
23
  dict_factory,
@@ -131,10 +136,20 @@ def _admit_http_mutation(request: Request, operation: str) -> None:
131
136
  resolve_principal,
132
137
  )
133
138
 
139
+ from superlocalmemory.core.admission import _company_mode_active
140
+
141
+ # Both switches, the same rule the other entry points use. Reading
142
+ # config.toml alone left this path treating a workspace as personal after
143
+ # the dashboard toggle had turned per-user access on -- so a write refused
144
+ # everywhere else was admitted here.
134
145
  deployment = getattr(request.app.state, "deployment", None)
135
- is_enterprise = bool(deployment and deployment.is_enterprise)
136
- tier = "enterprise" if is_enterprise else "personal"
137
- mode = "company" if is_enterprise else "local"
146
+ if deployment is None:
147
+ from superlocalmemory.core.admission import _resolve_deployment
148
+
149
+ deployment = _resolve_deployment()
150
+ company = _company_mode_active(deployment)
151
+ tier = "enterprise" if company else "personal"
152
+ mode = "company" if company else "local"
138
153
 
139
154
  principal_info = resolve_principal(request)
140
155
  principal = str(principal_info.get("user_id") or "")
@@ -388,7 +403,7 @@ async def get_memories(
388
403
  cluster_id: Optional[int] = None,
389
404
  min_importance: Optional[int] = None,
390
405
  tags: Optional[str] = None,
391
- limit: int = Query(50, ge=1, le=200),
406
+ limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
392
407
  offset: int = Query(0, ge=0),
393
408
  filter: Optional[str] = Query(
394
409
  None,
@@ -444,15 +459,24 @@ async def get_memories(
444
459
  scope_where = "profile_id = ?"
445
460
  scope_params = [active_profile]
446
461
 
462
+ # Withheld rows are excluded from BOTH the page and the total. On
463
+ # the author's store this list served 24 model-authored summaries
464
+ # in its first 50 rows and reported 5,218 memories against 3,919
465
+ # real ones -- the count a user reads, inflated by exactly the
466
+ # 1,299 rows 4.0.10 withheld.
467
+ visible = visible_fact_clause_for_connection(conn)
447
468
  query = (
448
469
  "SELECT fact_id as id, memory_id, content, fact_type as category, "
449
470
  "confidence as importance, access_count, "
450
471
  "created_at, created_at as updated_at, "
451
472
  "session_id as project_name, scope, shared_with "
452
- f"FROM atomic_facts WHERE {scope_where}"
473
+ f"FROM atomic_facts WHERE {scope_where}{visible}"
453
474
  )
454
475
  params = list(scope_params)
455
- count_base = f"SELECT COUNT(*) as total FROM atomic_facts WHERE {scope_where}"
476
+ count_base = (
477
+ "SELECT COUNT(*) as total FROM atomic_facts "
478
+ f"WHERE {scope_where}{visible}"
479
+ )
456
480
  else:
457
481
  query = """
458
482
  SELECT id, content, summary, category, project_name, project_path,
@@ -656,6 +680,11 @@ async def search_memories(request: Request, body: SearchRequest):
656
680
  lambda: engine.recall(
657
681
  body.query, limit=body.limit, fast=True,
658
682
  window=_window or None,
683
+ # Name the surface. A recall with no name leaves no record
684
+ # an outcome can be matched to, and a search typed into the
685
+ # dashboard is one continuous thread of use, not a
686
+ # conversation turn.
687
+ session_id=f"dashboard:{get_active_profile()}",
659
688
  ),
660
689
  )
661
690
  # A run_in_executor thread cannot be cancelled, and wait_for() on it
@@ -803,6 +832,13 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
803
832
  except Exception:
804
833
  raise _internal_error("Summary generation error")
805
834
 
835
+ # Say why, not just what. The response already reported that a summary was
836
+ # assembled rather than written; someone seeing a plainer result than they
837
+ # expected still had no way to learn that a written one needs a model, and
838
+ # would reasonably read the feature as broken.
839
+ from superlocalmemory.core.mode_capability import llm_capability
840
+
841
+ generated = str(getattr(result, "generated_by", "") or "")
806
842
  return {
807
843
  "kind": result.kind,
808
844
  "profile_id": result.profile_id,
@@ -812,6 +848,12 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
812
848
  "source_fact_ids": result.source_fact_ids,
813
849
  "source_count": len(result.source_fact_ids),
814
850
  "metadata": result.metadata,
851
+ "capability": llm_capability(
852
+ cfg,
853
+ # This call has just been made, so its outcome is the honest answer
854
+ # about availability -- better than asking the configuration again.
855
+ llm_reachable=generated.startswith("llm"),
856
+ ),
815
857
  }
816
858
 
817
859
 
@@ -992,7 +1034,7 @@ async def get_clusters(request: Request):
992
1034
  async def get_cluster_detail(
993
1035
  request: Request,
994
1036
  cluster_id: str,
995
- limit: int = Query(50, ge=1, le=200),
1037
+ limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
996
1038
  ):
997
1039
  """Get detailed view of a specific cluster (scene)."""
998
1040
  try:
@@ -17,6 +17,9 @@ import sqlite3
17
17
  from fastapi import APIRouter, Query
18
18
  from fastapi.responses import JSONResponse
19
19
  from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
20
+ from superlocalmemory.storage.database import (
21
+ visible_fact_clause_for_connection,
22
+ )
20
23
 
21
24
  logger = logging.getLogger(__name__)
22
25
 
@@ -82,6 +85,7 @@ async def get_timeline(
82
85
  "confidence, session_id "
83
86
  "FROM atomic_facts "
84
87
  "WHERE profile_id = ? AND created_at >= ? AND lifecycle = 'active' "
88
+ f"{visible_fact_clause_for_connection(conn)} "
85
89
  "ORDER BY created_at DESC LIMIT ?",
86
90
  (pid, start_date, INTERNAL_CEILING),
87
91
  ).fetchall()