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.
Files changed (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. 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, Optional
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._db.commit()
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._db.commit()
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._db.commit()
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(applies_to or {})),
228
+ json.dumps(selectors, sort_keys=True)),
214
229
  )
215
- self._db.commit()
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._db.commit()
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 fact_id, created_at FROM atomic_facts WHERE profile_id = ?",
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
- expired = [
325
- str(r[0]) for r in rows
326
- if r[1] and self._age_in_days(r[1]) > days
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
- try:
346
- self._db.execute(
347
- f"UPDATE atomic_facts SET archive_status = 'tombstoned' "
348
- f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
349
- [profile_id, *expired],
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) -> None:
77
+ def stop(self) -> bool:
70
78
  """Stop the background scheduler.
71
79
 
72
- Cancels the pending timer. Safe to call even if not running.
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
- if self._timer is not None:
77
- self._timer.cancel()
78
- self._timer = None
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._execute_cycle()
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 = self._engine._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 = self._engine.enforce(profile_id)
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
+ ]