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
|
@@ -17,7 +17,7 @@ import json
|
|
|
17
17
|
import logging
|
|
18
18
|
import sqlite3
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
|
-
from typing import Any
|
|
20
|
+
from typing import Any
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger(__name__)
|
|
23
23
|
|
|
@@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS retention_rules (
|
|
|
45
45
|
_ACTION_LIFECYCLE = {"archive": "archived", "tombstone": "archived"}
|
|
46
46
|
_TOMBSTONE_ACTIONS = frozenset({"tombstone"})
|
|
47
47
|
_VALID_ACTIONS = ("archive", "tombstone", "notify")
|
|
48
|
+
_SUPPORTED_APPLIES_TO = frozenset({"scope", "fact_type", "signal_type", "session_id"})
|
|
48
49
|
|
|
49
50
|
_FACTS_TABLE_CHECK = """
|
|
50
51
|
SELECT name FROM sqlite_master
|
|
@@ -59,8 +60,9 @@ class RetentionEngine:
|
|
|
59
60
|
The engine can identify expired facts and enforce deletion.
|
|
60
61
|
"""
|
|
61
62
|
|
|
62
|
-
def __init__(self, db: sqlite3.Connection) -> None:
|
|
63
|
+
def __init__(self, db: sqlite3.Connection, *, autocommit: bool = True) -> None:
|
|
63
64
|
self._db = db
|
|
65
|
+
self._autocommit = autocommit
|
|
64
66
|
self._ensure_table()
|
|
65
67
|
|
|
66
68
|
# ------------------------------------------------------------------
|
|
@@ -84,7 +86,12 @@ class RetentionEngine:
|
|
|
84
86
|
):
|
|
85
87
|
if col not in cols:
|
|
86
88
|
self._db.execute(f"ALTER TABLE retention_rules ADD COLUMN {ddl}")
|
|
87
|
-
self.
|
|
89
|
+
self._commit_if_owned()
|
|
90
|
+
|
|
91
|
+
def _commit_if_owned(self) -> None:
|
|
92
|
+
"""Commit only when this engine owns the transaction boundary."""
|
|
93
|
+
if self._autocommit:
|
|
94
|
+
self._db.commit()
|
|
88
95
|
|
|
89
96
|
def _has_facts_table(self) -> bool:
|
|
90
97
|
"""Check if atomic_facts table exists in the database."""
|
|
@@ -131,7 +138,7 @@ class RetentionEngine:
|
|
|
131
138
|
"VALUES (?, ?, ?, ?)",
|
|
132
139
|
(profile_id, rule_name, days, description),
|
|
133
140
|
)
|
|
134
|
-
self.
|
|
141
|
+
self._commit_if_owned()
|
|
135
142
|
logger.info(
|
|
136
143
|
"Added retention rule '%s' (%d days) to profile '%s'",
|
|
137
144
|
rule_name, days, profile_id,
|
|
@@ -149,7 +156,7 @@ class RetentionEngine:
|
|
|
149
156
|
"WHERE profile_id = ? AND rule_name = ?",
|
|
150
157
|
(profile_id, rule_name),
|
|
151
158
|
)
|
|
152
|
-
self.
|
|
159
|
+
self._commit_if_owned()
|
|
153
160
|
logger.info(
|
|
154
161
|
"Removed retention rule '%s' from profile '%s'",
|
|
155
162
|
rule_name, profile_id,
|
|
@@ -205,14 +212,22 @@ class RetentionEngine:
|
|
|
205
212
|
"""
|
|
206
213
|
if action not in _VALID_ACTIONS:
|
|
207
214
|
raise ValueError(f"action must be one of {_VALID_ACTIONS}")
|
|
215
|
+
selectors = applies_to or {}
|
|
216
|
+
if not isinstance(selectors, dict):
|
|
217
|
+
raise ValueError("applies_to must be an object")
|
|
218
|
+
unsupported = set(selectors) - _SUPPORTED_APPLIES_TO
|
|
219
|
+
if unsupported:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"unsupported applies_to selectors: " + ", ".join(sorted(unsupported))
|
|
222
|
+
)
|
|
208
223
|
cur = self._db.execute(
|
|
209
224
|
"INSERT OR REPLACE INTO retention_rules "
|
|
210
225
|
"(profile_id, rule_name, days, description, framework, action, applies_to) "
|
|
211
226
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
212
227
|
(profile_id, name, int(retention_days), "", framework, action,
|
|
213
|
-
json.dumps(
|
|
228
|
+
json.dumps(selectors, sort_keys=True)),
|
|
214
229
|
)
|
|
215
|
-
self.
|
|
230
|
+
self._commit_if_owned()
|
|
216
231
|
logger.info(
|
|
217
232
|
"Created retention rule '%s' (%dd, %s/%s) for profile '%s'",
|
|
218
233
|
name, retention_days, framework, action, profile_id,
|
|
@@ -237,7 +252,7 @@ class RetentionEngine:
|
|
|
237
252
|
"DELETE FROM retention_rules WHERE profile_id = ? AND rule_name = ?",
|
|
238
253
|
(profile_id, name),
|
|
239
254
|
)
|
|
240
|
-
self.
|
|
255
|
+
self._commit_if_owned()
|
|
241
256
|
return cur.rowcount > 0
|
|
242
257
|
|
|
243
258
|
# ------------------------------------------------------------------
|
|
@@ -287,6 +302,23 @@ class RetentionEngine:
|
|
|
287
302
|
# ------------------------------------------------------------------
|
|
288
303
|
|
|
289
304
|
def enforce(self, profile_id: str) -> dict[str, Any]:
|
|
305
|
+
"""Atomically enforce all rules for one profile."""
|
|
306
|
+
if not self._autocommit and not self._db.in_transaction:
|
|
307
|
+
self._db.execute("BEGIN")
|
|
308
|
+
savepoint = "slm_retention_profile"
|
|
309
|
+
self._db.execute(f"SAVEPOINT {savepoint}")
|
|
310
|
+
try:
|
|
311
|
+
result = self._enforce_uncommitted(profile_id)
|
|
312
|
+
self._db.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
313
|
+
if self._autocommit:
|
|
314
|
+
self._db.commit()
|
|
315
|
+
return result
|
|
316
|
+
except Exception:
|
|
317
|
+
self._db.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
|
318
|
+
self._db.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
def _enforce_uncommitted(self, profile_id: str) -> dict[str, Any]:
|
|
290
322
|
"""Enforce every retention rule for a profile, honoring each action.
|
|
291
323
|
|
|
292
324
|
For each rule, facts in the profile older than its retention_days are
|
|
@@ -312,19 +344,43 @@ class RetentionEngine:
|
|
|
312
344
|
if not rules or not self._has_facts_table():
|
|
313
345
|
return result
|
|
314
346
|
|
|
347
|
+
fact_columns = {
|
|
348
|
+
row[1] for row in self._db.execute("PRAGMA table_info(atomic_facts)").fetchall()
|
|
349
|
+
}
|
|
350
|
+
selector_columns = sorted(_SUPPORTED_APPLIES_TO & fact_columns)
|
|
351
|
+
selected_columns = ["fact_id", "created_at", *selector_columns]
|
|
315
352
|
rows = self._db.execute(
|
|
316
|
-
"SELECT
|
|
353
|
+
f"SELECT {', '.join(selected_columns)} FROM atomic_facts "
|
|
354
|
+
"WHERE profile_id = ?",
|
|
317
355
|
(profile_id,),
|
|
318
356
|
).fetchall()
|
|
357
|
+
facts = [dict(zip(selected_columns, row, strict=True)) for row in rows]
|
|
319
358
|
|
|
320
359
|
affected: set[str] = set()
|
|
321
360
|
for rule in rules:
|
|
322
361
|
action = rule.get("action", "archive")
|
|
323
362
|
days = rule.get("retention_days", rule.get("days", 0))
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
363
|
+
selectors = rule.get("applies_to") or {}
|
|
364
|
+
if set(selectors) - set(selector_columns):
|
|
365
|
+
logger.warning(
|
|
366
|
+
"Retention rule %r skipped unsupported/unavailable selectors: %s",
|
|
367
|
+
rule.get("name"),
|
|
368
|
+
sorted(set(selectors) - set(selector_columns)),
|
|
369
|
+
)
|
|
370
|
+
continue
|
|
371
|
+
expired = []
|
|
372
|
+
for fact in facts:
|
|
373
|
+
created_at = fact.get("created_at")
|
|
374
|
+
if not created_at or self._age_in_days(created_at) <= days:
|
|
375
|
+
continue
|
|
376
|
+
matches = True
|
|
377
|
+
for field, expected in selectors.items():
|
|
378
|
+
accepted = expected if isinstance(expected, list) else [expected]
|
|
379
|
+
if fact.get(field) not in accepted:
|
|
380
|
+
matches = False
|
|
381
|
+
break
|
|
382
|
+
if matches:
|
|
383
|
+
expired.append(str(fact["fact_id"]))
|
|
328
384
|
if not expired:
|
|
329
385
|
continue
|
|
330
386
|
if action == "notify":
|
|
@@ -342,18 +398,14 @@ class RetentionEngine:
|
|
|
342
398
|
)
|
|
343
399
|
if action in _TOMBSTONE_ACTIONS:
|
|
344
400
|
# Flag purgeable without violating the lifecycle CHECK.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
except Exception:
|
|
352
|
-
pass
|
|
401
|
+
self._db.execute(
|
|
402
|
+
f"UPDATE atomic_facts SET archive_status = 'tombstoned' "
|
|
403
|
+
f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
|
|
404
|
+
[profile_id, *expired],
|
|
405
|
+
)
|
|
353
406
|
result["archived" if action == "archive" else "tombstoned"] += len(expired)
|
|
354
407
|
affected.update(expired)
|
|
355
408
|
|
|
356
|
-
self._db.commit()
|
|
357
409
|
result["affected_ids"] = sorted(affected)
|
|
358
410
|
result["deleted_count"] = result["tombstoned"] # legacy alias
|
|
359
411
|
logger.info(
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import logging
|
|
14
14
|
import sqlite3
|
|
15
15
|
import threading
|
|
16
|
+
from pathlib import Path
|
|
16
17
|
from typing import Any, Optional
|
|
17
18
|
|
|
18
19
|
from .retention import RetentionEngine
|
|
@@ -32,14 +33,20 @@ class RetentionScheduler:
|
|
|
32
33
|
|
|
33
34
|
def __init__(
|
|
34
35
|
self,
|
|
35
|
-
retention_engine: RetentionEngine,
|
|
36
|
+
retention_engine: RetentionEngine | None = None,
|
|
36
37
|
interval_seconds: int = DEFAULT_INTERVAL_SECONDS,
|
|
38
|
+
*,
|
|
39
|
+
db_path: str | Path | None = None,
|
|
37
40
|
) -> None:
|
|
41
|
+
if retention_engine is None and db_path is None:
|
|
42
|
+
raise ValueError("retention_engine or db_path is required")
|
|
38
43
|
self._engine = retention_engine
|
|
44
|
+
self._db_path = Path(db_path) if db_path is not None else None
|
|
39
45
|
self._interval = interval_seconds
|
|
40
46
|
self._timer: Optional[threading.Timer] = None
|
|
41
47
|
self._running = False
|
|
42
48
|
self._lock = threading.Lock()
|
|
49
|
+
self._stop_event = threading.Event()
|
|
43
50
|
|
|
44
51
|
@property
|
|
45
52
|
def is_running(self) -> bool:
|
|
@@ -59,6 +66,7 @@ class RetentionScheduler:
|
|
|
59
66
|
with self._lock:
|
|
60
67
|
if self._running:
|
|
61
68
|
return
|
|
69
|
+
self._stop_event.clear()
|
|
62
70
|
self._running = True
|
|
63
71
|
self._schedule_next()
|
|
64
72
|
logger.info(
|
|
@@ -66,17 +74,30 @@ class RetentionScheduler:
|
|
|
66
74
|
self._interval,
|
|
67
75
|
)
|
|
68
76
|
|
|
69
|
-
def stop(self) ->
|
|
77
|
+
def stop(self) -> bool:
|
|
70
78
|
"""Stop the background scheduler.
|
|
71
79
|
|
|
72
|
-
Cancels the pending timer
|
|
80
|
+
Cancels the pending timer and waits briefly for an active cycle. Safe
|
|
81
|
+
to call even if not running. Returns False only when an active cycle
|
|
82
|
+
does not exit within the bounded shutdown window.
|
|
73
83
|
"""
|
|
84
|
+
timer: threading.Timer | None
|
|
74
85
|
with self._lock:
|
|
75
86
|
self._running = False
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
self._stop_event.set()
|
|
88
|
+
timer = self._timer
|
|
89
|
+
self._timer = None
|
|
90
|
+
if timer is not None:
|
|
91
|
+
timer.cancel()
|
|
92
|
+
stopped = True
|
|
93
|
+
if timer is not None and timer is not threading.current_thread():
|
|
94
|
+
timer.join(timeout=10.0)
|
|
95
|
+
if timer.is_alive():
|
|
96
|
+
stopped = False
|
|
97
|
+
logger.warning("Retention scheduler did not stop within 10 seconds")
|
|
98
|
+
if stopped:
|
|
79
99
|
logger.info("Retention scheduler stopped")
|
|
100
|
+
return stopped
|
|
80
101
|
|
|
81
102
|
# ------------------------------------------------------------------
|
|
82
103
|
# Execution
|
|
@@ -103,7 +124,8 @@ class RetentionScheduler:
|
|
|
103
124
|
def _run_cycle(self) -> None:
|
|
104
125
|
"""Run one enforcement cycle, then schedule the next."""
|
|
105
126
|
try:
|
|
106
|
-
self.
|
|
127
|
+
if not self._stop_event.is_set():
|
|
128
|
+
self._execute_cycle()
|
|
107
129
|
except Exception as exc:
|
|
108
130
|
# Scheduler must not crash — log and continue
|
|
109
131
|
logger.error("Retention scheduler cycle failed: %s", exc)
|
|
@@ -117,10 +139,51 @@ class RetentionScheduler:
|
|
|
117
139
|
|
|
118
140
|
Discovers all profiles with retention rules and enforces each.
|
|
119
141
|
"""
|
|
142
|
+
if self._db_path is not None:
|
|
143
|
+
return self._execute_db_path_cycle()
|
|
144
|
+
if self._engine is None: # pragma: no cover - constructor invariant
|
|
145
|
+
raise RuntimeError("retention scheduler has no engine")
|
|
146
|
+
return self._execute_with_engine(self._engine)
|
|
147
|
+
|
|
148
|
+
def _execute_db_path_cycle(self) -> dict[str, Any]:
|
|
149
|
+
"""Discover read-only, then enforce each profile in its own write transaction."""
|
|
150
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
with memory_read(self._db_path) as connection:
|
|
154
|
+
rows = connection.execute(
|
|
155
|
+
"SELECT DISTINCT profile_id FROM retention_rules"
|
|
156
|
+
).fetchall()
|
|
157
|
+
profile_ids = [str(row[0]) for row in rows]
|
|
158
|
+
except sqlite3.OperationalError:
|
|
159
|
+
profile_ids = []
|
|
160
|
+
|
|
161
|
+
results: list[dict[str, Any]] = []
|
|
162
|
+
for profile_id in profile_ids:
|
|
163
|
+
if self._stop_event.is_set():
|
|
164
|
+
break
|
|
165
|
+
try:
|
|
166
|
+
with memory_write(self._db_path) as connection:
|
|
167
|
+
result = RetentionEngine(
|
|
168
|
+
connection, autocommit=False
|
|
169
|
+
).enforce(profile_id)
|
|
170
|
+
results.append(result)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
logger.error(
|
|
173
|
+
"Retention enforcement failed for profile '%s': %s",
|
|
174
|
+
profile_id,
|
|
175
|
+
exc,
|
|
176
|
+
)
|
|
177
|
+
results.append({"profile_id": profile_id, "error": str(exc)})
|
|
178
|
+
return {"profiles_processed": len(results), "results": results}
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _execute_with_engine(engine: RetentionEngine) -> dict[str, Any]:
|
|
182
|
+
"""Enforce one cycle through the supplied short-lived engine."""
|
|
120
183
|
results: list[dict[str, Any]] = []
|
|
121
184
|
|
|
122
185
|
try:
|
|
123
|
-
db =
|
|
186
|
+
db = engine._db
|
|
124
187
|
rows = db.execute(
|
|
125
188
|
"SELECT DISTINCT profile_id FROM retention_rules"
|
|
126
189
|
).fetchall()
|
|
@@ -130,9 +193,15 @@ class RetentionScheduler:
|
|
|
130
193
|
|
|
131
194
|
for profile_id in profile_ids:
|
|
132
195
|
try:
|
|
133
|
-
result =
|
|
196
|
+
result = engine.enforce(profile_id)
|
|
134
197
|
results.append(result)
|
|
135
198
|
except Exception as exc:
|
|
199
|
+
try:
|
|
200
|
+
engine._db.rollback()
|
|
201
|
+
except sqlite3.Error:
|
|
202
|
+
logger.exception(
|
|
203
|
+
"Retention rollback failed for profile '%s'", profile_id
|
|
204
|
+
)
|
|
136
205
|
logger.error(
|
|
137
206
|
"Retention enforcement failed for profile '%s': %s",
|
|
138
207
|
profile_id, exc,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Server-derived actor identity for the V4 admission / policy layer.
|
|
5
|
+
|
|
6
|
+
INVARIANT: ActorContext is ALWAYS constructed from server-authenticated state —
|
|
7
|
+
session tokens, daemon descriptors, profile runtime, RBAC result — NEVER from
|
|
8
|
+
the request body. This constraint is enforced by construction convention and
|
|
9
|
+
audited in OperationPolicyRegistry.evaluate().
|
|
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 enum import Enum
|
|
18
|
+
from typing import FrozenSet
|
|
19
|
+
|
|
20
|
+
# Hosts that indicate a local-machine origin (loopback or in-process).
|
|
21
|
+
_LOCAL_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost", ""})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActorRole(str, Enum):
|
|
25
|
+
"""Coarse role for an authenticated principal.
|
|
26
|
+
|
|
27
|
+
Roles form a permission lattice: OWNER ≥ ADMIN ≥ MEMBER ≥ VIEWER.
|
|
28
|
+
SYSTEM is for internal daemon operations; ANONYMOUS marks unauthenticated.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
OWNER = "owner"
|
|
32
|
+
ADMIN = "admin"
|
|
33
|
+
MEMBER = "member"
|
|
34
|
+
VIEWER = "viewer"
|
|
35
|
+
SYSTEM = "system"
|
|
36
|
+
ANONYMOUS = "anonymous"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Transport(str, Enum):
|
|
40
|
+
"""Physical transport through which the operation arrived.
|
|
41
|
+
|
|
42
|
+
Used by OperationPolicy.allowed_transports to restrict sensitive operations
|
|
43
|
+
to known-safe channels (e.g. SCHEMA_MIGRATE only via CLI or INTERNAL).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
HTTP = "http"
|
|
47
|
+
MCP = "mcp"
|
|
48
|
+
CLI = "cli"
|
|
49
|
+
MESH = "mesh"
|
|
50
|
+
HOOK = "hook"
|
|
51
|
+
ADAPTER = "adapter"
|
|
52
|
+
DASHBOARD = "dashboard"
|
|
53
|
+
INTERNAL = "internal" # In-process Python API path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ActorContext:
|
|
58
|
+
"""Immutable, server-derived actor identity for a single operation.
|
|
59
|
+
|
|
60
|
+
All fields come from server-side resolution (session store, RBAC, daemon
|
|
61
|
+
descriptor). None may originate from the request body. Callers that build
|
|
62
|
+
an ActorContext from untrusted input violate this contract — the registry
|
|
63
|
+
evaluator cannot detect this, so the constraint must be audited at the
|
|
64
|
+
construction site.
|
|
65
|
+
|
|
66
|
+
Field semantics
|
|
67
|
+
---------------
|
|
68
|
+
principal_id Stable user/operator identifier (never a raw token).
|
|
69
|
+
roles Coarse permission set; OWNER means unrestricted writes.
|
|
70
|
+
allowed_profiles Empty frozenset ≡ unrestricted (all profiles allowed).
|
|
71
|
+
Non-empty ≡ explicit allowlist; any other profile is
|
|
72
|
+
denied at the coordinator level, not here.
|
|
73
|
+
active_profile_id Profile this operation targets.
|
|
74
|
+
scopes Scope labels this actor may write to.
|
|
75
|
+
delegations Signed capability tokens (future use).
|
|
76
|
+
transport Channel through which the call arrived.
|
|
77
|
+
client_host Resolved remote address; empty string ≡ in-process.
|
|
78
|
+
session_token_hash First 16 hex chars of SHA-256 of session token.
|
|
79
|
+
The raw token is NEVER stored here.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
principal_id: str = ""
|
|
83
|
+
roles: FrozenSet[ActorRole] = field(
|
|
84
|
+
default_factory=lambda: frozenset({ActorRole.OWNER})
|
|
85
|
+
)
|
|
86
|
+
# empty = all profiles allowed; non-empty = explicit allowlist
|
|
87
|
+
allowed_profiles: FrozenSet[str] = field(default_factory=frozenset)
|
|
88
|
+
active_profile_id: str = ""
|
|
89
|
+
active_profile_generation: int = 0
|
|
90
|
+
scopes: FrozenSet[str] = field(
|
|
91
|
+
default_factory=lambda: frozenset({"personal", "project", "shared", "global"})
|
|
92
|
+
)
|
|
93
|
+
delegations: tuple[str, ...] = ()
|
|
94
|
+
transport: Transport = Transport.HTTP
|
|
95
|
+
client_host: str = ""
|
|
96
|
+
session_token_hash: str = "" # SHA-256[:16] of session token — NEVER raw
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Derived predicates (pure, no I/O)
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_local(self) -> bool:
|
|
104
|
+
"""True when the request originates from the local machine (loopback)."""
|
|
105
|
+
return self.client_host in _LOCAL_HOSTS
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_authenticated(self) -> bool:
|
|
109
|
+
"""True when principal_id is non-empty and not an anonymous role."""
|
|
110
|
+
return self.principal_id != "" and ActorRole.ANONYMOUS not in self.roles
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Convenience constructors (server-side only — never call from request parsers)
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def make_internal_owner_context(
|
|
118
|
+
*,
|
|
119
|
+
principal_id: str,
|
|
120
|
+
profile_id: str = "",
|
|
121
|
+
) -> ActorContext:
|
|
122
|
+
"""Build the standard ActorContext for the in-process Python API path.
|
|
123
|
+
|
|
124
|
+
Use this when the caller is the daemon itself or an authenticated local
|
|
125
|
+
operator using the Python SDK directly. The transport is INTERNAL and the
|
|
126
|
+
client_host is empty (treated as local).
|
|
127
|
+
"""
|
|
128
|
+
return ActorContext(
|
|
129
|
+
principal_id=principal_id,
|
|
130
|
+
roles=frozenset({ActorRole.OWNER}),
|
|
131
|
+
active_profile_id=profile_id,
|
|
132
|
+
transport=Transport.INTERNAL,
|
|
133
|
+
client_host="",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def make_http_actor_context(
|
|
138
|
+
*,
|
|
139
|
+
principal_id: str,
|
|
140
|
+
profile_id: str = "",
|
|
141
|
+
client_host: str = "",
|
|
142
|
+
session_token_hash: str = "",
|
|
143
|
+
roles: FrozenSet[ActorRole] | None = None,
|
|
144
|
+
) -> ActorContext:
|
|
145
|
+
"""Build the standard ActorContext for the HTTP /remember endpoint.
|
|
146
|
+
|
|
147
|
+
``principal_id`` comes from ``_require_write_actor`` (server-side); it is
|
|
148
|
+
never the raw session token and never from the request body.
|
|
149
|
+
"""
|
|
150
|
+
return ActorContext(
|
|
151
|
+
principal_id=principal_id,
|
|
152
|
+
roles=roles if roles is not None else frozenset({ActorRole.OWNER}),
|
|
153
|
+
active_profile_id=profile_id,
|
|
154
|
+
transport=Transport.HTTP,
|
|
155
|
+
client_host=client_host,
|
|
156
|
+
session_token_hash=session_token_hash,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = [
|
|
161
|
+
"ActorContext",
|
|
162
|
+
"ActorRole",
|
|
163
|
+
"Transport",
|
|
164
|
+
"make_internal_owner_context",
|
|
165
|
+
"make_http_actor_context",
|
|
166
|
+
]
|