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
@@ -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 to SHA-256[:16] for grouping.
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 hashlib
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 _hash_query(query: str) -> str:
174
- """Privacy-preserving SHA-256[:16] query hash."""
175
- return hashlib.sha256(query.encode("utf-8")).hexdigest()[:16]
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 SHA-256[:16]
361
- hash — full text is never persisted.
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 = _hash_query(query) if query else None
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