superlocalmemory 3.7.7 → 3.8.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/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +85 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- 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 +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -2
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +96 -12
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/auth_middleware.py +28 -0
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +50 -5
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +21 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +72 -16
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +221 -49
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +194 -81
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +30 -5
- package/src/superlocalmemory/server/unified_daemon.py +431 -75
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v32.py +0 -9
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -43,6 +43,7 @@ _MAX_RETRY_DELAY_SECONDS = 3600
|
|
|
43
43
|
_SCHEMA = """
|
|
44
44
|
CREATE TABLE IF NOT EXISTS pending_memories (
|
|
45
45
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
46
47
|
content TEXT NOT NULL,
|
|
47
48
|
tags TEXT DEFAULT '',
|
|
48
49
|
metadata TEXT DEFAULT '{}',
|
|
@@ -62,6 +63,12 @@ def _get_db(base_dir: Path | None = None) -> sqlite3.Connection:
|
|
|
62
63
|
db_path = d / _PENDING_DB
|
|
63
64
|
conn = sqlite3.connect(str(db_path), timeout=5)
|
|
64
65
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
66
|
+
# C4: pending queue can hold not-yet-materialized memory content owner-only.
|
|
67
|
+
try:
|
|
68
|
+
from superlocalmemory.core.security_primitives import harden_db_perms
|
|
69
|
+
harden_db_perms(db_path)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
65
72
|
conn.execute(_SCHEMA)
|
|
66
73
|
columns = {
|
|
67
74
|
row[1]
|
|
@@ -73,6 +80,15 @@ def _get_db(base_dir: Path | None = None) -> sqlite3.Connection:
|
|
|
73
80
|
"next_retry_at REAL DEFAULT 0"
|
|
74
81
|
)
|
|
75
82
|
conn.commit()
|
|
83
|
+
# Per-profile isolation: a queued item must materialize under the profile
|
|
84
|
+
# that was active when it was enqueued — never under whatever profile is
|
|
85
|
+
# active at drain time. Existing rows backfill to 'default'.
|
|
86
|
+
if "profile_id" not in columns:
|
|
87
|
+
conn.execute(
|
|
88
|
+
"ALTER TABLE pending_memories ADD COLUMN "
|
|
89
|
+
"profile_id TEXT NOT NULL DEFAULT 'default'"
|
|
90
|
+
)
|
|
91
|
+
conn.commit()
|
|
76
92
|
# Pre-V3.7 rows were terminally hidden after three failures. Restore them
|
|
77
93
|
# to the retry queue; M018 makes replay idempotent and no raw evidence may
|
|
78
94
|
# remain stranded solely because an older version exhausted its counter.
|
|
@@ -89,8 +105,12 @@ def store_pending(
|
|
|
89
105
|
tags: str = "",
|
|
90
106
|
metadata: dict | None = None,
|
|
91
107
|
base_dir: Path | None = None,
|
|
108
|
+
profile_id: str = "default",
|
|
92
109
|
) -> int:
|
|
93
|
-
"""Store content in pending table. Returns the row ID.
|
|
110
|
+
"""Store content in pending table under a profile. Returns the row ID.
|
|
111
|
+
|
|
112
|
+
``profile_id`` captures the profile active at ENQUEUE time so a later
|
|
113
|
+
profile switch can never redirect this memory to a different profile.
|
|
94
114
|
|
|
95
115
|
This is intentionally FAST — no engine init, no embedding, no model loading.
|
|
96
116
|
Just a raw SQLite INSERT (~0.1s).
|
|
@@ -98,9 +118,11 @@ def store_pending(
|
|
|
98
118
|
conn = _get_db(base_dir)
|
|
99
119
|
try:
|
|
100
120
|
cur = conn.execute(
|
|
101
|
-
"INSERT INTO pending_memories
|
|
102
|
-
"
|
|
103
|
-
(
|
|
121
|
+
"INSERT INTO pending_memories "
|
|
122
|
+
"(profile_id, content, tags, metadata, created_at, status) "
|
|
123
|
+
"VALUES (?, ?, ?, ?, ?, 'pending')",
|
|
124
|
+
(profile_id, content, tags, json.dumps(metadata or {}),
|
|
125
|
+
time.strftime("%Y-%m-%dT%H:%M:%S")),
|
|
104
126
|
)
|
|
105
127
|
conn.commit()
|
|
106
128
|
return cur.lastrowid or 0
|
|
@@ -108,20 +130,34 @@ def store_pending(
|
|
|
108
130
|
conn.close()
|
|
109
131
|
|
|
110
132
|
|
|
111
|
-
def get_pending(
|
|
112
|
-
|
|
133
|
+
def get_pending(
|
|
134
|
+
base_dir: Path | None = None,
|
|
135
|
+
limit: int = 50,
|
|
136
|
+
profile_id: str | None = None,
|
|
137
|
+
) -> list[dict]:
|
|
138
|
+
"""Get unprocessed pending memories, optionally scoped to one profile.
|
|
139
|
+
|
|
140
|
+
The drain passes ``profile_id`` = its engine's active profile so it only
|
|
141
|
+
ever claims items enqueued under that profile. Items for other profiles
|
|
142
|
+
wait until their profile is active — never materialized under the wrong one.
|
|
143
|
+
"""
|
|
113
144
|
conn = _get_db(base_dir)
|
|
114
145
|
try:
|
|
115
|
-
|
|
116
|
-
"SELECT id, content, tags, metadata, created_at, retry_count "
|
|
146
|
+
query = (
|
|
147
|
+
"SELECT id, content, tags, metadata, created_at, retry_count, profile_id "
|
|
117
148
|
"FROM pending_memories WHERE status = 'pending' "
|
|
118
|
-
"AND COALESCE(next_retry_at, 0) <= ?
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
"AND COALESCE(next_retry_at, 0) <= ?"
|
|
150
|
+
)
|
|
151
|
+
params: list = [time.time()]
|
|
152
|
+
if profile_id is not None:
|
|
153
|
+
query += " AND profile_id = ?"
|
|
154
|
+
params.append(profile_id)
|
|
155
|
+
query += " ORDER BY id ASC LIMIT ?"
|
|
156
|
+
params.append(limit)
|
|
157
|
+
rows = conn.execute(query, params).fetchall()
|
|
122
158
|
return [
|
|
123
159
|
{"id": r[0], "content": r[1], "tags": r[2], "metadata": r[3],
|
|
124
|
-
"created_at": r[4], "retry_count": r[5]}
|
|
160
|
+
"created_at": r[4], "retry_count": r[5], "profile_id": r[6]}
|
|
125
161
|
for r in rows
|
|
126
162
|
]
|
|
127
163
|
finally:
|
|
@@ -99,6 +99,10 @@ def cmd_proxy(args: Namespace) -> None:
|
|
|
99
99
|
print("Or run: slm wrap claude")
|
|
100
100
|
print()
|
|
101
101
|
print("Proxy ready.")
|
|
102
|
+
print()
|
|
103
|
+
print("Note: 'slm proxy' enables the proxy independently — it does not")
|
|
104
|
+
print("flip the master optimize switch, so 'slm optimize status' may")
|
|
105
|
+
print("show OFF while the proxy is running.")
|
|
102
106
|
else:
|
|
103
107
|
print("Error: proxy failed to start. Check logs.", file=sys.stderr)
|
|
104
108
|
sys.exit(1)
|
|
@@ -37,10 +37,16 @@ def cmd_db_scale(args: Namespace) -> int:
|
|
|
37
37
|
if not args.stage_id:
|
|
38
38
|
raise ScaleEngineError("promote requires --stage-id (see `slm db scale status`)")
|
|
39
39
|
result = manager.promote(args.stage_id)
|
|
40
|
+
# The running daemon reads scale_engine_state once at startup; the
|
|
41
|
+
# promoted Cozo/Lance backends are only wired in on the next start.
|
|
42
|
+
# Without this flag users see a clean promote and keep hitting
|
|
43
|
+
# SQLite-only silently (no error, no speedup).
|
|
44
|
+
result = {**result, "restart_required": True}
|
|
40
45
|
elif action == "rollback":
|
|
41
46
|
if not args.backup_id:
|
|
42
47
|
raise ScaleEngineError("rollback requires --backup-id (see `slm db scale status`)")
|
|
43
48
|
result = manager.rollback(args.backup_id)
|
|
49
|
+
result = {**result, "restart_required": True}
|
|
44
50
|
else:
|
|
45
51
|
raise ScaleEngineError(f"unknown Scale Engine action: {action}")
|
|
46
52
|
except (ScaleEngineError, CanonicalVectorError) as exc:
|
|
@@ -114,17 +114,19 @@ def _download_model(model_name: str, label: str) -> bool:
|
|
|
114
114
|
print(f"\n Downloading {label}: {model_name}")
|
|
115
115
|
print(f" (this may take a few minutes on first run)\n")
|
|
116
116
|
|
|
117
|
+
# H-03: pass the model name as argv, never interpolated into executed
|
|
118
|
+
# source, so a crafted model_name cannot become arbitrary Python.
|
|
117
119
|
script = (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
"import sys; "
|
|
121
|
+
"from sentence_transformers import SentenceTransformer; "
|
|
122
|
+
"m = SentenceTransformer(sys.argv[1], trust_remote_code=True); "
|
|
123
|
+
"d = m.get_sentence_embedding_dimension(); "
|
|
124
|
+
"print(f'OK dim={d}'); "
|
|
123
125
|
)
|
|
124
126
|
|
|
125
127
|
try:
|
|
126
128
|
result = subprocess.run(
|
|
127
|
-
[sys.executable, "-c", script],
|
|
129
|
+
[sys.executable, "-c", script, model_name],
|
|
128
130
|
timeout=600, # 10 min for large model downloads
|
|
129
131
|
capture_output=False, # Show download progress
|
|
130
132
|
text=True,
|
|
@@ -156,15 +158,16 @@ def _download_reranker(model_name: str) -> bool:
|
|
|
156
158
|
print(f"\n Downloading reranker: {model_name}")
|
|
157
159
|
print(f" (cross-encoder for result re-ranking)\n")
|
|
158
160
|
|
|
161
|
+
# H-03: model name via argv, never interpolated into executed source.
|
|
159
162
|
script = (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
"import sys; from sentence_transformers import CrossEncoder; "
|
|
164
|
+
"m = CrossEncoder(sys.argv[1], trust_remote_code=True); "
|
|
165
|
+
"print('OK'); "
|
|
163
166
|
)
|
|
164
167
|
|
|
165
168
|
try:
|
|
166
169
|
result = subprocess.run(
|
|
167
|
-
[sys.executable, "-c", script],
|
|
170
|
+
[sys.executable, "-c", script, model_name],
|
|
168
171
|
timeout=300,
|
|
169
172
|
capture_output=False,
|
|
170
173
|
text=True,
|
|
@@ -195,16 +198,17 @@ def _download_compressor(model_name: str) -> bool:
|
|
|
195
198
|
print(f"\n Downloading compression model: {model_name}")
|
|
196
199
|
print(f" (LLMLingua-2 prose compressor, ~560MB — aggressive mode only)\n")
|
|
197
200
|
|
|
201
|
+
# H-03: model name via argv, never interpolated into executed source.
|
|
198
202
|
script = (
|
|
199
|
-
"from llmlingua import PromptCompressor; "
|
|
200
|
-
|
|
203
|
+
"import sys; from llmlingua import PromptCompressor; "
|
|
204
|
+
"PromptCompressor(model_name=sys.argv[1], use_llmlingua2=True, "
|
|
201
205
|
"device_map='cpu'); "
|
|
202
206
|
"print('OK')"
|
|
203
207
|
)
|
|
204
208
|
|
|
205
209
|
try:
|
|
206
210
|
result = subprocess.run(
|
|
207
|
-
[sys.executable, "-c", script],
|
|
211
|
+
[sys.executable, "-c", script, model_name],
|
|
208
212
|
timeout=900, # 560MB on a slow link can exceed 5 min
|
|
209
213
|
capture_output=False,
|
|
210
214
|
text=True,
|
|
@@ -494,6 +498,11 @@ def run_wizard(auto: bool = False) -> None:
|
|
|
494
498
|
|
|
495
499
|
# -- Step 4: Download models --
|
|
496
500
|
print()
|
|
501
|
+
# H-06 (CVE-2025-14926): a malicious HuggingFace checkpoint can execute code
|
|
502
|
+
# at load time. SLM only downloads its pinned defaults, but warn users who
|
|
503
|
+
# point config at a custom model.
|
|
504
|
+
print(" ⚠ Only install models from sources you trust — a malicious model")
|
|
505
|
+
print(" checkpoint can run code on your machine at load time. See SECURITY.md.")
|
|
497
506
|
print("─── Step 4/10: Download Embedding Model ───")
|
|
498
507
|
|
|
499
508
|
if _embedding_is_remote(config):
|
|
@@ -92,6 +92,12 @@ class AuditChain:
|
|
|
92
92
|
conn = sqlite3.connect(path)
|
|
93
93
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
94
94
|
conn.row_factory = sqlite3.Row
|
|
95
|
+
# C4: audit chain holds a tamper-evident record — keep it owner-only.
|
|
96
|
+
try:
|
|
97
|
+
from superlocalmemory.core.security_primitives import harden_db_perms
|
|
98
|
+
harden_db_perms(path)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
95
101
|
return conn
|
|
96
102
|
|
|
97
103
|
def _make_conn(self) -> sqlite3.Connection:
|
|
@@ -19,6 +19,15 @@ from datetime import UTC, datetime
|
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
|
+
# Friendly export keys → canonical table names (stable Art.20 export contract).
|
|
23
|
+
_EXPORT_ALIASES = {
|
|
24
|
+
"facts": "atomic_facts",
|
|
25
|
+
"entities": "canonical_entities",
|
|
26
|
+
"edges": "graph_edges",
|
|
27
|
+
"feedback": "feedback_records",
|
|
28
|
+
"scenes": "memory_scenes",
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
|
|
23
32
|
class GDPRCompliance:
|
|
24
33
|
"""GDPR compliance operations for memory data.
|
|
@@ -30,170 +39,146 @@ class GDPRCompliance:
|
|
|
30
39
|
- Audit Trail: Log all data operations
|
|
31
40
|
"""
|
|
32
41
|
|
|
42
|
+
# Tables that carry a profile_id column but are NOT tenant memory to be
|
|
43
|
+
# erased/exported wholesale. `profiles` is the tenant record (handled
|
|
44
|
+
# separately, deleted last).
|
|
45
|
+
_NON_MEMORY_SCOPED = frozenset({"profiles"})
|
|
46
|
+
|
|
33
47
|
def __init__(self, db) -> None:
|
|
34
48
|
self._db = db
|
|
35
49
|
|
|
50
|
+
def _profile_scoped_tables(self) -> list[str]:
|
|
51
|
+
"""Every table carrying a ``profile_id`` column — discovered live from
|
|
52
|
+
the schema so a newly-added table can never be silently missed by
|
|
53
|
+
export or erasure (the class of bug that breaks GDPR completeness)."""
|
|
54
|
+
try:
|
|
55
|
+
names = [
|
|
56
|
+
dict(r)["name"]
|
|
57
|
+
for r in self._db.execute(
|
|
58
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
59
|
+
)
|
|
60
|
+
]
|
|
61
|
+
except Exception:
|
|
62
|
+
return []
|
|
63
|
+
out: list[str] = []
|
|
64
|
+
for t in names:
|
|
65
|
+
if t.startswith("sqlite_") or t in self._NON_MEMORY_SCOPED:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
cols = {dict(c)["name"] for c in self._db.execute(f"PRAGMA table_info({t})")}
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
if "profile_id" in cols:
|
|
72
|
+
out.append(t)
|
|
73
|
+
return out
|
|
74
|
+
|
|
36
75
|
# -- Right to Access (Art. 15) -----------------------------------------
|
|
37
76
|
|
|
38
77
|
def export_profile_data(self, profile_id: str) -> dict:
|
|
39
|
-
"""Export ALL data for a profile in machine-readable format.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
edges, trust scores, feedback, and behavioral patterns.
|
|
43
|
-
"""
|
|
78
|
+
"""Export ALL data for a profile in machine-readable format (Art. 15 /
|
|
79
|
+
Art. 20). Covers every profile-scoped table discovered from the schema,
|
|
80
|
+
plus the profile record itself."""
|
|
44
81
|
self._audit("export", "profile", profile_id, "Full data export")
|
|
45
82
|
|
|
46
83
|
data: dict = {"profile_id": profile_id, "exported_at": _now()}
|
|
84
|
+
for table in self._profile_scoped_tables():
|
|
85
|
+
try:
|
|
86
|
+
rows = self._db.execute(
|
|
87
|
+
f"SELECT * FROM {table} WHERE profile_id = ?", (profile_id,)
|
|
88
|
+
)
|
|
89
|
+
data[table] = [dict(r) for r in rows]
|
|
90
|
+
except Exception as exc: # pragma: no cover — defensive per-table
|
|
91
|
+
logger.warning("export: table %s skipped: %s", table, exc)
|
|
92
|
+
|
|
93
|
+
# Profile record itself (the tenant metadata).
|
|
94
|
+
try:
|
|
95
|
+
rows = self._db.execute(
|
|
96
|
+
"SELECT * FROM profiles WHERE profile_id = ?", (profile_id,)
|
|
97
|
+
)
|
|
98
|
+
data["profile_record"] = [dict(r) for r in rows]
|
|
99
|
+
except Exception:
|
|
100
|
+
data["profile_record"] = []
|
|
47
101
|
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
"SELECT * FROM memories WHERE profile_id = ?", (profile_id,)
|
|
51
|
-
)
|
|
52
|
-
data["memories"] = [dict(r) for r in rows]
|
|
53
|
-
|
|
54
|
-
# Facts
|
|
55
|
-
rows = self._db.execute(
|
|
56
|
-
"SELECT * FROM atomic_facts WHERE profile_id = ?", (profile_id,)
|
|
57
|
-
)
|
|
58
|
-
data["facts"] = [dict(r) for r in rows]
|
|
59
|
-
|
|
60
|
-
# Entities
|
|
61
|
-
rows = self._db.execute(
|
|
62
|
-
"SELECT * FROM canonical_entities WHERE profile_id = ?", (profile_id,)
|
|
63
|
-
)
|
|
64
|
-
data["entities"] = [dict(r) for r in rows]
|
|
65
|
-
|
|
66
|
-
# Graph edges
|
|
67
|
-
rows = self._db.execute(
|
|
68
|
-
"SELECT * FROM graph_edges WHERE profile_id = ?", (profile_id,)
|
|
69
|
-
)
|
|
70
|
-
data["edges"] = [dict(r) for r in rows]
|
|
71
|
-
|
|
72
|
-
# Trust scores
|
|
73
|
-
rows = self._db.execute(
|
|
74
|
-
"SELECT * FROM trust_scores WHERE profile_id = ?", (profile_id,)
|
|
75
|
-
)
|
|
76
|
-
data["trust_scores"] = [dict(r) for r in rows]
|
|
77
|
-
|
|
78
|
-
# Feedback
|
|
79
|
-
rows = self._db.execute(
|
|
80
|
-
"SELECT * FROM feedback_records WHERE profile_id = ?", (profile_id,)
|
|
81
|
-
)
|
|
82
|
-
data["feedback"] = [dict(r) for r in rows]
|
|
83
|
-
|
|
84
|
-
# Entity profiles
|
|
85
|
-
rows = self._db.execute(
|
|
86
|
-
"SELECT * FROM entity_profiles WHERE profile_id = ?", (profile_id,)
|
|
87
|
-
)
|
|
88
|
-
data["entity_profiles"] = [dict(r) for r in rows]
|
|
89
|
-
|
|
90
|
-
# Memory scenes
|
|
91
|
-
rows = self._db.execute(
|
|
92
|
-
"SELECT * FROM memory_scenes WHERE profile_id = ?", (profile_id,)
|
|
93
|
-
)
|
|
94
|
-
data["scenes"] = [dict(r) for r in rows]
|
|
95
|
-
|
|
96
|
-
# Temporal events
|
|
97
|
-
rows = self._db.execute(
|
|
98
|
-
"SELECT * FROM temporal_events WHERE profile_id = ?", (profile_id,)
|
|
99
|
-
)
|
|
100
|
-
data["temporal_events"] = [dict(r) for r in rows]
|
|
101
|
-
|
|
102
|
-
# Consolidation log
|
|
103
|
-
rows = self._db.execute(
|
|
104
|
-
"SELECT * FROM consolidation_log WHERE profile_id = ?", (profile_id,)
|
|
105
|
-
)
|
|
106
|
-
data["consolidation_log"] = [dict(r) for r in rows]
|
|
107
|
-
|
|
108
|
-
# Behavioral patterns
|
|
109
|
-
rows = self._db.execute(
|
|
110
|
-
"SELECT * FROM behavioral_patterns WHERE profile_id = ?", (profile_id,)
|
|
111
|
-
)
|
|
112
|
-
data["behavioral_patterns"] = [dict(r) for r in rows]
|
|
113
|
-
|
|
114
|
-
# Action outcomes
|
|
115
|
-
rows = self._db.execute(
|
|
116
|
-
"SELECT * FROM action_outcomes WHERE profile_id = ?", (profile_id,)
|
|
117
|
-
)
|
|
118
|
-
data["action_outcomes"] = [dict(r) for r in rows]
|
|
119
|
-
|
|
120
|
-
# Compliance audit trail
|
|
121
|
-
rows = self._db.execute(
|
|
122
|
-
"SELECT * FROM compliance_audit WHERE profile_id = ?", (profile_id,)
|
|
123
|
-
)
|
|
124
|
-
data["compliance_audit"] = [dict(r) for r in rows]
|
|
125
|
-
|
|
126
|
-
# Provenance (data lineage — EU AI Act Art. 10)
|
|
127
|
-
rows = self._db.execute(
|
|
128
|
-
"SELECT * FROM provenance WHERE profile_id = ?", (profile_id,)
|
|
129
|
-
)
|
|
130
|
-
data["provenance"] = [dict(r) for r in rows]
|
|
131
|
-
|
|
132
|
-
# Entity aliases (indirect PII via entity relationships)
|
|
133
|
-
rows = self._db.execute(
|
|
134
|
-
"SELECT ea.* FROM entity_aliases ea "
|
|
135
|
-
"JOIN canonical_entities ce ON ea.entity_id = ce.entity_id "
|
|
136
|
-
"WHERE ce.profile_id = ?", (profile_id,)
|
|
137
|
-
)
|
|
138
|
-
data["entity_aliases"] = [dict(r) for r in rows]
|
|
139
|
-
|
|
140
|
-
# Profile record itself
|
|
141
|
-
rows = self._db.execute(
|
|
142
|
-
"SELECT * FROM profiles WHERE profile_id = ?", (profile_id,)
|
|
143
|
-
)
|
|
144
|
-
data["profile_record"] = [dict(r) for r in rows]
|
|
145
|
-
|
|
102
|
+
# total_items counts the canonical (table-name) keys only, before
|
|
103
|
+
# friendly aliases are added, so it is not double-counted.
|
|
146
104
|
data["total_items"] = sum(
|
|
147
105
|
len(v) for v in data.values() if isinstance(v, list)
|
|
148
106
|
)
|
|
149
107
|
|
|
108
|
+
# Backward-compatible friendly aliases for the well-known keys (stable
|
|
109
|
+
# export contract) — they reference the same lists, not copies.
|
|
110
|
+
for friendly, table in _EXPORT_ALIASES.items():
|
|
111
|
+
if table in data:
|
|
112
|
+
data[friendly] = data[table]
|
|
113
|
+
|
|
150
114
|
logger.info("Exported %d items for profile '%s'", data["total_items"], profile_id)
|
|
151
115
|
return data
|
|
152
116
|
|
|
153
117
|
# -- Right to Erasure (Art. 17) ----------------------------------------
|
|
154
118
|
|
|
155
119
|
def forget_profile(self, profile_id: str) -> dict:
|
|
156
|
-
"""Delete ALL data for a profile (right to be forgotten).
|
|
120
|
+
"""Delete ALL data for a profile (right to be forgotten, Art. 17).
|
|
157
121
|
|
|
158
|
-
|
|
159
|
-
|
|
122
|
+
Erases every profile-scoped table discovered from the live schema, so a
|
|
123
|
+
newly-added table is covered automatically. The erasure is recorded in
|
|
124
|
+
the tamper-proof audit chain BEFORE any deletion (Art. 5(2)
|
|
125
|
+
accountability) — the in-DB compliance_audit row is itself erased, so
|
|
126
|
+
the chain in a separate DB is the durable evidence.
|
|
160
127
|
"""
|
|
161
128
|
if profile_id == "default":
|
|
162
129
|
raise ValueError("Cannot delete the default profile via GDPR erasure. "
|
|
163
130
|
"Use profile deletion instead.")
|
|
164
131
|
|
|
132
|
+
# 1) Durable, tamper-evident record FIRST — survives the erasure.
|
|
133
|
+
try:
|
|
134
|
+
from superlocalmemory.compliance.audit import AuditChain
|
|
135
|
+
from superlocalmemory.infra.data_root import state_path
|
|
136
|
+
AuditChain(str(state_path("audit_chain.db"))).log(
|
|
137
|
+
"gdpr_erase", agent_id="gdpr", profile_id=profile_id,
|
|
138
|
+
metadata={"basis": "GDPR Art.17 right-to-erasure"},
|
|
139
|
+
)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning("GDPR erase: audit-chain log failed: %s", exc)
|
|
165
142
|
self._audit("delete", "profile", profile_id, "GDPR erasure request")
|
|
166
143
|
|
|
167
144
|
counts: dict[str, int] = {}
|
|
168
|
-
tables =
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"memory_scenes", "entity_profiles", "bm25_tokens",
|
|
173
|
-
"atomic_facts", "memories", "canonical_entities",
|
|
174
|
-
]
|
|
145
|
+
tables = self._profile_scoped_tables()
|
|
146
|
+
# Pass 1 — count every table BEFORE any deletion, so a CASCADE that
|
|
147
|
+
# removes a child (e.g. atomic_facts via memories) does not zero the
|
|
148
|
+
# attribution. Completeness is independent of this.
|
|
175
149
|
for table in tables:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
150
|
+
try:
|
|
151
|
+
rows = self._db.execute(
|
|
152
|
+
f"SELECT COUNT(*) AS c FROM {table} WHERE profile_id = ?",
|
|
153
|
+
(profile_id,),
|
|
154
|
+
)
|
|
155
|
+
counts[table] = int(dict(rows[0])["c"]) if rows else 0
|
|
156
|
+
except Exception as exc: # pragma: no cover
|
|
157
|
+
logger.warning("GDPR erase: count %s failed: %s", table, exc)
|
|
158
|
+
counts[table] = 0
|
|
159
|
+
# Pass 2 — full-tenant wipe with FK enforcement OFF so table order is
|
|
160
|
+
# irrelevant (every profile row in every table goes). FTS shadow rows
|
|
161
|
+
# are still removed by the base-table delete triggers.
|
|
162
|
+
try:
|
|
163
|
+
self._db.execute("PRAGMA foreign_keys=OFF")
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
try:
|
|
167
|
+
for table in tables:
|
|
168
|
+
try:
|
|
169
|
+
self._db.execute(
|
|
170
|
+
f"DELETE FROM {table} WHERE profile_id = ?", (profile_id,)
|
|
171
|
+
)
|
|
172
|
+
except Exception as exc: # pragma: no cover — defensive per-table
|
|
173
|
+
logger.warning("GDPR erase: delete %s failed: %s", table, exc)
|
|
174
|
+
# Delete the profile record itself.
|
|
175
|
+
self._db.execute("DELETE FROM profiles WHERE profile_id = ?", (profile_id,))
|
|
176
|
+
counts["profiles"] = 1
|
|
177
|
+
finally:
|
|
178
|
+
try:
|
|
179
|
+
self._db.execute("PRAGMA foreign_keys=ON")
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
197
182
|
|
|
198
183
|
# Erase learning database (separate DB file)
|
|
199
184
|
try:
|
|
@@ -211,7 +196,7 @@ class GDPRCompliance:
|
|
|
211
196
|
except Exception:
|
|
212
197
|
pass
|
|
213
198
|
|
|
214
|
-
logger.info("GDPR erasure for '%s': %s", profile_id, counts)
|
|
199
|
+
logger.info("GDPR erasure for '%s': %d tables, %s", profile_id, len(tables), counts)
|
|
215
200
|
return counts
|
|
216
201
|
|
|
217
202
|
def forget_entity(self, entity_name: str, profile_id: str) -> dict:
|
|
@@ -254,9 +239,14 @@ class GDPRCompliance:
|
|
|
254
239
|
(eid, profile_id),
|
|
255
240
|
)
|
|
256
241
|
|
|
257
|
-
# Delete aliases + entity
|
|
258
|
-
|
|
259
|
-
self._db.execute(
|
|
242
|
+
# Delete aliases + entity (profile-scoped — entity_id is UUID-global but
|
|
243
|
+
# keep the tenant predicate for consistent Art.17 isolation).
|
|
244
|
+
self._db.execute(
|
|
245
|
+
"DELETE FROM entity_aliases WHERE entity_id = ? AND profile_id = ?",
|
|
246
|
+
(eid, profile_id))
|
|
247
|
+
self._db.execute(
|
|
248
|
+
"DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
|
|
249
|
+
(eid, profile_id))
|
|
260
250
|
counts["entity"] = 1
|
|
261
251
|
|
|
262
252
|
logger.info("Entity erasure '%s' in '%s': %s", entity_name, profile_id, counts)
|