superlocalmemory 3.8.13 → 4.0.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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- 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 +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- 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 +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import sqlite3
|
|
10
|
+
from pathlib import Path
|
|
9
11
|
from typing import Any
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger("superlocalmemory.audit")
|
|
@@ -36,6 +38,308 @@ def _context(
|
|
|
36
38
|
return profile_id, context
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
def _invalidate_context_cache_for_fact(
|
|
42
|
+
engine: Any,
|
|
43
|
+
fact_id: str,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Best-effort removal of context cache entries referencing fact_id.
|
|
46
|
+
|
|
47
|
+
Opens the cache SQLite file directly (no ContextCache instance needed).
|
|
48
|
+
Tries the two standard cache locations relative to the database directory:
|
|
49
|
+
a ``context-cache`` subdirectory (isolation convention used in tests) and
|
|
50
|
+
the database directory itself (production default). Fail-open: any error
|
|
51
|
+
is silently discarded.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
db_parent = Path(engine._db.db_path).parent
|
|
55
|
+
except AttributeError:
|
|
56
|
+
return
|
|
57
|
+
pattern = f'%"{fact_id}"%'
|
|
58
|
+
candidates = [
|
|
59
|
+
db_parent / "context-cache" / "active_brain_cache.db",
|
|
60
|
+
db_parent / "active_brain_cache.db",
|
|
61
|
+
]
|
|
62
|
+
for cache_path in candidates:
|
|
63
|
+
if not cache_path.exists():
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
conn = sqlite3.connect(str(cache_path), timeout=2.0)
|
|
67
|
+
try:
|
|
68
|
+
conn.execute(
|
|
69
|
+
"DELETE FROM context_entries WHERE fact_ids LIKE ?",
|
|
70
|
+
(pattern,),
|
|
71
|
+
)
|
|
72
|
+
conn.commit()
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sync_vector_ann(
|
|
80
|
+
retrieval: Any,
|
|
81
|
+
fact_id: str,
|
|
82
|
+
profile_id: str,
|
|
83
|
+
embedding: list[float] | None,
|
|
84
|
+
*,
|
|
85
|
+
operation: str,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Replace or remove a fact's entry in the vector store and ANN index.
|
|
88
|
+
|
|
89
|
+
``operation`` is either ``"update"`` or ``"delete"``. For updates,
|
|
90
|
+
``embedding`` must be provided; the semantic representations are replaced
|
|
91
|
+
in place so a correction is reflected by every semantic query. Fail-open:
|
|
92
|
+
errors are logged and ignored so a projection hiccup never blocks the
|
|
93
|
+
authoritative write.
|
|
94
|
+
"""
|
|
95
|
+
vector_store = getattr(retrieval, "_vector_store", None)
|
|
96
|
+
ann_index = getattr(retrieval, "_ann_index", None)
|
|
97
|
+
if operation == "update" and embedding:
|
|
98
|
+
if vector_store is not None:
|
|
99
|
+
try:
|
|
100
|
+
# upsert replaces the stored vector for an existing fact_id.
|
|
101
|
+
vector_store.upsert(fact_id, profile_id, embedding)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
logger.warning("vector_store upsert failed for %s: %s", fact_id[:16], exc)
|
|
104
|
+
if ann_index is not None:
|
|
105
|
+
try:
|
|
106
|
+
# add() is upsert-semantic: it overwrites an existing entry.
|
|
107
|
+
ann_index.add(fact_id, embedding)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.warning("ann_index add failed for %s: %s", fact_id[:16], exc)
|
|
110
|
+
elif operation == "delete":
|
|
111
|
+
if vector_store is not None:
|
|
112
|
+
try:
|
|
113
|
+
vector_store.delete(fact_id)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.warning("vector_store.delete failed for %s: %s", fact_id[:16], exc)
|
|
116
|
+
if ann_index is not None:
|
|
117
|
+
try:
|
|
118
|
+
ann_index.remove(fact_id)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.warning("ann_index.remove failed for %s: %s", fact_id[:16], exc)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _converge_update_projections(
|
|
124
|
+
engine: Any,
|
|
125
|
+
fact_id: str,
|
|
126
|
+
content: str,
|
|
127
|
+
profile_id: str,
|
|
128
|
+
embedding: list[float] | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Fan out a fact correction to every derived projection.
|
|
131
|
+
|
|
132
|
+
Projection obligations on correction:
|
|
133
|
+
- temporal_events.description — updated to new content
|
|
134
|
+
- fact_context.contextual_description — cleared of stale tokens (row kept)
|
|
135
|
+
- fact_expansion_fts.alt_keys — cleared of stale tokens (row kept)
|
|
136
|
+
- context cache — entry deleted so stale content is never served
|
|
137
|
+
- BM25 live channel — replaced (one entry, no duplicate)
|
|
138
|
+
- vector store + ANN index — update called
|
|
139
|
+
"""
|
|
140
|
+
db = engine._db
|
|
141
|
+
|
|
142
|
+
# Temporal events: description must match new content
|
|
143
|
+
db.update_temporal_event_description(fact_id, content)
|
|
144
|
+
|
|
145
|
+
# Fact context: keep row alive but clear stale description/keywords
|
|
146
|
+
db.store_fact_context(fact_id, profile_id, "", "")
|
|
147
|
+
|
|
148
|
+
# Expansion FTS: keep row alive but replace with empty alt_keys
|
|
149
|
+
db.reset_fact_expansion(fact_id, "")
|
|
150
|
+
|
|
151
|
+
# Context cache: delete stale entries so the hot path returns nothing stale
|
|
152
|
+
_invalidate_context_cache_for_fact(engine, fact_id)
|
|
153
|
+
|
|
154
|
+
# BM25 live channel: replace (remove then add) to guarantee exactly one entry
|
|
155
|
+
retrieval = getattr(engine, "_retrieval_engine", None)
|
|
156
|
+
bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
|
|
157
|
+
if bm25 is not None and hasattr(bm25, "update_fact"):
|
|
158
|
+
bm25.update_fact(fact_id, content, profile_id)
|
|
159
|
+
elif bm25 is not None:
|
|
160
|
+
# Fallback: add without dedup (legacy path)
|
|
161
|
+
bm25.add(fact_id, content, profile_id)
|
|
162
|
+
|
|
163
|
+
# Vector store + ANN index
|
|
164
|
+
if retrieval is not None:
|
|
165
|
+
_sync_vector_ann(retrieval, fact_id, profile_id, embedding, operation="update")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
169
|
+
orchestrator = get_orchestrator()
|
|
170
|
+
if orchestrator is not None:
|
|
171
|
+
orchestrator.sync_changed_fact(fact_id)
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.warning("Derived projection update sync failed for %s", fact_id[:16])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _purge_delete_projections(
|
|
177
|
+
engine: Any,
|
|
178
|
+
fact_id: str,
|
|
179
|
+
profile_id: str,
|
|
180
|
+
*,
|
|
181
|
+
memory_id: str | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Purge every derived representation of a fact before the canonical row is gone.
|
|
184
|
+
|
|
185
|
+
Projection obligations on deletion:
|
|
186
|
+
- memories (raw memory record that sourced this fact)
|
|
187
|
+
- bm25_tokens (persisted tokens in the DB table)
|
|
188
|
+
- fact_expansion_fts (BM25 alt-key expansion entry)
|
|
189
|
+
- graph_edges (any edge where fact_id is source or target)
|
|
190
|
+
- memory_scenes (fact_id removed from JSON arrays; empty scenes deleted)
|
|
191
|
+
- fact_context (contextual description entry)
|
|
192
|
+
- context cache (entries referencing this fact_id)
|
|
193
|
+
- BM25 live channel (evicted from in-memory corpus)
|
|
194
|
+
- vector store + ANN index (delete / remove called)
|
|
195
|
+
"""
|
|
196
|
+
db = engine._db
|
|
197
|
+
|
|
198
|
+
if memory_id:
|
|
199
|
+
try:
|
|
200
|
+
siblings = db.execute(
|
|
201
|
+
"SELECT 1 FROM atomic_facts "
|
|
202
|
+
"WHERE memory_id = ? AND profile_id = ? LIMIT 1",
|
|
203
|
+
(memory_id, profile_id),
|
|
204
|
+
)
|
|
205
|
+
if not siblings:
|
|
206
|
+
db.execute(
|
|
207
|
+
"DELETE FROM memories WHERE memory_id = ? AND profile_id = ?",
|
|
208
|
+
(memory_id, profile_id),
|
|
209
|
+
)
|
|
210
|
+
except Exception:
|
|
211
|
+
logger.debug("memory delete by id skipped for %s", fact_id[:16])
|
|
212
|
+
else:
|
|
213
|
+
db.delete_memory_for_fact(fact_id, profile_id)
|
|
214
|
+
|
|
215
|
+
# Persistent BM25 tokens
|
|
216
|
+
db.delete_bm25_tokens_for_fact(fact_id)
|
|
217
|
+
|
|
218
|
+
# FTS expansion entry
|
|
219
|
+
db.upsert_fact_expansion(fact_id, "")
|
|
220
|
+
|
|
221
|
+
# Knowledge graph edges
|
|
222
|
+
db.delete_graph_edges_for_fact(fact_id)
|
|
223
|
+
|
|
224
|
+
# Memory scenes — remove fact_id from JSON arrays
|
|
225
|
+
db.remove_fact_from_scenes(fact_id, profile_id)
|
|
226
|
+
|
|
227
|
+
# Fact context description
|
|
228
|
+
db.delete_fact_context(fact_id)
|
|
229
|
+
|
|
230
|
+
# Context cache
|
|
231
|
+
_invalidate_context_cache_for_fact(engine, fact_id)
|
|
232
|
+
|
|
233
|
+
# BM25 live channel (in-memory eviction)
|
|
234
|
+
retrieval = getattr(engine, "_retrieval_engine", None)
|
|
235
|
+
bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
|
|
236
|
+
if bm25 is not None and hasattr(bm25, "remove_fact"):
|
|
237
|
+
bm25.remove_fact(fact_id)
|
|
238
|
+
|
|
239
|
+
# Vector store + ANN index
|
|
240
|
+
if retrieval is not None:
|
|
241
|
+
_sync_vector_ann(retrieval, fact_id, profile_id, None, operation="delete")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
245
|
+
orchestrator = get_orchestrator()
|
|
246
|
+
if orchestrator is not None:
|
|
247
|
+
orchestrator.sync_deleted_fact(fact_id)
|
|
248
|
+
except Exception:
|
|
249
|
+
logger.warning("Derived projection deletion sync failed for %s", fact_id[:16])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _canonical_present(engine: Any, fact_id: str, profile_id: str) -> bool:
|
|
253
|
+
return bool(engine._db.execute(
|
|
254
|
+
"SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ? LIMIT 1",
|
|
255
|
+
(fact_id, profile_id),
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _memory_present(engine: Any, memory_id: str, profile_id: str) -> bool:
|
|
260
|
+
return bool(engine._db.execute(
|
|
261
|
+
"SELECT 1 FROM memories WHERE memory_id = ? AND profile_id = ? LIMIT 1",
|
|
262
|
+
(memory_id, profile_id),
|
|
263
|
+
))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _memory_has_facts(engine: Any, memory_id: str, profile_id: str) -> bool:
|
|
267
|
+
return bool(engine._db.execute(
|
|
268
|
+
"SELECT 1 FROM atomic_facts WHERE memory_id = ? AND profile_id = ? LIMIT 1",
|
|
269
|
+
(memory_id, profile_id),
|
|
270
|
+
))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _finalize_erasure(
|
|
274
|
+
engine: Any,
|
|
275
|
+
service: Any,
|
|
276
|
+
op_ctx: Any,
|
|
277
|
+
fact_id: str,
|
|
278
|
+
profile_id: str,
|
|
279
|
+
memory_id: str | None,
|
|
280
|
+
erasure_id: str,
|
|
281
|
+
requested_at: float,
|
|
282
|
+
*,
|
|
283
|
+
requested_by: str,
|
|
284
|
+
tombstoned: bool,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
try:
|
|
287
|
+
from superlocalmemory.core.transactions import ErasureProofRecord
|
|
288
|
+
from superlocalmemory.core.transactions.erasure import tombstone_memory_id
|
|
289
|
+
|
|
290
|
+
canonical_absent = not _canonical_present(engine, fact_id, profile_id)
|
|
291
|
+
extra = [ErasureProofRecord(
|
|
292
|
+
owner="canonical", erased=canonical_absent, checksum="",
|
|
293
|
+
residue=() if canonical_absent else (fact_id,),
|
|
294
|
+
)]
|
|
295
|
+
if memory_id:
|
|
296
|
+
memory_ok = (
|
|
297
|
+
not _memory_present(engine, memory_id, profile_id)
|
|
298
|
+
or _memory_has_facts(engine, memory_id, profile_id)
|
|
299
|
+
)
|
|
300
|
+
extra.append(ErasureProofRecord(
|
|
301
|
+
owner="memory", erased=memory_ok, checksum="",
|
|
302
|
+
residue=() if memory_ok else (memory_id,),
|
|
303
|
+
))
|
|
304
|
+
stored_mid = tombstone_memory_id(engine._db, profile_id, fact_id)
|
|
305
|
+
if stored_mid and stored_mid != memory_id:
|
|
306
|
+
stored_mid_ok = (
|
|
307
|
+
not _memory_present(engine, stored_mid, profile_id)
|
|
308
|
+
or _memory_has_facts(engine, stored_mid, profile_id)
|
|
309
|
+
)
|
|
310
|
+
if not stored_mid_ok:
|
|
311
|
+
extra.append(ErasureProofRecord(
|
|
312
|
+
owner="memory_provenance", erased=False, checksum="",
|
|
313
|
+
residue=(stored_mid,),
|
|
314
|
+
))
|
|
315
|
+
receipt = service.finalize(
|
|
316
|
+
engine._db, op_ctx,
|
|
317
|
+
subject_type="fact", subject_id=fact_id,
|
|
318
|
+
requested_by=requested_by, requested_at=requested_at,
|
|
319
|
+
extra_proofs=extra,
|
|
320
|
+
)
|
|
321
|
+
verified = receipt.all_erased and receipt.persisted and tombstoned
|
|
322
|
+
if not verified:
|
|
323
|
+
logger.error(
|
|
324
|
+
"Erasure not verified for %s: state=%s persisted=%s "
|
|
325
|
+
"tombstoned=%s residue=%s",
|
|
326
|
+
fact_id[:16], receipt.state, receipt.persisted, tombstoned,
|
|
327
|
+
[p.owner for p in receipt.proofs if not p.erased],
|
|
328
|
+
)
|
|
329
|
+
return {
|
|
330
|
+
"erasure_id": erasure_id,
|
|
331
|
+
"erasure_verified": verified,
|
|
332
|
+
"erasure_state": receipt.state,
|
|
333
|
+
}
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
logger.error("Erasure finalize failed for %s: %s", fact_id[:16], exc)
|
|
336
|
+
return {
|
|
337
|
+
"erasure_id": erasure_id,
|
|
338
|
+
"erasure_verified": False,
|
|
339
|
+
"erasure_state": "FAILED",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
39
343
|
def delete_fact_authorized(
|
|
40
344
|
engine: Any,
|
|
41
345
|
fact_id: str,
|
|
@@ -46,6 +350,19 @@ def delete_fact_authorized(
|
|
|
46
350
|
idempotency_key: str | None = None,
|
|
47
351
|
) -> dict[str, Any]:
|
|
48
352
|
"""Authorize, delete one profile-owned fact, then emit post hooks."""
|
|
353
|
+
import time as _time
|
|
354
|
+
import uuid
|
|
355
|
+
|
|
356
|
+
from superlocalmemory.core.transactions import OperationContext
|
|
357
|
+
from superlocalmemory.core.transactions.concrete_owners import (
|
|
358
|
+
build_erasure_service,
|
|
359
|
+
)
|
|
360
|
+
from superlocalmemory.core.transactions.erasure import (
|
|
361
|
+
is_tombstoned,
|
|
362
|
+
tombstone_memory_id,
|
|
363
|
+
)
|
|
364
|
+
from superlocalmemory.storage.erasure_fence import clear_erasing, mark_erasing
|
|
365
|
+
|
|
49
366
|
profile_id, context = _context(
|
|
50
367
|
engine,
|
|
51
368
|
"delete",
|
|
@@ -53,40 +370,122 @@ def delete_fact_authorized(
|
|
|
53
370
|
trusted_actor_id=trusted_actor_id,
|
|
54
371
|
source_agent_id=source_agent_id,
|
|
55
372
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
else
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
373
|
+
|
|
374
|
+
rows = engine._db.execute(
|
|
375
|
+
"SELECT content, memory_id FROM atomic_facts "
|
|
376
|
+
"WHERE fact_id = ? AND profile_id = ? LIMIT 1",
|
|
377
|
+
(fact_id, profile_id),
|
|
378
|
+
)
|
|
379
|
+
exists = bool(rows)
|
|
380
|
+
content_preview = dict(rows[0]).get("content", "")[:80] if exists else ""
|
|
381
|
+
memory_id = dict(rows[0]).get("memory_id") if exists else None
|
|
382
|
+
|
|
383
|
+
erasure_id = uuid.uuid4().hex
|
|
384
|
+
requested_at = _time.time()
|
|
385
|
+
op_ctx = OperationContext(
|
|
386
|
+
operation_id=erasure_id, profile_id=profile_id,
|
|
387
|
+
subject_id=fact_id, fact_ids=(fact_id,),
|
|
388
|
+
)
|
|
389
|
+
service = build_erasure_service(engine)
|
|
390
|
+
|
|
391
|
+
# A tombstoned fact whose canonical row is already gone is a partially
|
|
392
|
+
# completed prior erasure (e.g. a transient raw-memory delete failure left
|
|
393
|
+
# the source memory behind). Resume cleanup from the stored memory_id
|
|
394
|
+
# instead of reporting the fact "not found".
|
|
395
|
+
resuming = False
|
|
396
|
+
if not exists:
|
|
397
|
+
with engine._db.raw_connection() as _conn:
|
|
398
|
+
resuming = is_tombstoned(_conn, profile_id, fact_id)
|
|
399
|
+
if not resuming:
|
|
70
400
|
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
71
|
-
|
|
72
|
-
|
|
401
|
+
memory_id = tombstone_memory_id(engine._db, profile_id, fact_id)
|
|
402
|
+
|
|
403
|
+
remove_result = None
|
|
404
|
+
if exists:
|
|
405
|
+
try:
|
|
406
|
+
remove_result = service.remove(engine._db, op_ctx, memory_id=memory_id)
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
logger.error("Erasure remove failed for %s: %s", fact_id[:16], exc)
|
|
409
|
+
if (
|
|
410
|
+
remove_result is None
|
|
411
|
+
or not remove_result.spine_ok
|
|
412
|
+
or not remove_result.tombstoned
|
|
413
|
+
):
|
|
414
|
+
erasure = _finalize_erasure(
|
|
415
|
+
engine, service, op_ctx, fact_id, profile_id, memory_id,
|
|
416
|
+
erasure_id, requested_at, requested_by=trusted_actor_id,
|
|
417
|
+
tombstoned=bool(remove_result and remove_result.tombstoned),
|
|
418
|
+
)
|
|
419
|
+
logger.error(
|
|
420
|
+
"Erasure blocked (residue or unverified tombstone) for %s — "
|
|
421
|
+
"canonical retained",
|
|
422
|
+
fact_id[:16],
|
|
423
|
+
)
|
|
424
|
+
return {
|
|
425
|
+
"ok": False,
|
|
426
|
+
"error": "erasure incomplete: projection residue",
|
|
427
|
+
"retryable": True,
|
|
428
|
+
**erasure,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# Fence the fact for the purge window so a concurrent materializer cannot
|
|
432
|
+
# re-write its projections after the durable tombstone is committed. The
|
|
433
|
+
# tombstone remains the cross-process signal; this is the in-process guard.
|
|
434
|
+
mark_erasing(profile_id, fact_id)
|
|
73
435
|
try:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
436
|
+
if exists:
|
|
437
|
+
if canonical_runtime is not None:
|
|
438
|
+
result = dict(canonical_runtime.delete_fact(
|
|
439
|
+
profile_id, fact_id, idempotency_key=idempotency_key,
|
|
440
|
+
))
|
|
441
|
+
if not result.get("ok"):
|
|
442
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
443
|
+
if not content_preview:
|
|
444
|
+
content_preview = str(result.get("content_preview", ""))
|
|
445
|
+
else:
|
|
446
|
+
engine._db.delete_fact(fact_id, profile_id=profile_id)
|
|
447
|
+
|
|
448
|
+
# Purge projections for a fresh delete and re-run (idempotently) for a
|
|
449
|
+
# resumed cleanup so an orphaned source memory is reclaimed on retry.
|
|
450
|
+
_purge_delete_projections(engine, fact_id, profile_id, memory_id=memory_id)
|
|
451
|
+
|
|
452
|
+
if exists:
|
|
453
|
+
tombstoned = bool(remove_result and remove_result.tombstoned)
|
|
454
|
+
else:
|
|
455
|
+
with engine._db.raw_connection() as _conn:
|
|
456
|
+
tombstoned = is_tombstoned(_conn, profile_id, fact_id)
|
|
457
|
+
|
|
458
|
+
erasure = _finalize_erasure(
|
|
459
|
+
engine, service, op_ctx, fact_id, profile_id, memory_id, erasure_id,
|
|
460
|
+
requested_at, requested_by=trusted_actor_id, tombstoned=tombstoned,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
engine._hooks.run_post("delete", context)
|
|
464
|
+
logger.info(
|
|
465
|
+
"DELETE fact_id=%s actor=%s source_agent=%s content=%s",
|
|
466
|
+
fact_id[:16], trusted_actor_id, source_agent_id, content_preview,
|
|
467
|
+
)
|
|
468
|
+
erasure_verified = bool(erasure.get("erasure_verified", False))
|
|
469
|
+
if not erasure_verified:
|
|
470
|
+
logger.error(
|
|
471
|
+
"Erasure unverified for %s — returning retryable failure",
|
|
472
|
+
fact_id[:16],
|
|
473
|
+
)
|
|
474
|
+
return {
|
|
475
|
+
"ok": False,
|
|
476
|
+
"retryable": True,
|
|
477
|
+
"deleted": fact_id,
|
|
478
|
+
"content_preview": content_preview,
|
|
479
|
+
**erasure,
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
"ok": True,
|
|
483
|
+
"deleted": fact_id,
|
|
484
|
+
"content_preview": content_preview,
|
|
485
|
+
**erasure,
|
|
486
|
+
}
|
|
487
|
+
finally:
|
|
488
|
+
clear_erasing(profile_id, fact_id)
|
|
90
489
|
|
|
91
490
|
|
|
92
491
|
def update_fact_authorized(
|
|
@@ -99,7 +498,7 @@ def update_fact_authorized(
|
|
|
99
498
|
canonical_runtime: Any | None = None,
|
|
100
499
|
idempotency_key: str | None = None,
|
|
101
500
|
) -> dict[str, Any]:
|
|
102
|
-
"""Authorize a fact
|
|
501
|
+
"""Authorize a fact correction and converge every derived projection."""
|
|
103
502
|
if not content or not content.strip():
|
|
104
503
|
return {"ok": False, "error": "content cannot be empty"}
|
|
105
504
|
content = content.strip()
|
|
@@ -120,6 +519,7 @@ def update_fact_authorized(
|
|
|
120
519
|
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
121
520
|
old_content = dict(rows[0]).get("content", "")[:80]
|
|
122
521
|
updates: dict[str, Any] = {"content": content}
|
|
522
|
+
embedding: list[float] | None = None
|
|
123
523
|
if engine._embedder:
|
|
124
524
|
try:
|
|
125
525
|
embedding = engine._embedder.embed(content)
|
|
@@ -143,17 +543,10 @@ def update_fact_authorized(
|
|
|
143
543
|
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
144
544
|
else:
|
|
145
545
|
engine._db.update_fact(fact_id, updates, profile_id=profile_id)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
orchestrator.sync_changed_fact(fact_id)
|
|
151
|
-
except Exception:
|
|
152
|
-
logger.warning("Derived projection update sync failed for %s", fact_id[:16])
|
|
153
|
-
retrieval = getattr(engine, "_retrieval_engine", None)
|
|
154
|
-
bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
|
|
155
|
-
if bm25:
|
|
156
|
-
bm25.add(fact_id, content, profile_id)
|
|
546
|
+
|
|
547
|
+
# Converge every derived projection to the corrected content
|
|
548
|
+
_converge_update_projections(engine, fact_id, content, profile_id, embedding)
|
|
549
|
+
|
|
157
550
|
engine._hooks.run_post("update", context)
|
|
158
551
|
logger.info(
|
|
159
552
|
"UPDATE fact_id=%s actor=%s source_agent=%s old=%s new=%s",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Declarative per-operation policy record for the V4 admission layer.
|
|
5
|
+
|
|
6
|
+
OperationPolicy is a plain frozen dataclass — a pure data declaration with no
|
|
7
|
+
behaviour. The OperationPolicyRegistry owns all evaluation logic. A policy
|
|
8
|
+
declares WHAT is required; the registry decides WHAT to DO when requirements
|
|
9
|
+
are not met.
|
|
10
|
+
|
|
11
|
+
Part of SuperLocalMemory V4 | Phase 4: Admission/Policy Layer
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import FrozenSet
|
|
18
|
+
|
|
19
|
+
from superlocalmemory.core.actor_context import ActorRole, Transport
|
|
20
|
+
from superlocalmemory.core.operation_request import OperationKind
|
|
21
|
+
|
|
22
|
+
# Pre-compute the frozenset of all transports once; used as the default for
|
|
23
|
+
# policies that allow every transport (most operations).
|
|
24
|
+
_ALL_TRANSPORTS: frozenset[Transport] = frozenset(Transport)
|
|
25
|
+
|
|
26
|
+
# Sensitive/administrative operations that are restricted to fewer transports
|
|
27
|
+
# by default. The registry may override these at construction time.
|
|
28
|
+
_ADMIN_TRANSPORTS: frozenset[Transport] = frozenset({
|
|
29
|
+
Transport.CLI,
|
|
30
|
+
Transport.INTERNAL,
|
|
31
|
+
Transport.DASHBOARD,
|
|
32
|
+
})
|
|
33
|
+
_MESH_TRANSPORTS: frozenset[Transport] = frozenset({
|
|
34
|
+
Transport.MESH,
|
|
35
|
+
Transport.MCP,
|
|
36
|
+
Transport.HTTP,
|
|
37
|
+
Transport.INTERNAL,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class OperationPolicy:
|
|
43
|
+
"""Declarative policy for one operation kind.
|
|
44
|
+
|
|
45
|
+
The registry stores one OperationPolicy per OperationKind (and
|
|
46
|
+
optionally per transport when transport-specific overrides are needed,
|
|
47
|
+
though the V4 default table uses one per kind).
|
|
48
|
+
|
|
49
|
+
Field semantics
|
|
50
|
+
---------------
|
|
51
|
+
required_roles Actor must hold at least ONE of these roles.
|
|
52
|
+
Empty frozenset → deny all (explicit deny policy).
|
|
53
|
+
required_authentication Actor.is_authenticated must be True.
|
|
54
|
+
allowed_transports Actor.transport must be in this set.
|
|
55
|
+
requires_schema_capability Non-empty → DB must expose this capability.
|
|
56
|
+
max_payload_bytes Advisory only — payload > this is ANNOTATED, not
|
|
57
|
+
rejected (double-rejection is avoided; the ingest
|
|
58
|
+
gate already enforces the 1 MiB hard cap).
|
|
59
|
+
deadline_budget_ms Advisory request deadline.
|
|
60
|
+
audit_level "none" | "standard" | "full" — governs log detail.
|
|
61
|
+
redaction_policy "none" | "scrub" | "full" — governs PII scrub.
|
|
62
|
+
resource_ownership_check Whether the coordinator must verify ownership.
|
|
63
|
+
allow_cross_profile Whether the actor may target a non-active profile.
|
|
64
|
+
fail_closed_in_company_mode Informational — the registry uses the ``mode``
|
|
65
|
+
argument to evaluate(), not this field, for
|
|
66
|
+
unknown-kind fallback behaviour.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
kind: OperationKind
|
|
70
|
+
required_roles: FrozenSet[ActorRole] = field(
|
|
71
|
+
default_factory=lambda: frozenset({ActorRole.OWNER})
|
|
72
|
+
)
|
|
73
|
+
required_authentication: bool = True
|
|
74
|
+
allowed_transports: FrozenSet[Transport] = field(
|
|
75
|
+
default_factory=lambda: _ALL_TRANSPORTS
|
|
76
|
+
)
|
|
77
|
+
requires_schema_capability: str = ""
|
|
78
|
+
max_payload_bytes: int = 1_048_576 # 1 MiB — advisory only
|
|
79
|
+
deadline_budget_ms: int = 30_000
|
|
80
|
+
audit_level: str = "standard" # "none" | "standard" | "full"
|
|
81
|
+
redaction_policy: str = "scrub" # "none" | "scrub" | "full"
|
|
82
|
+
resource_ownership_check: bool = True
|
|
83
|
+
allow_cross_profile: bool = False
|
|
84
|
+
fail_closed_in_company_mode: bool = True # informational; see docstring
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"OperationPolicy",
|
|
89
|
+
"_ALL_TRANSPORTS",
|
|
90
|
+
"_ADMIN_TRANSPORTS",
|
|
91
|
+
"_MESH_TRANSPORTS",
|
|
92
|
+
]
|