superlocalmemory 4.0.9 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- 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 +308 -20
- 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 +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- 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/fact_consolidator.py +316 -125
- 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.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- 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/store_pipeline.py +78 -3
- 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 +51 -7
- 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/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- 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 +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -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/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- 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/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- 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 +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- 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 +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- 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 +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
json.dumps(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
@@ -12,7 +12,7 @@ import gzip
|
|
|
12
12
|
import hashlib
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
-
from typing import Optional
|
|
15
|
+
from typing import Any, Optional
|
|
16
16
|
from datetime import datetime, timezone
|
|
17
17
|
|
|
18
18
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
|
|
@@ -78,7 +78,22 @@ async def export_memories(
|
|
|
78
78
|
use_v3 = False
|
|
79
79
|
|
|
80
80
|
if use_v3:
|
|
81
|
+
# Withheld summaries are excluded, and this is about IMPORT, not
|
|
82
|
+
# tidiness. Import re-ingests every record through the normal
|
|
83
|
+
# pipeline, which mints a fresh memory with quarantined = 0 — so an
|
|
84
|
+
# export taken here and imported anywhere would resurrect all 1,195
|
|
85
|
+
# model-written rows as though the owner had written them, on a
|
|
86
|
+
# machine where nothing had gone wrong. The repair would then have
|
|
87
|
+
# to run again there.
|
|
88
|
+
#
|
|
89
|
+
# Nothing the owner wrote is lost: these are derived artefacts, the
|
|
90
|
+
# consolidator regenerates them from the facts that ARE exported,
|
|
91
|
+
# and their text is kept in consolidated_summaries. (That table is
|
|
92
|
+
# not in this export either — it is a view, not a memory. Worth
|
|
93
|
+
# revisiting when export covers derived state.)
|
|
81
94
|
query = "SELECT * FROM atomic_facts WHERE profile_id = ?"
|
|
95
|
+
if _has_column(cursor, "atomic_facts", "quarantined"):
|
|
96
|
+
query += " AND COALESCE(quarantined, 0) = 0"
|
|
82
97
|
params = [active_profile]
|
|
83
98
|
if category:
|
|
84
99
|
query += " AND fact_type = ?"
|
|
@@ -154,6 +169,19 @@ async def export_memories(
|
|
|
154
169
|
raise _internal_error("Export error")
|
|
155
170
|
|
|
156
171
|
|
|
172
|
+
def _has_column(cursor: Any, table: str, column: str) -> bool:
|
|
173
|
+
"""Whether ``table`` carries ``column`` in this database.
|
|
174
|
+
|
|
175
|
+
Presence-guarded because ``quarantined`` arrives with a migration and this
|
|
176
|
+
route must keep working on a store the engine has not opened.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
cursor.execute(f"PRAGMA table_info({table})")
|
|
180
|
+
return any(row[1] == column for row in cursor.fetchall())
|
|
181
|
+
except Exception: # noqa: BLE001 -- an export must not fail over a probe
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
157
185
|
@router.post("/api/import")
|
|
158
186
|
async def import_memories(request: Request, file: UploadFile = File(...)):
|
|
159
187
|
"""Import memories from JSON file using V3 engine."""
|
|
@@ -84,7 +84,19 @@ def list_entities(
|
|
|
84
84
|
|
|
85
85
|
conn = get_read_connection(engine._config.db_path)
|
|
86
86
|
try:
|
|
87
|
-
|
|
87
|
+
# Hide the placeholder that dated facts with no recognised entity
|
|
88
|
+
# attach their temporal events to (core/store_pipeline.py,
|
|
89
|
+
# _ensure_unresolved_entity). It is a hook for a foreign key, not a
|
|
90
|
+
# concept, and it would otherwise appear here as a nameless entity and
|
|
91
|
+
# be counted in the total the owner reads as "entities I have".
|
|
92
|
+
#
|
|
93
|
+
# Only the presentation layer needs this. The other readers of
|
|
94
|
+
# canonical_entities are unaffected on inspection: graph_pruner uses
|
|
95
|
+
# `NOT IN (SELECT entity_id ...)` to find orphaned edges, where
|
|
96
|
+
# including it is correct; community_summary builds from graph edges and
|
|
97
|
+
# the placeholder has none; scale_engine counts for backend sync, not
|
|
98
|
+
# for display.
|
|
99
|
+
where = ["ce.profile_id = ?", "ce.entity_type != 'unresolved'"]
|
|
88
100
|
params: list[object] = [profile]
|
|
89
101
|
if entity_type and entity_type.lower() != "all":
|
|
90
102
|
where.append("ce.entity_type = ? COLLATE NOCASE")
|