superlocalmemory 3.2.2 → 3.3.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/CHANGELOG.md +43 -1
- package/README.md +106 -71
- package/package.json +1 -2
- package/pyproject.toml +16 -1
- package/src/superlocalmemory/cli/commands.py +309 -0
- package/src/superlocalmemory/cli/main.py +44 -0
- package/src/superlocalmemory/core/config.py +282 -11
- package/src/superlocalmemory/core/consolidation_engine.py +37 -0
- package/src/superlocalmemory/core/engine.py +21 -0
- package/src/superlocalmemory/core/engine_wiring.py +58 -8
- package/src/superlocalmemory/dynamics/activation_guided_quantization.py +374 -0
- package/src/superlocalmemory/dynamics/eap_scheduler.py +276 -0
- package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +171 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +804 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +46 -8
- package/src/superlocalmemory/hooks/auto_parameterize.py +147 -0
- package/src/superlocalmemory/infra/heartbeat_monitor.py +140 -0
- package/src/superlocalmemory/infra/pid_manager.py +193 -0
- package/src/superlocalmemory/infra/process_reaper.py +572 -0
- package/src/superlocalmemory/learning/consolidation_quantization_worker.py +115 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +263 -0
- package/src/superlocalmemory/learning/quantization_scheduler.py +320 -0
- package/src/superlocalmemory/math/ebbinghaus.py +309 -0
- package/src/superlocalmemory/math/fisher_quantized.py +251 -0
- package/src/superlocalmemory/math/hopfield.py +279 -0
- package/src/superlocalmemory/math/polar_quant.py +379 -0
- package/src/superlocalmemory/math/qjl.py +115 -0
- package/src/superlocalmemory/mcp/server.py +2 -0
- package/src/superlocalmemory/mcp/tools_v3.py +10 -0
- package/src/superlocalmemory/mcp/tools_v33.py +351 -0
- package/src/superlocalmemory/parameterization/__init__.py +47 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +534 -0
- package/src/superlocalmemory/parameterization/pii_filter.py +106 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +216 -0
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +275 -0
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +425 -0
- package/src/superlocalmemory/retrieval/engine.py +21 -3
- package/src/superlocalmemory/retrieval/forgetting_filter.py +145 -0
- package/src/superlocalmemory/retrieval/hopfield_channel.py +335 -0
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +133 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +1 -1
- package/src/superlocalmemory/retrieval/strategy.py +16 -6
- package/src/superlocalmemory/retrieval/vector_store.py +1 -1
- package/src/superlocalmemory/server/routes/agents.py +68 -8
- package/src/superlocalmemory/server/routes/learning.py +18 -1
- package/src/superlocalmemory/server/routes/lifecycle.py +36 -17
- package/src/superlocalmemory/server/routes/v3_api.py +503 -1
- package/src/superlocalmemory/storage/database.py +206 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +178 -0
- package/src/superlocalmemory/storage/migration_v33.py +140 -0
- package/src/superlocalmemory/storage/quantized_store.py +261 -0
- package/src/superlocalmemory/storage/schema_v32.py +137 -0
- package/conftest.py +0 -5
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SuperLocalMemory V3 -- Process Reaper.
|
|
6
|
+
|
|
7
|
+
Detects and kills orphaned SLM MCP processes whose parent IDE/Claude session
|
|
8
|
+
has died. Prevents RAM exhaustion from zombie embedding workers (1.5-2 GB each).
|
|
9
|
+
|
|
10
|
+
Discovery: March 30, 2026 -- Saturday session's SLM MCP (PID 15493) still alive
|
|
11
|
+
Monday night, consuming 1.8 GB.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from superlocalmemory.infra.pid_manager import PidManager
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Local config (not modifying core/config.py per instructions)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ReaperConfig:
|
|
38
|
+
"""Configuration for process health & stale reaper (Phase H0).
|
|
39
|
+
|
|
40
|
+
Ships enabled by default. Prevents zombie SLM processes from
|
|
41
|
+
exhausting RAM (1.5-2 GB per orphaned embedding worker).
|
|
42
|
+
|
|
43
|
+
Advisory ranges: heartbeat 10-300s, orphan_age 0.5-48h,
|
|
44
|
+
graceful_timeout 1-30s.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
heartbeat_interval_seconds: int = 60
|
|
49
|
+
orphan_age_threshold_hours: float = 4.0
|
|
50
|
+
pid_file_path: str = "" # Empty = default (~/.superlocalmemory/slm.pids)
|
|
51
|
+
graceful_timeout_seconds: float = 5.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Constants
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
KNOWN_LAUNCHER_NAMES: frozenset[str] = frozenset({
|
|
59
|
+
"node", "claude", "code", "cursor", "windsurf", "zed",
|
|
60
|
+
"vim", "nvim", "emacs", "idea", "pycharm", "webstorm",
|
|
61
|
+
"python", "python3",
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Data model
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SlmProcessInfo:
|
|
71
|
+
"""Information about a running SLM process."""
|
|
72
|
+
|
|
73
|
+
pid: int
|
|
74
|
+
ppid: int
|
|
75
|
+
start_time: float
|
|
76
|
+
command: str
|
|
77
|
+
is_orphan: bool
|
|
78
|
+
parent_name: str
|
|
79
|
+
age_hours: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Windows no-op stubs (AUDIT FIX H0-HIGH-02)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
if sys.platform == "win32": # pragma: no cover
|
|
87
|
+
logger.info(
|
|
88
|
+
"Process reaper not supported on Windows, "
|
|
89
|
+
"all functions return no-op results"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def find_slm_processes() -> list[SlmProcessInfo]:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
def _check_parent(ppid: int) -> tuple[bool, str]:
|
|
96
|
+
return (False, "")
|
|
97
|
+
|
|
98
|
+
def find_orphans(config: ReaperConfig) -> list[SlmProcessInfo]:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
def kill_orphan(
|
|
102
|
+
pid: int, graceful_timeout_seconds: float = 5.0
|
|
103
|
+
) -> dict[str, object]:
|
|
104
|
+
return {
|
|
105
|
+
"pid": pid, "killed": False,
|
|
106
|
+
"method": "unsupported",
|
|
107
|
+
"error": "Windows not supported",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def reap_stale_on_startup(
|
|
111
|
+
config: ReaperConfig, pid_manager: PidManager
|
|
112
|
+
) -> dict[str, object]:
|
|
113
|
+
return {
|
|
114
|
+
"orphans_found": 0, "orphans_killed": 0,
|
|
115
|
+
"errors": [], "registered_pid": 0,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def cleanup_all_orphans(
|
|
119
|
+
config: ReaperConfig, dry_run: bool = False, force: bool = False
|
|
120
|
+
) -> dict[str, object]:
|
|
121
|
+
return {
|
|
122
|
+
"total_found": 0, "orphans_found": 0, "killed": 0,
|
|
123
|
+
"skipped": 0, "errors": [], "processes": [],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
else:
|
|
127
|
+
# -----------------------------------------------------------------------
|
|
128
|
+
# Full POSIX implementation
|
|
129
|
+
# -----------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _check_parent(ppid: int) -> tuple[bool, str]:
|
|
132
|
+
"""Check if a parent process is alive and is a known launcher.
|
|
133
|
+
|
|
134
|
+
Returns (is_orphan, parent_name).
|
|
135
|
+
Conservative: never marks a living process as orphan.
|
|
136
|
+
"""
|
|
137
|
+
if ppid <= 1:
|
|
138
|
+
return (True, "init")
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
os.kill(ppid, 0)
|
|
142
|
+
except ProcessLookupError:
|
|
143
|
+
return (True, "")
|
|
144
|
+
except PermissionError:
|
|
145
|
+
return (False, "unknown")
|
|
146
|
+
|
|
147
|
+
# Parent is alive -- try to read its name
|
|
148
|
+
parent_name = ""
|
|
149
|
+
try:
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
["ps", "-p", str(ppid), "-o", "comm="],
|
|
152
|
+
capture_output=True, text=True, timeout=5,
|
|
153
|
+
)
|
|
154
|
+
if result.returncode == 0:
|
|
155
|
+
parent_name = os.path.basename(result.stdout.strip())
|
|
156
|
+
except (subprocess.SubprocessError, OSError):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
return (False, parent_name)
|
|
160
|
+
|
|
161
|
+
def find_slm_processes() -> list[SlmProcessInfo]:
|
|
162
|
+
"""Find all running SLM-related processes on the system.
|
|
163
|
+
|
|
164
|
+
Uses POSIX `ps` command. Returns empty list on failure.
|
|
165
|
+
"""
|
|
166
|
+
my_pid = os.getpid()
|
|
167
|
+
results: list[SlmProcessInfo] = []
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
proc = subprocess.run(
|
|
171
|
+
["ps", "-eo", "pid,ppid,lstart,command"],
|
|
172
|
+
capture_output=True, text=True, timeout=10,
|
|
173
|
+
)
|
|
174
|
+
if proc.returncode != 0:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"ps command failed with code %d", proc.returncode
|
|
177
|
+
)
|
|
178
|
+
return []
|
|
179
|
+
except (subprocess.SubprocessError, FileNotFoundError) as exc:
|
|
180
|
+
logger.warning("Cannot run ps command: %s", exc)
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
for line in proc.stdout.strip().split("\n")[1:]:
|
|
184
|
+
line = line.strip()
|
|
185
|
+
if not line:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if "superlocalmemory" not in line:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
parts = line.split()
|
|
193
|
+
pid = int(parts[0])
|
|
194
|
+
ppid = int(parts[1])
|
|
195
|
+
|
|
196
|
+
if pid == my_pid:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# lstart is 5 fields: "Mon Mar 30 14:25:01 2026"
|
|
200
|
+
lstart_str = " ".join(parts[2:7])
|
|
201
|
+
command = " ".join(parts[7:])
|
|
202
|
+
|
|
203
|
+
if not any(
|
|
204
|
+
kw in command.lower() for kw in ("mcp", "server", "slm")
|
|
205
|
+
):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
start_epoch = time.mktime(
|
|
210
|
+
time.strptime(lstart_str, "%a %b %d %H:%M:%S %Y")
|
|
211
|
+
)
|
|
212
|
+
except ValueError:
|
|
213
|
+
try:
|
|
214
|
+
start_epoch = time.mktime(
|
|
215
|
+
time.strptime(lstart_str, "%c")
|
|
216
|
+
)
|
|
217
|
+
except ValueError:
|
|
218
|
+
logger.debug(
|
|
219
|
+
"Cannot parse lstart '%s', skipping PID %d",
|
|
220
|
+
lstart_str, pid,
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
age_hours = (time.time() - start_epoch) / 3600.0
|
|
225
|
+
is_orphan, parent_name = _check_parent(ppid)
|
|
226
|
+
|
|
227
|
+
results.append(SlmProcessInfo(
|
|
228
|
+
pid=pid,
|
|
229
|
+
ppid=ppid,
|
|
230
|
+
start_time=start_epoch,
|
|
231
|
+
command=command,
|
|
232
|
+
is_orphan=is_orphan,
|
|
233
|
+
parent_name=parent_name,
|
|
234
|
+
age_hours=age_hours,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
except (ValueError, IndexError) as exc:
|
|
238
|
+
logger.debug("Cannot parse ps line '%s': %s", line, exc)
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
results.sort(key=lambda p: p.pid)
|
|
242
|
+
return results
|
|
243
|
+
|
|
244
|
+
def find_orphans(config: ReaperConfig) -> list[SlmProcessInfo]:
|
|
245
|
+
"""Filter SLM processes to only confirmed orphans safe to kill.
|
|
246
|
+
|
|
247
|
+
Safety invariants:
|
|
248
|
+
- Parent PID is confirmed dead or reparented to PID 1
|
|
249
|
+
- Process has been running longer than the age threshold
|
|
250
|
+
- Process is not self and not own parent
|
|
251
|
+
"""
|
|
252
|
+
all_procs = find_slm_processes()
|
|
253
|
+
orphans: list[SlmProcessInfo] = []
|
|
254
|
+
|
|
255
|
+
for p in all_procs:
|
|
256
|
+
if not p.is_orphan:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if p.age_hours < config.orphan_age_threshold_hours:
|
|
260
|
+
logger.warning(
|
|
261
|
+
"Young orphan PID %d (age=%.1fh < threshold=%.1fh), "
|
|
262
|
+
"skipping",
|
|
263
|
+
p.pid, p.age_hours,
|
|
264
|
+
config.orphan_age_threshold_hours,
|
|
265
|
+
)
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Triple safety: HR-09
|
|
269
|
+
if p.pid == os.getpid():
|
|
270
|
+
continue
|
|
271
|
+
if p.pid == os.getppid():
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
orphans.append(p)
|
|
275
|
+
|
|
276
|
+
return orphans
|
|
277
|
+
|
|
278
|
+
def kill_orphan(
|
|
279
|
+
pid: int,
|
|
280
|
+
graceful_timeout_seconds: float = 5.0,
|
|
281
|
+
) -> dict[str, object]:
|
|
282
|
+
"""Kill a single orphaned process safely.
|
|
283
|
+
|
|
284
|
+
Triple safety check (HR-02):
|
|
285
|
+
1. Refuse PID <= 1 (init/launchd)
|
|
286
|
+
2. Refuse self-kill
|
|
287
|
+
3. Refuse parent-kill
|
|
288
|
+
|
|
289
|
+
Two-phase kill (HR-04):
|
|
290
|
+
1. SIGTERM (graceful)
|
|
291
|
+
2. SIGKILL (forced, after timeout)
|
|
292
|
+
"""
|
|
293
|
+
# --- Safety checks (HR-02) ---
|
|
294
|
+
if pid <= 1:
|
|
295
|
+
return {
|
|
296
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
297
|
+
"error": (
|
|
298
|
+
f"Safety check failed: refusing to kill PID {pid} (<= 1)"
|
|
299
|
+
),
|
|
300
|
+
}
|
|
301
|
+
if pid == os.getpid():
|
|
302
|
+
return {
|
|
303
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
304
|
+
"error": "Safety check failed: refusing to kill self",
|
|
305
|
+
}
|
|
306
|
+
if pid == os.getppid():
|
|
307
|
+
return {
|
|
308
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
309
|
+
"error": "Safety check failed: refusing to kill own parent",
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
# --- Verify target still exists ---
|
|
313
|
+
try:
|
|
314
|
+
os.kill(pid, 0)
|
|
315
|
+
except ProcessLookupError:
|
|
316
|
+
return {
|
|
317
|
+
"pid": pid, "killed": False,
|
|
318
|
+
"method": "already_dead", "error": None,
|
|
319
|
+
}
|
|
320
|
+
except PermissionError:
|
|
321
|
+
return {
|
|
322
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
323
|
+
"error": f"Permission denied checking PID {pid}",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# --- Phase 1: SIGTERM (graceful) ---
|
|
327
|
+
try:
|
|
328
|
+
os.kill(pid, signal.SIGTERM)
|
|
329
|
+
except ProcessLookupError:
|
|
330
|
+
return {
|
|
331
|
+
"pid": pid, "killed": False,
|
|
332
|
+
"method": "already_dead", "error": None,
|
|
333
|
+
}
|
|
334
|
+
except PermissionError:
|
|
335
|
+
return {
|
|
336
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
337
|
+
"error": f"Permission denied killing PID {pid}",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# --- Wait for graceful death ---
|
|
341
|
+
deadline = time.monotonic() + graceful_timeout_seconds
|
|
342
|
+
while time.monotonic() < deadline:
|
|
343
|
+
time.sleep(0.5)
|
|
344
|
+
try:
|
|
345
|
+
os.kill(pid, 0)
|
|
346
|
+
except ProcessLookupError:
|
|
347
|
+
logger.info(
|
|
348
|
+
"PID %d terminated gracefully (SIGTERM)", pid
|
|
349
|
+
)
|
|
350
|
+
return {
|
|
351
|
+
"pid": pid, "killed": True,
|
|
352
|
+
"method": "sigterm", "error": None,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# --- Phase 2: SIGKILL (forced) ---
|
|
356
|
+
try:
|
|
357
|
+
os.kill(pid, signal.SIGKILL)
|
|
358
|
+
time.sleep(0.5)
|
|
359
|
+
logger.warning(
|
|
360
|
+
"PID %d force-killed (SIGKILL after %ds timeout)",
|
|
361
|
+
pid, graceful_timeout_seconds,
|
|
362
|
+
)
|
|
363
|
+
return {
|
|
364
|
+
"pid": pid, "killed": True,
|
|
365
|
+
"method": "sigkill", "error": None,
|
|
366
|
+
}
|
|
367
|
+
except ProcessLookupError:
|
|
368
|
+
return {
|
|
369
|
+
"pid": pid, "killed": True,
|
|
370
|
+
"method": "sigterm", "error": None,
|
|
371
|
+
}
|
|
372
|
+
except PermissionError:
|
|
373
|
+
return {
|
|
374
|
+
"pid": pid, "killed": False, "method": "refused",
|
|
375
|
+
"error": f"Permission denied force-killing PID {pid}",
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
def reap_stale_on_startup(
|
|
379
|
+
config: ReaperConfig, pid_manager: PidManager
|
|
380
|
+
) -> dict[str, object]:
|
|
381
|
+
"""Run on MCP server startup to clean orphans and register self.
|
|
382
|
+
|
|
383
|
+
Cleans dead PIDs from the PID file, kills confirmed orphans,
|
|
384
|
+
then registers the current process.
|
|
385
|
+
"""
|
|
386
|
+
result: dict[str, object] = {
|
|
387
|
+
"orphans_found": 0,
|
|
388
|
+
"orphans_killed": 0,
|
|
389
|
+
"errors": [],
|
|
390
|
+
"registered_pid": os.getpid(),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if not config.enabled:
|
|
394
|
+
logger.info("Reaper disabled, skipping orphan scan")
|
|
395
|
+
pid_manager.register(os.getpid(), os.getppid())
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
# Phase 1: Clean dead PIDs from PID file
|
|
399
|
+
records = pid_manager.read_all()
|
|
400
|
+
for record in records:
|
|
401
|
+
try:
|
|
402
|
+
os.kill(record.pid, 0)
|
|
403
|
+
except ProcessLookupError:
|
|
404
|
+
pid_manager.remove(record.pid)
|
|
405
|
+
continue
|
|
406
|
+
except PermissionError:
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Process alive -- check if orphaned
|
|
410
|
+
is_orphan, _parent_name = _check_parent(record.ppid)
|
|
411
|
+
if is_orphan:
|
|
412
|
+
started = record.started_at
|
|
413
|
+
# Estimate age from started_at
|
|
414
|
+
try:
|
|
415
|
+
from datetime import datetime, UTC
|
|
416
|
+
start_dt = datetime.fromisoformat(started)
|
|
417
|
+
age_hours = (
|
|
418
|
+
datetime.now(UTC) - start_dt
|
|
419
|
+
).total_seconds() / 3600.0
|
|
420
|
+
except (ValueError, TypeError):
|
|
421
|
+
age_hours = 0.0
|
|
422
|
+
|
|
423
|
+
if age_hours > config.orphan_age_threshold_hours:
|
|
424
|
+
result["orphans_found"] = (
|
|
425
|
+
int(result["orphans_found"]) + 1
|
|
426
|
+
)
|
|
427
|
+
kill_result = kill_orphan(record.pid)
|
|
428
|
+
if kill_result["killed"]:
|
|
429
|
+
result["orphans_killed"] = (
|
|
430
|
+
int(result["orphans_killed"]) + 1
|
|
431
|
+
)
|
|
432
|
+
pid_manager.remove(record.pid)
|
|
433
|
+
elif kill_result["error"]:
|
|
434
|
+
errors = list(result["errors"]) # type: ignore[arg-type]
|
|
435
|
+
errors.append(str(kill_result["error"]))
|
|
436
|
+
result["errors"] = errors
|
|
437
|
+
|
|
438
|
+
# Phase 2: Also scan for untracked orphans
|
|
439
|
+
tracked_pids = {r.pid for r in pid_manager.read_all()}
|
|
440
|
+
try:
|
|
441
|
+
untracked_orphans = [
|
|
442
|
+
o for o in find_orphans(config)
|
|
443
|
+
if o.pid not in tracked_pids
|
|
444
|
+
]
|
|
445
|
+
for orphan in untracked_orphans:
|
|
446
|
+
logger.warning(
|
|
447
|
+
"Found untracked orphan PID %d", orphan.pid
|
|
448
|
+
)
|
|
449
|
+
result["orphans_found"] = (
|
|
450
|
+
int(result["orphans_found"]) + 1
|
|
451
|
+
)
|
|
452
|
+
kill_result = kill_orphan(orphan.pid)
|
|
453
|
+
if kill_result["killed"]:
|
|
454
|
+
result["orphans_killed"] = (
|
|
455
|
+
int(result["orphans_killed"]) + 1
|
|
456
|
+
)
|
|
457
|
+
elif kill_result["error"]:
|
|
458
|
+
errors = list(result["errors"]) # type: ignore[arg-type]
|
|
459
|
+
errors.append(str(kill_result["error"]))
|
|
460
|
+
result["errors"] = errors
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
logger.warning("Untracked orphan scan failed: %s", exc)
|
|
463
|
+
|
|
464
|
+
# Phase 3: Register self
|
|
465
|
+
pid_manager.register(os.getpid(), os.getppid())
|
|
466
|
+
|
|
467
|
+
logger.info(
|
|
468
|
+
"Startup reaper: found=%d, killed=%d, errors=%d",
|
|
469
|
+
result["orphans_found"],
|
|
470
|
+
result["orphans_killed"],
|
|
471
|
+
len(result["errors"]), # type: ignore[arg-type]
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
def cleanup_all_orphans(
|
|
477
|
+
config: ReaperConfig,
|
|
478
|
+
dry_run: bool = False,
|
|
479
|
+
force: bool = False,
|
|
480
|
+
) -> dict[str, object]:
|
|
481
|
+
"""CLI-facing function for `slm cleanup`.
|
|
482
|
+
|
|
483
|
+
Finds and optionally kills all orphans.
|
|
484
|
+
With --force, kills ALL SLM processes except current.
|
|
485
|
+
With --dry-run, reports but does not kill.
|
|
486
|
+
"""
|
|
487
|
+
result: dict[str, object] = {
|
|
488
|
+
"total_found": 0,
|
|
489
|
+
"orphans_found": 0,
|
|
490
|
+
"killed": 0,
|
|
491
|
+
"skipped": 0,
|
|
492
|
+
"errors": [],
|
|
493
|
+
"processes": [],
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
all_procs = find_slm_processes()
|
|
497
|
+
result["total_found"] = len(all_procs)
|
|
498
|
+
processes: list[dict[str, object]] = []
|
|
499
|
+
|
|
500
|
+
if force:
|
|
501
|
+
for p in all_procs:
|
|
502
|
+
if p.pid == os.getpid():
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
proc_info: dict[str, object] = {
|
|
506
|
+
"pid": p.pid, "ppid": p.ppid,
|
|
507
|
+
"age_hours": p.age_hours,
|
|
508
|
+
"parent_name": p.parent_name,
|
|
509
|
+
"command": p.command,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if dry_run:
|
|
513
|
+
proc_info["status"] = "would_kill"
|
|
514
|
+
processes.append(proc_info)
|
|
515
|
+
else:
|
|
516
|
+
kill_result = kill_orphan(p.pid)
|
|
517
|
+
if kill_result["killed"]:
|
|
518
|
+
result["killed"] = int(result["killed"]) + 1
|
|
519
|
+
proc_info["status"] = "killed"
|
|
520
|
+
else:
|
|
521
|
+
proc_info["status"] = "error"
|
|
522
|
+
proc_info["error"] = kill_result["error"]
|
|
523
|
+
if kill_result["error"]:
|
|
524
|
+
errors = list(result["errors"]) # type: ignore[arg-type]
|
|
525
|
+
errors.append(str(kill_result["error"]))
|
|
526
|
+
result["errors"] = errors
|
|
527
|
+
processes.append(proc_info)
|
|
528
|
+
|
|
529
|
+
else:
|
|
530
|
+
orphans = find_orphans(config)
|
|
531
|
+
orphan_pids = {o.pid for o in orphans}
|
|
532
|
+
result["orphans_found"] = len(orphans)
|
|
533
|
+
|
|
534
|
+
for orphan in orphans:
|
|
535
|
+
proc_info = {
|
|
536
|
+
"pid": orphan.pid, "ppid": orphan.ppid,
|
|
537
|
+
"age_hours": orphan.age_hours,
|
|
538
|
+
"parent_name": orphan.parent_name,
|
|
539
|
+
"command": orphan.command,
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if dry_run:
|
|
543
|
+
proc_info["status"] = "would_kill"
|
|
544
|
+
processes.append(proc_info)
|
|
545
|
+
else:
|
|
546
|
+
kill_result = kill_orphan(orphan.pid)
|
|
547
|
+
if kill_result["killed"]:
|
|
548
|
+
result["killed"] = int(result["killed"]) + 1
|
|
549
|
+
proc_info["status"] = "killed"
|
|
550
|
+
else:
|
|
551
|
+
proc_info["status"] = "error"
|
|
552
|
+
proc_info["error"] = kill_result["error"]
|
|
553
|
+
if kill_result["error"]:
|
|
554
|
+
errors = list(result["errors"]) # type: ignore[arg-type]
|
|
555
|
+
errors.append(str(kill_result["error"]))
|
|
556
|
+
result["errors"] = errors
|
|
557
|
+
processes.append(proc_info)
|
|
558
|
+
|
|
559
|
+
# Report non-orphan processes as skipped
|
|
560
|
+
for p in all_procs:
|
|
561
|
+
if p.pid not in orphan_pids:
|
|
562
|
+
processes.append({
|
|
563
|
+
"pid": p.pid, "ppid": p.ppid,
|
|
564
|
+
"age_hours": p.age_hours,
|
|
565
|
+
"parent_name": p.parent_name,
|
|
566
|
+
"command": p.command,
|
|
567
|
+
"status": "active",
|
|
568
|
+
})
|
|
569
|
+
result["skipped"] = int(result["skipped"]) + 1
|
|
570
|
+
|
|
571
|
+
result["processes"] = processes
|
|
572
|
+
return result
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""CCQ Worker — background scheduling for Cognitive Consolidation Quantization.
|
|
6
|
+
|
|
7
|
+
Wraps CognitiveConsolidator with scheduling logic:
|
|
8
|
+
- Runs every N stores (store_count_trigger, default 100)
|
|
9
|
+
- Runs on session end
|
|
10
|
+
- Respects enabled flag
|
|
11
|
+
|
|
12
|
+
Integration point: ConsolidationEngine._step7_ccq() calls this worker.
|
|
13
|
+
|
|
14
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
15
|
+
License: MIT
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from superlocalmemory.core.config import CCQConfig
|
|
25
|
+
from superlocalmemory.encoding.cognitive_consolidator import (
|
|
26
|
+
CCQPipelineResult,
|
|
27
|
+
CognitiveConsolidator,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CCQWorker:
|
|
34
|
+
"""Background CCQ scheduling and execution.
|
|
35
|
+
|
|
36
|
+
Wraps CognitiveConsolidator.run_pipeline() with trigger logic.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_consolidator", "_config", "_run_count")
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
consolidator: CognitiveConsolidator,
|
|
44
|
+
config: CCQConfig,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._consolidator = consolidator
|
|
47
|
+
self._config = config
|
|
48
|
+
self._run_count: int = 0
|
|
49
|
+
|
|
50
|
+
def run(self, profile_id: str) -> CCQPipelineResult:
|
|
51
|
+
"""Execute one CCQ pipeline run.
|
|
52
|
+
|
|
53
|
+
Returns empty result if disabled. Otherwise delegates to
|
|
54
|
+
CognitiveConsolidator.run_pipeline().
|
|
55
|
+
"""
|
|
56
|
+
from superlocalmemory.encoding.cognitive_consolidator import (
|
|
57
|
+
CCQPipelineResult,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not self._config.enabled:
|
|
61
|
+
return CCQPipelineResult(
|
|
62
|
+
clusters_processed=0,
|
|
63
|
+
blocks_created=0,
|
|
64
|
+
facts_archived=0,
|
|
65
|
+
total_bytes_before=0,
|
|
66
|
+
total_bytes_after=0,
|
|
67
|
+
compression_ratio=0.0,
|
|
68
|
+
audit_entries=(),
|
|
69
|
+
errors=(),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._run_count += 1
|
|
73
|
+
result = self._consolidator.run_pipeline(profile_id)
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
"CCQ run #%d: clusters=%d, blocks=%d, archived=%d, ratio=%.2f",
|
|
77
|
+
self._run_count,
|
|
78
|
+
result.clusters_processed,
|
|
79
|
+
result.blocks_created,
|
|
80
|
+
result.facts_archived,
|
|
81
|
+
result.compression_ratio,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def should_run(self, store_count: int, is_session_end: bool) -> bool:
|
|
87
|
+
"""Determine if CCQ should run now.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
store_count: Current store count since last trigger.
|
|
91
|
+
is_session_end: Whether the current session is ending.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if CCQ should execute.
|
|
95
|
+
"""
|
|
96
|
+
if not self._config.enabled:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
if is_session_end and self._config.run_on_session_end:
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
store_count > 0
|
|
104
|
+
and store_count % self._config.store_count_trigger == 0
|
|
105
|
+
):
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def get_stats(self) -> dict[str, Any]:
|
|
111
|
+
"""Return worker statistics."""
|
|
112
|
+
return {
|
|
113
|
+
"total_runs": self._run_count,
|
|
114
|
+
"enabled": self._config.enabled,
|
|
115
|
+
}
|