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
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Boot self-heal: idempotently remove provably-dead SLM lock/PID artifacts.
|
|
6
|
+
|
|
7
|
+
H1 from the No-Deadlock Hardening Plan:
|
|
8
|
+
- Called on every daemon boot BEFORE the writer claim.
|
|
9
|
+
- Removes ONLY artifacts whose owner PID is provably dead.
|
|
10
|
+
- Never removes an artifact whose owner is a verified-live SLM process.
|
|
11
|
+
- Never kills any process — file removal only.
|
|
12
|
+
- PID-reuse safety: verifies process create_time or command-line when a PID
|
|
13
|
+
is numerically alive to avoid treating an unrelated process as ours.
|
|
14
|
+
|
|
15
|
+
H4 from the plan (mesh TTL expiry):
|
|
16
|
+
- expire_stale_mesh_locks() purges expired mesh_locks rows on boot.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import socket
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Plain-text single-PID files to check.
|
|
30
|
+
_PLAIN_PID_NAMES: tuple[str, ...] = (
|
|
31
|
+
".reranker-worker.pid",
|
|
32
|
+
".embedding.lock",
|
|
33
|
+
".config.json.lock",
|
|
34
|
+
"daemon.pid",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Unix socket artifact names.
|
|
38
|
+
_SOCKET_NAMES: tuple[str, ...] = ("hook_daemon.sock",)
|
|
39
|
+
|
|
40
|
+
# SLM process fingerprints for command-line checks.
|
|
41
|
+
# Covers the main daemon, packaged CLI entry points, and all named workers.
|
|
42
|
+
_SLM_EXECUTABLE_NAMES: tuple[str, ...] = (
|
|
43
|
+
"slm",
|
|
44
|
+
"superlocalmemory",
|
|
45
|
+
"unified_daemon.py",
|
|
46
|
+
"remember_runtime.py",
|
|
47
|
+
"reranker_worker.py",
|
|
48
|
+
"embedding_worker.py",
|
|
49
|
+
"recall_worker.py",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Low-level PID helpers (additive; reuse CLI helpers where possible)
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def _is_pid_alive(pid: int) -> bool:
|
|
58
|
+
"""Return True iff *pid* exists in the kernel process table.
|
|
59
|
+
|
|
60
|
+
G-09: PermissionError means the process EXISTS but we cannot signal it
|
|
61
|
+
(e.g. different user). We treat it as ALIVE to avoid wrongly removing
|
|
62
|
+
a live process's artifact.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
import psutil
|
|
66
|
+
return psutil.pid_exists(pid)
|
|
67
|
+
except ImportError:
|
|
68
|
+
try:
|
|
69
|
+
os.kill(pid, 0)
|
|
70
|
+
return True
|
|
71
|
+
except ProcessLookupError:
|
|
72
|
+
return False
|
|
73
|
+
except PermissionError:
|
|
74
|
+
# Process exists but we lack permission to signal it — treat as alive.
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _pid_create_time(pid: int) -> float | None:
|
|
79
|
+
"""Return process create_time (seconds since epoch) or None if unavailable."""
|
|
80
|
+
try:
|
|
81
|
+
import psutil
|
|
82
|
+
return psutil.Process(pid).create_time()
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pid_cmdline(pid: int) -> str:
|
|
88
|
+
"""Return space-joined cmdline for *pid*, empty string on error."""
|
|
89
|
+
try:
|
|
90
|
+
import psutil
|
|
91
|
+
return " ".join(psutil.Process(pid).cmdline())
|
|
92
|
+
except Exception:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pid_cmdline_parts(pid: int) -> list[str]:
|
|
97
|
+
"""Return argv components for *pid*, or an empty list on failure."""
|
|
98
|
+
try:
|
|
99
|
+
import psutil
|
|
100
|
+
return [str(part) for part in psutil.Process(pid).cmdline()]
|
|
101
|
+
except Exception:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _pid_is_slm(pid: int) -> bool:
|
|
106
|
+
"""Return True iff *pid* is alive AND its command line looks like an SLM process."""
|
|
107
|
+
if not _is_pid_alive(pid):
|
|
108
|
+
return False
|
|
109
|
+
argv = _pid_cmdline_parts(pid)
|
|
110
|
+
if not argv:
|
|
111
|
+
return False
|
|
112
|
+
executable = Path(argv[0]).name.lower()
|
|
113
|
+
if executable in _SLM_EXECUTABLE_NAMES:
|
|
114
|
+
return True
|
|
115
|
+
if not (executable.startswith("python") or executable.startswith("pypy")):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Parse interpreter arguments in order. Only a leading ``-m`` module or
|
|
119
|
+
# the first script operand can establish identity; anything after ``-c``
|
|
120
|
+
# or a script operand belongs to the executed program, not the interpreter.
|
|
121
|
+
index = 1
|
|
122
|
+
options_with_values = {"-W", "-X", "--check-hash-based-pycs"}
|
|
123
|
+
while index < len(argv):
|
|
124
|
+
part = argv[index]
|
|
125
|
+
if part == "--":
|
|
126
|
+
if index + 1 >= len(argv):
|
|
127
|
+
return False
|
|
128
|
+
return Path(argv[index + 1]).name.lower() in _SLM_EXECUTABLE_NAMES
|
|
129
|
+
if part == "-":
|
|
130
|
+
return False
|
|
131
|
+
if part == "-m":
|
|
132
|
+
if index + 1 >= len(argv):
|
|
133
|
+
return False
|
|
134
|
+
module = argv[index + 1].lower()
|
|
135
|
+
return module == "superlocalmemory" or module.startswith(
|
|
136
|
+
"superlocalmemory."
|
|
137
|
+
)
|
|
138
|
+
if part.startswith("-m") and len(part) > 2:
|
|
139
|
+
module = part[2:].lower()
|
|
140
|
+
return module == "superlocalmemory" or module.startswith(
|
|
141
|
+
"superlocalmemory."
|
|
142
|
+
)
|
|
143
|
+
if part.startswith("-c"):
|
|
144
|
+
return False
|
|
145
|
+
if part in options_with_values:
|
|
146
|
+
index += 2
|
|
147
|
+
continue
|
|
148
|
+
if part.startswith("-W") or part.startswith("-X"):
|
|
149
|
+
index += 1
|
|
150
|
+
continue
|
|
151
|
+
if part.startswith("-"):
|
|
152
|
+
index += 1
|
|
153
|
+
continue
|
|
154
|
+
return Path(part).name.lower() in _SLM_EXECUTABLE_NAMES
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pid_matches_claimed_at(pid: int, claimed_at_ms: int) -> bool:
|
|
159
|
+
"""Return True iff process create_time matches *claimed_at_ms* within 300 s.
|
|
160
|
+
|
|
161
|
+
Converts the millisecond timestamp from the writer-lock JSON to seconds
|
|
162
|
+
and compares against the kernel's process-start epoch.
|
|
163
|
+
|
|
164
|
+
G-03: window widened from 10 s → 300 s. This is a SECONDARY signal only —
|
|
165
|
+
create_time mismatch alone never removes a verified-live SLM process.
|
|
166
|
+
It is only checked to detect PID reuse when the PID is alive but NOT
|
|
167
|
+
identified as SLM by _pid_is_slm().
|
|
168
|
+
"""
|
|
169
|
+
actual = _pid_create_time(pid)
|
|
170
|
+
if actual is None:
|
|
171
|
+
# psutil unavailable — cannot verify, assume matches (safe default).
|
|
172
|
+
return True
|
|
173
|
+
claimed_s = claimed_at_ms / 1000.0
|
|
174
|
+
return abs(actual - claimed_s) <= 300.0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Per-artifact-type PID extraction
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _read_json_pid(path: Path) -> tuple[int | None, int | None]:
|
|
182
|
+
"""Read ``(pid, claimed_at_ms)`` from a JSON writer-lock metadata file.
|
|
183
|
+
|
|
184
|
+
The ``*.writer.lock`` files written by WriteCoordinator contain::
|
|
185
|
+
|
|
186
|
+
{"pid": N, "owner_id": "...", "claimed_at_ms": N, "database": "..."}
|
|
187
|
+
|
|
188
|
+
Returns ``(None, None)`` when the file is absent, empty, or malformed.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
raw = path.read_bytes()
|
|
192
|
+
if not raw.strip():
|
|
193
|
+
return None, None
|
|
194
|
+
data = json.loads(raw)
|
|
195
|
+
pid = int(data["pid"])
|
|
196
|
+
claimed_at_ms_raw = data.get("claimed_at_ms")
|
|
197
|
+
claimed_at_ms = int(claimed_at_ms_raw) if claimed_at_ms_raw else None
|
|
198
|
+
return pid, claimed_at_ms
|
|
199
|
+
except Exception:
|
|
200
|
+
return None, None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _read_plain_pid(path: Path) -> int | None:
|
|
204
|
+
"""Read a plain-text (integer) PID file. Returns ``None`` on any error."""
|
|
205
|
+
try:
|
|
206
|
+
return int(path.read_text(encoding="utf-8").strip())
|
|
207
|
+
except Exception:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _socket_has_listener(sock_path: Path) -> bool:
|
|
212
|
+
"""Return True iff a Unix-domain socket file has an active listener.
|
|
213
|
+
|
|
214
|
+
G-06: retry 3× with short backoff (total < 1.5 s) before returning False.
|
|
215
|
+
The daemon may be mid-bind when we check at boot; a single probe can give
|
|
216
|
+
a false-negative that causes a valid socket to be deleted.
|
|
217
|
+
|
|
218
|
+
Backoff schedule: attempt 0 (immediate), attempt 1 (+0.2 s), attempt 2 (+0.4 s).
|
|
219
|
+
Per-attempt connect timeout: 0.3 s. Total wall time ≤ 0.9 s + 0.6 s = 1.5 s.
|
|
220
|
+
"""
|
|
221
|
+
import time
|
|
222
|
+
|
|
223
|
+
_DELAYS = (0.0, 0.2, 0.4) # pre-attempt sleep seconds
|
|
224
|
+
for delay in _DELAYS:
|
|
225
|
+
if delay > 0:
|
|
226
|
+
time.sleep(delay)
|
|
227
|
+
try:
|
|
228
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
229
|
+
s.settimeout(0.3)
|
|
230
|
+
s.connect(str(sock_path))
|
|
231
|
+
s.close()
|
|
232
|
+
return True
|
|
233
|
+
except (ConnectionRefusedError, FileNotFoundError, OSError):
|
|
234
|
+
continue
|
|
235
|
+
except Exception:
|
|
236
|
+
continue
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Safe-remove helper
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def _safe_unlink(path: Path, report: dict, reason: str) -> None:
|
|
245
|
+
"""Remove *path* and record the result in *report*. Never raises."""
|
|
246
|
+
try:
|
|
247
|
+
path.unlink(missing_ok=True)
|
|
248
|
+
report["removed"].append({"path": str(path), "reason": reason})
|
|
249
|
+
logger.info("self_heal: removed stale artifact %s (%s)", path.name, reason)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
report["errors"].append({"path": str(path), "error": str(exc)})
|
|
252
|
+
logger.warning("self_heal: could not remove %s: %s", path, exc)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Public API — H1
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def reap_stale_artifacts(data_dir: Path) -> dict:
|
|
260
|
+
"""Idempotently remove SLM artifacts whose owner PID is provably dead.
|
|
261
|
+
|
|
262
|
+
Safety invariants (never violated):
|
|
263
|
+
- A live, verified SLM process's artifacts are NEVER removed.
|
|
264
|
+
- No process is ever signalled or killed; only file paths are unlinked.
|
|
265
|
+
- PID-reuse is detected and treated as a dead owner (removes the stale
|
|
266
|
+
artifact, never the live unrelated process).
|
|
267
|
+
|
|
268
|
+
Returns a report dict::
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
"removed": [{"path": str, "reason": str}, ...],
|
|
272
|
+
"kept": [str, ...], # paths left because owner is alive
|
|
273
|
+
"errors": [{"path": str, "error": str}, ...],
|
|
274
|
+
}
|
|
275
|
+
"""
|
|
276
|
+
report: dict = {"removed": [], "kept": [], "errors": []}
|
|
277
|
+
data_dir = Path(data_dir)
|
|
278
|
+
if not data_dir.is_dir():
|
|
279
|
+
return report
|
|
280
|
+
|
|
281
|
+
# --- JSON writer-lock metadata files (*.writer.lock) --------------------
|
|
282
|
+
# The portalocker flock is already auto-released when the holder dies;
|
|
283
|
+
# these JSON bodies are purely informational. Removing them when the
|
|
284
|
+
# recorded PID is dead or reused clears up stale metadata without
|
|
285
|
+
# affecting the actual OS advisory lock.
|
|
286
|
+
for lock_file in data_dir.glob("*.writer.lock"):
|
|
287
|
+
pid, claimed_at_ms = _read_json_pid(lock_file)
|
|
288
|
+
if pid is None:
|
|
289
|
+
# Empty or unreadable — safe to remove.
|
|
290
|
+
_safe_unlink(lock_file, report, "unreadable_metadata")
|
|
291
|
+
continue
|
|
292
|
+
if not _is_pid_alive(pid):
|
|
293
|
+
_safe_unlink(lock_file, report, "dead_owner_pid")
|
|
294
|
+
elif _pid_is_slm(pid):
|
|
295
|
+
# G-03: live AND verified SLM owner → KEEP unconditionally.
|
|
296
|
+
# create_time check is skipped — a live SLM process always wins.
|
|
297
|
+
report["kept"].append(str(lock_file))
|
|
298
|
+
elif claimed_at_ms is not None and not _pid_matches_claimed_at(pid, claimed_at_ms):
|
|
299
|
+
# Alive but NOT SLM and create_time mismatch → PID was reused.
|
|
300
|
+
_safe_unlink(lock_file, report, "pid_reused")
|
|
301
|
+
else:
|
|
302
|
+
# Alive but cmdline is not SLM → reused by an unrelated process.
|
|
303
|
+
_safe_unlink(lock_file, report, "pid_reused_non_slm")
|
|
304
|
+
|
|
305
|
+
# --- Plain-text PID files -----------------------------------------------
|
|
306
|
+
for name in _PLAIN_PID_NAMES:
|
|
307
|
+
path = data_dir / name
|
|
308
|
+
if not path.exists():
|
|
309
|
+
continue
|
|
310
|
+
pid = _read_plain_pid(path)
|
|
311
|
+
if pid is None:
|
|
312
|
+
_safe_unlink(path, report, "unreadable_pid_file")
|
|
313
|
+
continue
|
|
314
|
+
if not _is_pid_alive(pid):
|
|
315
|
+
_safe_unlink(path, report, "dead_owner_pid")
|
|
316
|
+
elif not _pid_is_slm(pid):
|
|
317
|
+
# PID alive but command is not SLM → reused by an unrelated process.
|
|
318
|
+
_safe_unlink(path, report, "pid_reused_non_slm")
|
|
319
|
+
else:
|
|
320
|
+
report["kept"].append(str(path))
|
|
321
|
+
|
|
322
|
+
# --- Unix socket artifacts -----------------------------------------------
|
|
323
|
+
for name in _SOCKET_NAMES:
|
|
324
|
+
path = data_dir / name
|
|
325
|
+
if not path.exists():
|
|
326
|
+
continue
|
|
327
|
+
if not _socket_has_listener(path):
|
|
328
|
+
_safe_unlink(path, report, "no_listener")
|
|
329
|
+
else:
|
|
330
|
+
report["kept"].append(str(path))
|
|
331
|
+
|
|
332
|
+
return report
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# Public API — H4 (team-mode mesh-lock expiry)
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
_MESH_NEVER_EXPIRES = "9999-12-31T23:59:59Z"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def expire_stale_mesh_locks(db_path: Path) -> int:
|
|
343
|
+
"""Delete expired TTL rows from ``mesh_locks`` on boot.
|
|
344
|
+
|
|
345
|
+
A row is stale iff:
|
|
346
|
+
- ``expires_at`` is not NULL,
|
|
347
|
+
- ``expires_at`` is not the legacy ``_NEVER_EXPIRES`` sentinel, and
|
|
348
|
+
- ``expires_at <= now_iso`` (i.e. the lease has elapsed).
|
|
349
|
+
|
|
350
|
+
Keeps fencing intact: only the TTL expiry gate is touched. Any row whose
|
|
351
|
+
lease is still valid is left untouched. Fail-soft — returns 0 on error
|
|
352
|
+
(e.g. if the mesh_locks table does not exist yet).
|
|
353
|
+
|
|
354
|
+
Returns the count of deleted rows.
|
|
355
|
+
"""
|
|
356
|
+
import sqlite3
|
|
357
|
+
from datetime import datetime, timezone
|
|
358
|
+
|
|
359
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
360
|
+
try:
|
|
361
|
+
conn = sqlite3.connect(str(db_path), timeout=2.0)
|
|
362
|
+
try:
|
|
363
|
+
# G-07: BEGIN IMMEDIATE prevents a concurrent write from seeing our
|
|
364
|
+
# DELETE mid-flight. On OperationalError (DB locked by another
|
|
365
|
+
# writer) we log a warning and return 0 — fail-soft, non-blocking.
|
|
366
|
+
try:
|
|
367
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
368
|
+
except sqlite3.OperationalError as _lock_exc:
|
|
369
|
+
logger.warning(
|
|
370
|
+
"expire_stale_mesh_locks: DB locked, skipping this boot: %s",
|
|
371
|
+
_lock_exc,
|
|
372
|
+
)
|
|
373
|
+
return 0
|
|
374
|
+
cur = conn.execute(
|
|
375
|
+
"DELETE FROM mesh_locks "
|
|
376
|
+
"WHERE expires_at IS NOT NULL "
|
|
377
|
+
" AND expires_at != ? "
|
|
378
|
+
" AND expires_at <= ?",
|
|
379
|
+
(_MESH_NEVER_EXPIRES, now),
|
|
380
|
+
)
|
|
381
|
+
conn.commit()
|
|
382
|
+
deleted = cur.rowcount or 0
|
|
383
|
+
if deleted:
|
|
384
|
+
logger.info(
|
|
385
|
+
"self_heal: expired %d stale mesh_lock row(s) from %s",
|
|
386
|
+
deleted, db_path.name,
|
|
387
|
+
)
|
|
388
|
+
return deleted
|
|
389
|
+
finally:
|
|
390
|
+
conn.close()
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
logger.debug(
|
|
393
|
+
"expire_stale_mesh_locks: %s (table may not exist yet)", exc,
|
|
394
|
+
)
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
__all__ = [
|
|
399
|
+
"reap_stale_artifacts",
|
|
400
|
+
"expire_stale_mesh_locks",
|
|
401
|
+
]
|
|
@@ -12,7 +12,7 @@ Collects implicit and explicit relevance signals:
|
|
|
12
12
|
|
|
13
13
|
Privacy:
|
|
14
14
|
- Full query text is NEVER stored.
|
|
15
|
-
- Queries are hashed
|
|
15
|
+
- Queries are keyed-hashed for local grouping; the key never enters SQLite.
|
|
16
16
|
|
|
17
17
|
Storage:
|
|
18
18
|
Every explicit-feedback event is written to the CANONICAL learning store
|
|
@@ -39,8 +39,10 @@ Storage:
|
|
|
39
39
|
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
-
import
|
|
42
|
+
import hmac
|
|
43
43
|
import logging
|
|
44
|
+
import os
|
|
45
|
+
import secrets
|
|
44
46
|
import sqlite3
|
|
45
47
|
import threading
|
|
46
48
|
from dataclasses import dataclass
|
|
@@ -170,9 +172,46 @@ def _canonical_schema_ready(conn: sqlite3.Connection) -> bool:
|
|
|
170
172
|
)
|
|
171
173
|
|
|
172
174
|
|
|
173
|
-
def
|
|
174
|
-
"""
|
|
175
|
-
|
|
175
|
+
def _load_or_create_hash_key(db_path: Path) -> bytes:
|
|
176
|
+
"""Return an owner-only per-install key for feedback query HMACs."""
|
|
177
|
+
key_path = db_path.parent / ".feedback-hash-key"
|
|
178
|
+
try:
|
|
179
|
+
key = key_path.read_bytes()
|
|
180
|
+
if len(key) >= 32:
|
|
181
|
+
os.chmod(key_path, 0o600)
|
|
182
|
+
return key
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
key = secrets.token_bytes(32)
|
|
187
|
+
try:
|
|
188
|
+
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
189
|
+
except FileExistsError:
|
|
190
|
+
existing = key_path.read_bytes()
|
|
191
|
+
if len(existing) < 32:
|
|
192
|
+
raise RuntimeError("feedback hash key is truncated")
|
|
193
|
+
os.chmod(key_path, 0o600)
|
|
194
|
+
return existing
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
# Do not fall back to a guessable digest on a read-only data root.
|
|
197
|
+
# A process-local key preserves privacy; only cross-restart grouping
|
|
198
|
+
# is lost, and the operator gets an explicit warning.
|
|
199
|
+
logger.warning(
|
|
200
|
+
"cannot persist feedback hash key beside %s: %s; using a "
|
|
201
|
+
"process-local key", db_path, exc,
|
|
202
|
+
)
|
|
203
|
+
return key
|
|
204
|
+
with os.fdopen(fd, "wb") as handle:
|
|
205
|
+
handle.write(key)
|
|
206
|
+
handle.flush()
|
|
207
|
+
os.fsync(handle.fileno())
|
|
208
|
+
os.chmod(key_path, 0o600)
|
|
209
|
+
return key
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _hash_query(query: str, key: bytes) -> str:
|
|
213
|
+
"""Return a keyed, truncated SHA-256 digest for local query grouping."""
|
|
214
|
+
return hmac.digest(key, query.encode("utf-8"), "sha256").hex()[:16]
|
|
176
215
|
|
|
177
216
|
|
|
178
217
|
class FeedbackCollector:
|
|
@@ -188,6 +227,7 @@ class FeedbackCollector:
|
|
|
188
227
|
|
|
189
228
|
def __init__(self, db_path: Path) -> None:
|
|
190
229
|
self._db_path = Path(db_path)
|
|
230
|
+
self._query_hash_key = _load_or_create_hash_key(self._db_path)
|
|
191
231
|
self._lock = threading.Lock()
|
|
192
232
|
# Latched once the canonical LLD-02 tables are confirmed present, so
|
|
193
233
|
# the sqlite_master probe runs at most once per collector instead of
|
|
@@ -262,7 +302,7 @@ class FeedbackCollector:
|
|
|
262
302
|
if not profile_id or not query:
|
|
263
303
|
return 0
|
|
264
304
|
|
|
265
|
-
qhash = _hash_query(query)
|
|
305
|
+
qhash = _hash_query(query, self._query_hash_key)
|
|
266
306
|
returned_set = set(fact_ids_returned)
|
|
267
307
|
now = _utcnow_iso()
|
|
268
308
|
records: list[tuple] = []
|
|
@@ -357,8 +397,9 @@ class FeedbackCollector:
|
|
|
357
397
|
signal_type: One of ``user_positive``, ``user_negative``,
|
|
358
398
|
``user_correction``, or any custom type.
|
|
359
399
|
value: Numeric signal value (0.0 to 1.0).
|
|
360
|
-
query: Originating query. Stored only as a
|
|
361
|
-
|
|
400
|
+
query: Originating query. Stored only as a keyed truncated
|
|
401
|
+
digest — full text and the HMAC key are never stored
|
|
402
|
+
in SQLite.
|
|
362
403
|
channel: Retrieval channel that surfaced the fact.
|
|
363
404
|
|
|
364
405
|
Returns:
|
|
@@ -369,7 +410,9 @@ class FeedbackCollector:
|
|
|
369
410
|
|
|
370
411
|
clamped = max(0.0, min(1.0, float(value)))
|
|
371
412
|
now = _utcnow_iso()
|
|
372
|
-
query_hash =
|
|
413
|
+
query_hash = (
|
|
414
|
+
_hash_query(query, self._query_hash_key) if query else None
|
|
415
|
+
)
|
|
373
416
|
|
|
374
417
|
with self._lock:
|
|
375
418
|
conn = self._connect()
|
|
@@ -139,6 +139,16 @@ def run_bounded_loop(
|
|
|
139
139
|
budget.spend(result.tokens)
|
|
140
140
|
lap_changes.append(result.changed)
|
|
141
141
|
|
|
142
|
+
# 4b. The budget is a HARD ceiling and takes precedence over the gate.
|
|
143
|
+
# Token spend and elapsed wall-clock are only knowable AFTER the lap
|
|
144
|
+
# runs, so re-check here: an overshooting lap must HALT even when its own
|
|
145
|
+
# gate would pass. ``lap`` (not lap+1) keeps the iteration cap from
|
|
146
|
+
# tripping while we are still within the allowed iteration count.
|
|
147
|
+
tripped, why = budget.exceeded(lap, bounds)
|
|
148
|
+
if tripped:
|
|
149
|
+
emit("halt", Verdict(False, why), result)
|
|
150
|
+
return Outcome(Status.HALT, why, lap, run_id)
|
|
151
|
+
|
|
142
152
|
# 5. Independent gate — agent's own claim is never consulted here.
|
|
143
153
|
try:
|
|
144
154
|
verdict = gate(lap)
|
|
@@ -83,6 +83,7 @@ class DaemonPoolProxy:
|
|
|
83
83
|
include_global: bool | None = None,
|
|
84
84
|
include_shared: bool | None = None,
|
|
85
85
|
window: str | None = None,
|
|
86
|
+
as_of: str | None = None,
|
|
86
87
|
) -> dict[str, Any]:
|
|
87
88
|
if self._unavailable:
|
|
88
89
|
return self._unavailable_response()
|
|
@@ -105,6 +106,8 @@ class DaemonPoolProxy:
|
|
|
105
106
|
_params["include_shared"] = "true" if include_shared else "false"
|
|
106
107
|
if window:
|
|
107
108
|
_params["window"] = window
|
|
109
|
+
if as_of:
|
|
110
|
+
_params["as_of"] = as_of
|
|
108
111
|
params = urllib.parse.urlencode(_params)
|
|
109
112
|
try:
|
|
110
113
|
from superlocalmemory.cli.daemon import daemon_request
|