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
@@ -43,6 +43,29 @@ class AutoRecall:
43
43
  self._max_memories = self._config.get("max_memories_injected", 10)
44
44
  self._threshold = self._config.get("relevance_threshold", 0.3)
45
45
 
46
+ def _get_soft_prompt_text(self) -> str:
47
+ """Return soft prompt text via engine._auto_invoker (V3.3 bridge, fail-soft).
48
+
49
+ The CLI and daemon paths pass ``engine=<MemoryEngine>`` to AutoRecall.
50
+ The engine stores an AutoInvoker that has a wired PromptInjector.
51
+ This bridge lets AutoRecall surface soft prompts without needing the
52
+ caller to pass a separate PromptInjector argument.
53
+
54
+ Falls back to "" when no engine, no invoker, or any error occurs.
55
+ """
56
+ try:
57
+ invoker = getattr(self._engine, "_auto_invoker", None)
58
+ if invoker is None:
59
+ return ""
60
+ getter = getattr(invoker, "_get_soft_prompt_text", None)
61
+ if getter is None:
62
+ return ""
63
+ return getter() or ""
64
+ except Exception as exc:
65
+ # warning: unexpected bridge failure silences a claimed feature
66
+ logger.warning("Soft-prompt bridge (AutoRecall→AutoInvoker) failed: %s", exc)
67
+ return ""
68
+
46
69
  def _recall(self, query: str, limit: int):
47
70
  if self._recall_fn is not None:
48
71
  return self._recall_fn(query, limit=limit)
@@ -55,47 +78,58 @@ class AutoRecall:
55
78
 
56
79
  Returns a formatted string of relevant memories suitable
57
80
  for injection into an AI's system prompt.
81
+
82
+ Soft prompts are fetched via the engine→AutoInvoker bridge FIRST so
83
+ they are returned even when recall finds no memories. Memory recall
84
+ is then layered on top when results are present.
58
85
  """
59
86
  if not self._enabled:
60
87
  return ""
61
88
  if self._recall_fn is None and self._engine is None:
62
89
  return ""
63
90
 
91
+ # --- 1. Soft-prompt (fail-soft, may return "") ---
92
+ soft_prompt = self._get_soft_prompt_text()
93
+
94
+ # --- 2. Memory recall ---
64
95
  try:
65
- # Build query from project path or explicit query
66
96
  search_query = query or f"project context {project_path}"
67
97
  response = self._recall(search_query, self._max_memories)
68
-
69
- if response is None or not response.results:
70
- return ""
71
-
72
- # Filter by relevance threshold
73
- relevant = [r for r in response.results if r.score >= self._threshold]
74
-
75
- if not relevant:
76
- return ""
77
-
78
- memories = [
79
- InjectableMemory(
80
- content=r.fact.content,
81
- score=float(r.score),
82
- fact_id=str(r.fact.fact_id),
83
- importance=float(getattr(r.fact, "importance", 0.0) or 0.0),
84
- access_count=int(getattr(r.fact, "access_count", 0) or 0),
85
- source_type="recall",
86
- )
87
- for r in relevant[:self._max_memories]
88
- ]
89
- ctx = render_context(memories, mode="B", cfg=None, wrap=True)
90
- # T-inject: anchor the injected memories to "now" so a time-blind
91
- # model can weigh recency. Prepend a one-line temporal frame.
92
- frame = temporal_frame(
93
- [getattr(r.fact, "created_at", "") for r in relevant[:self._max_memories]]
94
- )
95
- return f"{frame}\n\n{ctx}" if ctx else ctx
96
98
  except Exception as exc:
97
99
  logger.warning("Auto-recall failed: %s", exc)
98
- return ""
100
+ # Still return soft prompt if present
101
+ return soft_prompt
102
+
103
+ # --- 3. Build memory context block (empty string when no results) ---
104
+ memory_ctx = ""
105
+ try:
106
+ if response is not None and response.results:
107
+ relevant = [r for r in response.results if r.score >= self._threshold]
108
+ if relevant:
109
+ memories = [
110
+ InjectableMemory(
111
+ content=r.fact.content,
112
+ score=float(r.score),
113
+ fact_id=str(r.fact.fact_id),
114
+ importance=float(getattr(r.fact, "importance", 0.0) or 0.0),
115
+ access_count=int(getattr(r.fact, "access_count", 0) or 0),
116
+ source_type="recall",
117
+ )
118
+ for r in relevant[:self._max_memories]
119
+ ]
120
+ ctx = render_context(memories, mode="B", cfg=None, wrap=True)
121
+ # T-inject: anchor memories to "now" so a time-blind model
122
+ # can weigh recency. Prepend a one-line temporal frame.
123
+ frame = temporal_frame(
124
+ [getattr(r.fact, "created_at", "") for r in relevant[:self._max_memories]]
125
+ )
126
+ memory_ctx = f"{frame}\n\n{ctx}" if ctx else ctx
127
+ except Exception as exc:
128
+ logger.warning("Auto-recall memory formatting failed: %s", exc)
129
+
130
+ # --- 4. Combine: soft-prompt + memories (either may be empty) ---
131
+ parts = [p for p in (soft_prompt, memory_ctx) if p]
132
+ return "\n\n".join(parts)
99
133
 
100
134
  def get_query_context(self, query: str) -> list[dict]:
101
135
  """Get relevant memories for a specific query.
