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.
- package/.claude-plugin/marketplace.json +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +357 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- 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
|
-
|
|
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":
|
|
182
|
-
"vector_backend":
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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()
|