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
|
@@ -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 = (
|
|
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(
|