@@ -6,7 +6,20 @@ import shutil
6
6
  import sysconfig
7
7
  from pathlib import Path
8
8
 
9
- SKILLS = ("slm-cache", "slm-compress", "slm-graph", "slm-recall", "slm-remember", "slm-session", "slm-status")
9
+ SKILLS = (
10
+ "slm-cache",
11
+ "slm-compress",
12
+ "slm-governance",
13
+ "slm-graph",
14
+ "slm-loop",
15
+ "slm-mesh",
16
+ "slm-profile",
17
+ "slm-recall",
18
+ "slm-remember",
19
+ "slm-scope",
20
+ "slm-session",
21
+ "slm-status",
22
+ )
10
23
 
11
24
  # Codex subagent files written to ~/.codex/agents (content built by _agent_files()).
12
25
  AGENTS = ("slm-memory-advisor.toml", "slm-optimize-advisor.toml")
@@ -12,12 +12,18 @@ Provides:
12
12
  V3 change: base directory is ``~/.superlocalmemory/`` (was ``~/.claude-memory/``).
13
13
  """
14
14
 
15
+ import hashlib
15
16
  import json
16
17
  import logging
18
+ import shutil
17
19
  import sqlite3
20
+ import time
21
+ import uuid
22
+ from contextlib import contextmanager
23
+ from dataclasses import asdict, dataclass
18
24
  from datetime import datetime, timedelta, timezone
19
25
  from pathlib import Path
20
- from typing import Dict, List, Optional
26
+ from typing import Dict, Generator, List, Optional
21
27
 
22
28
  from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
23
29
 
@@ -57,6 +63,416 @@ MANAGED_DATABASES: tuple[str, ...] = (
57
63
  )
58
64
 
59
65
 
66
+ # ---------------------------------------------------------------------------
67
+ # Coherent multi-store backup set
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class BackupVerificationError(Exception):
72
+ """Raised when a backup set fails checksum re-verification."""
73
+
74
+
75
+ class BackupRestoreError(Exception):
76
+ """Raised when a restore cannot be safely completed."""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class StoreEntry:
81
+ """Describes one database file within a backup set."""
82
+
83
+ store_name: str # filename, e.g. "memory.db"
84
+ file_path: str # absolute path inside the final backup directory
85
+ size_bytes: int
86
+ sha256: str # SHA-256 hex digest of the backup copy
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class BackupSetManifest:
91
+ """Describes a coherent snapshot of all managed databases.
92
+
93
+ All stores share a single epoch so callers can detect sets assembled
94
+ from different points in time and reject them. Checksums allow
95
+ independent verification of every backup file before restore.
96
+ """
97
+
98
+ set_id: str # unique identifier for this backup set
99
+ epoch: int # Unix timestamp when the set was created
100
+ stores: tuple[StoreEntry, ...] # one entry per backed-up store
101
+ manifest_hash: str # SHA-256 over sorted store checksums
102
+ verified: bool # True only after Phase-4 re-verification
103
+ created_at: str = "" # ISO-8601 UTC creation timestamp
104
+ product_version: str = "" # reserved for version tracking
105
+
106
+
107
+ class BackupCoordinator:
108
+ """Creates and verifies coherent backup sets spanning all managed databases.
109
+
110
+ A backup set groups every managed store under a single epoch and publishes
111
+ an atomic manifest only when all per-store checksums pass re-verification.
112
+ Any mismatch detected during re-verification causes the entire staging
113
+ directory to be removed without publication.
114
+
115
+ Args:
116
+ managed_databases: Ordered tuple of DB filenames to include.
117
+ base_dir: Directory where the live databases reside.
118
+ backup_dir: Directory where backup sets are written.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ managed_databases: tuple[str, ...],
124
+ base_dir: Path,
125
+ backup_dir: Path,
126
+ ) -> None:
127
+ self._managed_databases = managed_databases
128
+ self._base_dir = Path(base_dir)
129
+ self._backup_dir = Path(backup_dir)
130
+
131
+ # ------------------------------------------------------------------
132
+ # Public API
133
+ # ------------------------------------------------------------------
134
+
135
+ def create_backup_set(self) -> BackupSetManifest:
136
+ """Copy all existing managed stores and publish a verified manifest.
137
+
138
+ The algorithm has six phases:
139
+ 1. Identify which stores exist on disk.
140
+ 2. Create a staging directory.
141
+ 3. Fence SQLite writers (BEGIN IMMEDIATE) then copy every store and
142
+ record per-file SHA-256 checksums (Phase 3). If a ``lance/``
143
+ directory exists under the base dir, copy it into staging as well.
144
+ 4. Re-read every staging file and compare to Phase-3 hashes.
145
+ Mismatch → staging removed, BackupVerificationError raised.
146
+ 5. Build the manifest from the verified checksums.
147
+ 6. Atomically rename staging → final directory and write manifest.json.
148
+
149
+ The ``lance/`` directory (LanceDB vector index) is treated as an
150
+ out-of-manifest companion: it is copied recursively into the backup set
151
+ and restored alongside the SQLite stores. Its presence is noted in the
152
+ server log. If absent, the step is silently skipped.
153
+
154
+ Returns:
155
+ BackupSetManifest with verified=True.
156
+
157
+ Raises:
158
+ BackupVerificationError: if any staging file's content changed
159
+ between Phase 3 and Phase 4.
160
+ """
161
+ set_id = uuid.uuid4().hex[:16]
162
+ epoch = int(time.time())
163
+ staging_dir = self._backup_dir / f".staging_{set_id}"
164
+ staging_dir.mkdir(parents=True, exist_ok=True)
165
+
166
+ existing_dbs = [
167
+ db for db in self._managed_databases
168
+ if (self._base_dir / db).exists()
169
+ ]
170
+ sqlite_paths = [self._base_dir / db for db in existing_dbs]
171
+
172
+ # staging_records: (db_name, staging_path, size_bytes, phase3_sha256)
173
+ staging_records: list[tuple[str, Path, int, str]] = []
174
+
175
+ # Phases 2–3: fence writers, copy, hash
176
+ with self._writer_fence(sqlite_paths):
177
+ for db_name in existing_dbs:
178
+ src = self._base_dir / db_name
179
+ dest = staging_dir / db_name
180
+ self._sqlite_backup(src, dest)
181
+ sha = self._compute_entry_sha256(dest)
182
+ staging_records.append((db_name, dest, dest.stat().st_size, sha))
183
+
184
+ # Copy the LanceDB vector directory if present.
185
+ lance_src = self._base_dir / "lance"
186
+ if lance_src.is_dir():
187
+ lance_staging = staging_dir / "lance"
188
+ shutil.copytree(str(lance_src), str(lance_staging))
189
+ logger.info(
190
+ "Backup set %s: captured lance/ directory (%d items)",
191
+ set_id,
192
+ sum(1 for _ in lance_staging.rglob("*") if _.is_file()),
193
+ )
194
+
195
+ # Phase 4: re-verify every staging copy
196
+ for db_name, staging_path, _size, expected_sha in staging_records:
197
+ actual_sha = self._compute_entry_sha256(staging_path)
198
+ if actual_sha != expected_sha:
199
+ shutil.rmtree(str(staging_dir), ignore_errors=True)
200
+ raise BackupVerificationError(
201
+ f"Checksum mismatch for {db_name}: "
202
+ f"expected {expected_sha}, got {actual_sha}"
203
+ )
204
+
205
+ # Phase 5: build manifest (file_path points to where files will land)
206
+ final_dir = self._backup_dir / f"backup_{set_id}"
207
+ entries = tuple(
208
+ StoreEntry(
209
+ store_name=db_name,
210
+ file_path=str(final_dir / db_name),
211
+ size_bytes=size,
212
+ sha256=sha,
213
+ )
214
+ for db_name, _sp, size, sha in staging_records
215
+ )
216
+ manifest = BackupSetManifest(
217
+ set_id=set_id,
218
+ epoch=epoch,
219
+ stores=entries,
220
+ manifest_hash=self._compute_manifest_hash(entries),
221
+ verified=True,
222
+ created_at=datetime.now(timezone.utc).isoformat(),
223
+ )
224
+
225
+ # Phase 6: atomic publish
226
+ staging_dir.rename(final_dir)
227
+ (final_dir / "manifest.json").write_text(
228
+ json.dumps(asdict(manifest), indent=2)
229
+ )
230
+
231
+ return manifest
232
+
233
+ def restore_from_manifest(self, manifest: BackupSetManifest) -> None:
234
+ """Restore all stores from a verified manifest.
235
+
236
+ The restore proceeds in three phases to guarantee cross-store atomicity:
237
+
238
+ Phase A — Verify:
239
+ Re-derive the manifest hash and verify every backup file's checksum.
240
+ No live files are touched until all checks pass. Raises
241
+ BackupRestoreError on any failure.
242
+
243
+ Phase B — Pre-restore snapshot:
244
+ Copy the current live version of every store being restored into a
245
+ ``<store>.pre_restore`` sibling file, and the current ``lance/``
246
+ directory (if present) to ``lance.pre_restore/``. These snapshots
247
+ allow a full rollback if the write phase is interrupted.
248
+
249
+ Phase C — Staged write:
250
+ Copy each backup file to a ``<store>.restore_staging`` temporary,
251
+ then rename it into place. If any step raises, all pre-restore
252
+ snapshots are copied back to their live locations before the error
253
+ is re-raised, returning the live set to its original coherent state.
254
+ On full success, all snapshot files are removed.
255
+
256
+ The ``lance/`` directory companion is handled with the same snapshot and
257
+ rollback discipline: if the backup set contains a ``lance/`` subdirectory
258
+ it is restored recursively; absence is silently skipped on both sides.
259
+
260
+ Raises:
261
+ BackupRestoreError: on unverified manifest, hash mismatch, missing
262
+ or corrupted backup files, or if the staged write phase fails
263
+ and the rollback itself encounters an error.
264
+ """
265
+ if not manifest.verified:
266
+ raise BackupRestoreError("Cannot restore from an unverified manifest")
267
+
268
+ # Phase A-1: Re-derive manifest_hash from the store entries and compare.
269
+ # This detects tampering of manifest.json where an attacker changes a
270
+ # store's sha256 entry without recalculating manifest_hash.
271
+ computed_hash = self._compute_manifest_hash(manifest.stores)
272
+ if computed_hash != manifest.manifest_hash:
273
+ raise BackupRestoreError(
274
+ "Manifest hash mismatch: backup set is incoherent or has been tampered"
275
+ )
276
+
277
+ # Phase A-2: Verify every backup file exists and matches its checksum.
278
+ for entry in manifest.stores:
279
+ src = Path(entry.file_path)
280
+ if not src.exists():
281
+ raise BackupRestoreError(f"Backup file missing: {entry.file_path}")
282
+ actual_sha = self._compute_entry_sha256(src)
283
+ if actual_sha != entry.sha256:
284
+ raise BackupRestoreError(
285
+ f"Corrupted backup file (checksum mismatch): {entry.store_name}"
286
+ )
287
+
288
+ # Determine the backup set directory.
289
+ # Primary: derive from the first store's file_path (most reliable).
290
+ # Fallback for empty-store manifests: reconstruct from the backup_dir +
291
+ # set_id, which is how create_backup_set names the final directory.
292
+ if manifest.stores:
293
+ backup_set_dir: Optional[Path] = Path(manifest.stores[0].file_path).parent
294
+ else:
295
+ candidate = self._backup_dir / f"backup_{manifest.set_id}"
296
+ backup_set_dir = candidate if candidate.is_dir() else None
297
+
298
+ lance_backup: Optional[Path] = (
299
+ backup_set_dir / "lance"
300
+ if backup_set_dir is not None and (backup_set_dir / "lance").is_dir()
301
+ else None
302
+ )
303
+
304
+ # Phase B: Snapshot the current live copies so we can roll back.
305
+ # pre_restore_map: live_target -> pre_restore_snapshot_path
306
+ pre_restore_map: dict[Path, Path] = {}
307
+ pre_restore_lance: Optional[Path] = None
308
+ try:
309
+ for entry in manifest.stores:
310
+ target = self._base_dir / entry.store_name
311
+ snapshot = target.parent / f"{entry.store_name}.pre_restore"
312
+ if target.exists():
313
+ shutil.copy2(str(target), str(snapshot))
314
+ pre_restore_map[target] = snapshot
315
+
316
+ live_lance = self._base_dir / "lance"
317
+ if lance_backup is not None and live_lance.is_dir():
318
+ pre_restore_lance = self._base_dir / "lance.pre_restore"
319
+ if pre_restore_lance.exists():
320
+ shutil.rmtree(str(pre_restore_lance))
321
+ shutil.copytree(str(live_lance), str(pre_restore_lance))
322
+
323
+ except Exception as exc:
324
+ # Snapshot creation failed — clean up any partial snapshots and abort
325
+ # before touching any live files.
326
+ self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
327
+ raise BackupRestoreError(
328
+ f"Pre-restore snapshot failed, no live files were modified: {exc}"
329
+ ) from exc
330
+
331
+ # Phase C: Write restored files into the live directory.
332
+ try:
333
+ for entry in manifest.stores:
334
+ target = self._base_dir / entry.store_name
335
+ staging = target.parent / f"{entry.store_name}.restore_staging"
336
+ shutil.copy2(entry.file_path, str(staging))
337
+ staging.rename(target)
338
+
339
+ if lance_backup is not None:
340
+ live_lance = self._base_dir / "lance"
341
+ lance_staging = self._base_dir / "lance.restore_staging"
342
+ if lance_staging.exists():
343
+ shutil.rmtree(str(lance_staging))
344
+ shutil.copytree(str(lance_backup), str(lance_staging))
345
+ if live_lance.exists():
346
+ shutil.rmtree(str(live_lance))
347
+ lance_staging.rename(live_lance)
348
+ logger.info("Restore: lance/ directory restored from backup set")
349
+
350
+ except Exception as exc:
351
+ # Staged write failed — roll back all live files from pre-restore
352
+ # snapshots so the live set returns to its original coherent state.
353
+ logger.error(
354
+ "Restore write phase failed (%s); rolling back live files to "
355
+ "pre-restore state.",
356
+ exc,
357
+ )
358
+ rollback_errors: list[str] = []
359
+ for target, snapshot in pre_restore_map.items():
360
+ if snapshot.exists():
361
+ try:
362
+ shutil.copy2(str(snapshot), str(target))
363
+ except Exception as rb_exc:
364
+ rollback_errors.append(f"{target.name}: {rb_exc}")
365
+ # Roll back the lance/ directory if a snapshot was taken.
366
+ if pre_restore_lance is not None and pre_restore_lance.exists():
367
+ try:
368
+ live_lance = self._base_dir / "lance"
369
+ if live_lance.exists():
370
+ shutil.rmtree(str(live_lance))
371
+ shutil.copytree(str(pre_restore_lance), str(live_lance))
372
+ except Exception as rb_exc:
373
+ rollback_errors.append(f"lance/: {rb_exc}")
374
+
375
+ self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
376
+
377
+ if rollback_errors:
378
+ raise BackupRestoreError(
379
+ f"Restore failed and rollback encountered errors: "
380
+ f"{'; '.join(rollback_errors)}. Original error: {exc}"
381
+ ) from exc
382
+ raise BackupRestoreError(
383
+ f"Restore write phase failed; live files rolled back to "
384
+ f"pre-restore state: {exc}"
385
+ ) from exc
386
+
387
+ # Phase C succeeded — remove pre-restore snapshots.
388
+ self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
389
+
390
+ # ------------------------------------------------------------------
391
+ # Internal helpers (factored out for subclass testability)
392
+ # ------------------------------------------------------------------
393
+
394
+ def _compute_entry_sha256(self, path: Path) -> str:
395
+ """Return the SHA-256 hex digest of a file's raw bytes."""
396
+ return hashlib.sha256(path.read_bytes()).hexdigest()
397
+
398
+ def _compute_manifest_hash(
399
+ self, entries: tuple[StoreEntry, ...]
400
+ ) -> str:
401
+ """Deterministic hash of all store checksums (sorted for stability)."""
402
+ sorted_checksums = sorted(e.sha256 for e in entries)
403
+ payload = "|".join(sorted_checksums).encode()
404
+ return hashlib.sha256(payload).hexdigest()
405
+
406
+ @contextmanager
407
+ def _writer_fence(
408
+ self, db_paths: list[Path]
409
+ ) -> Generator[None, None, None]:
410
+ """Hold BEGIN IMMEDIATE on every live SQLite DB during the copy window.
411
+
412
+ This blocks concurrent writers for the duration of the copy loop,
413
+ ensuring the source files do not change while being read by
414
+ sqlite3.backup(). Connections are rolled back and closed on exit.
415
+ """
416
+ conns: list[sqlite3.Connection] = []
417
+ for path in db_paths:
418
+ if path.exists():
419
+ conn = sqlite3.connect(str(path))
420
+ conn.execute("BEGIN IMMEDIATE")
421
+ conns.append(conn)
422
+ try:
423
+ yield
424
+ finally:
425
+ for conn in conns:
426
+ try:
427
+ conn.rollback()
428
+ conn.close()
429
+ except Exception: # pragma: no cover – cleanup best-effort
430
+ pass
431
+
432
+ def _sqlite_backup(self, src: Path, dest: Path) -> None:
433
+ """Copy a SQLite database using the Online Backup API (hot copy)."""
434
+ src_conn = sqlite3.connect(str(src))
435
+ dst_conn = sqlite3.connect(str(dest))
436
+ try:
437
+ src_conn.backup(dst_conn)
438
+ finally:
439
+ dst_conn.close()
440
+ src_conn.close()
441
+
442
+ @staticmethod
443
+ def _cleanup_pre_restore_snapshots(
444
+ snapshot_map: dict[Path, Path],
445
+ lance_snapshot: Optional[Path],
446
+ ) -> None:
447
+ """Remove pre-restore snapshot files and directories.
448
+
449
+ Called after a successful restore to tidy up, and on the error path
450
+ after rollback completes. Best-effort: individual removal failures are
451
+ logged but do not raise.
452
+ """
453
+ for snapshot_path in snapshot_map.values():
454
+ if snapshot_path.exists():
455
+ try:
456
+ snapshot_path.unlink()
457
+ except OSError as exc:
458
+ logger.warning(
459
+ "Could not remove pre-restore snapshot %s: %s",
460
+ snapshot_path.name, exc,
461
+ )
462
+ if lance_snapshot is not None and lance_snapshot.exists():
463
+ try:
464
+ shutil.rmtree(str(lance_snapshot))
465
+ except OSError as exc:
466
+ logger.warning(
467
+ "Could not remove lance pre-restore snapshot %s: %s",
468
+ lance_snapshot, exc,
469
+ )
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Legacy per-file backup manager (preserved for backward compatibility)
473
+ # ---------------------------------------------------------------------------
474
+
475
+
60
476
  class BackupManager:
61
477
  """Automated backup manager for SuperLocalMemory V3.
62
478
 
@@ -282,15 +698,26 @@ class BackupManager:
282
698
  logger.error("Backup not found: %s", filename)
283
699
  return False
284
700
 
701
+ # Derive the target database from the backup filename stem.
702
+ # Backup files are named "{stem}-{timestamp}.db", where stem is the
703
+ # database name without extension (e.g., "audit_chain" for audit_chain.db).
704
+ stem_map = {Path(db).stem: db for db in MANAGED_DATABASES}
705
+ file_stem = filename.split("-", 1)[0]
706
+ target_name = stem_map.get(file_stem)
707
+ if target_name is None:
708
+ logger.error(
709
+ "Restore rejected: unrecognised database stem %r in %r; "
710
+ "expected one of %s",
711
+ file_stem,
712
+ filename,
713
+ list(stem_map),
714
+ )
715
+ return False
716
+ target = self.db_path.parent / target_name
717
+
285
718
  try:
286
719
  self.create_backup(label="pre-restore")
287
720
 
288
- target = (
289
- self.db_path.parent / "learning.db"
290
- if filename.startswith("learning-")
291
- else self.db_path
292
- )
293
-
294
721
  src = sqlite3.connect(str(backup_path))
295
722
  dst = sqlite3.connect(str(target))
296
723
  try:
@@ -102,6 +102,23 @@ def is_mcp_server_process(process: SlmProcessInfo) -> bool:
102
102
  )
103
103
 
104
104
 
105
+ def is_unified_daemon_process(process: SlmProcessInfo) -> bool:
106
+ """Return whether *process* is a long-lived shared SLM daemon.
107
+
108
+ A healthy daemon is deliberately detached and therefore commonly has
109
+ PPID 1. That is not evidence that it is stale. Automatic startup reaping
110
+ must never kill one merely because another SLM data root is starting.
111
+ """
112
+ command = process.command.lower()
113
+ return (
114
+ "superlocalmemory.server.unified_daemon" in command
115
+ or "/slm serve" in command
116
+ or " slm serve" in command
117
+ or "/slm daemon" in command
118
+ or " slm daemon" in command
119
+ )
120
+
121
+
105
122
  # ---------------------------------------------------------------------------
106
123
  # Windows no-op stubs (AUDIT FIX H0-HIGH-02)
107
124
  # ---------------------------------------------------------------------------
@@ -475,6 +492,7 @@ else:
475
492
  untracked_orphans = [
476
493
  o for o in find_orphans(config)
477
494
  if o.pid not in tracked_pids
495
+ and not is_unified_daemon_process(o)
478
496
  ]
479
497
  for orphan in untracked_orphans:
480
498
  logger.warning(