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
|
@@ -42,8 +42,14 @@ class AutoInvoker:
|
|
|
42
42
|
- trust_scorer: TrustScorer (for per-fact trust)
|
|
43
43
|
- embedder: EmbeddingService (for query encoding)
|
|
44
44
|
- config: AutoInvokeConfig
|
|
45
|
+
- prompt_injector: PromptInjector or None (V3.3 soft prompt injection)
|
|
45
46
|
"""
|
|
46
47
|
|
|
48
|
+
# Lifecycle zones that are excluded from auto-invoke results.
|
|
49
|
+
# "archived" was always skipped; "forgotten" added in V3.3 for
|
|
50
|
+
# forgetting-aware auto-invoke (Phase A integration).
|
|
51
|
+
_EXCLUDED_ZONES: frozenset[str] = frozenset({"archived", "forgotten"})
|
|
52
|
+
|
|
47
53
|
def __init__(
|
|
48
54
|
self,
|
|
49
55
|
db, # DatabaseManager
|
|
@@ -51,12 +57,14 @@ class AutoInvoker:
|
|
|
51
57
|
trust_scorer=None, # TrustScorer (existing)
|
|
52
58
|
embedder=None, # EmbeddingService for query encoding
|
|
53
59
|
config=None, # AutoInvokeConfig
|
|
60
|
+
prompt_injector=None, # PromptInjector (V3.3 soft prompt injection)
|
|
54
61
|
) -> None:
|
|
55
62
|
self._db = db
|
|
56
63
|
self._vector_store = vector_store
|
|
57
64
|
self._trust_scorer = trust_scorer
|
|
58
65
|
self._embedder = embedder
|
|
59
66
|
self._config = config or AutoInvokeConfig()
|
|
67
|
+
self._prompt_injector = prompt_injector
|
|
60
68
|
|
|
61
69
|
# ------------------------------------------------------------------
|
|
62
70
|
# Public API: AutoRecall-compatible interface (Rule 16 / AI-04)
|
|
@@ -68,6 +76,9 @@ class AutoInvoker:
|
|
|
68
76
|
EXACT same signature as AutoRecall.get_session_context().
|
|
69
77
|
Returns a formatted string of relevant memories suitable
|
|
70
78
|
for injection into an AI's system prompt.
|
|
79
|
+
|
|
80
|
+
V3.3: If a PromptInjector is wired, soft prompts are prepended
|
|
81
|
+
to the memory context with priority (soft prompts first).
|
|
71
82
|
"""
|
|
72
83
|
if not self._config.enabled:
|
|
73
84
|
return ""
|
|
@@ -79,10 +90,16 @@ class AutoInvoker:
|
|
|
79
90
|
limit=self._config.max_memories_injected,
|
|
80
91
|
)
|
|
81
92
|
|
|
82
|
-
if
|
|
83
|
-
return ""
|
|
93
|
+
memory_context = self.format_for_injection(results) if results else ""
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
# V3.3: Inject soft prompts (priority over memory context)
|
|
96
|
+
soft_prompt_text = self._get_soft_prompt_text()
|
|
97
|
+
if soft_prompt_text and self._prompt_injector is not None:
|
|
98
|
+
return self._prompt_injector.inject_into_context(
|
|
99
|
+
soft_prompt_text, memory_context,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return soft_prompt_text + ("\n\n" + memory_context if memory_context else "") if soft_prompt_text else memory_context
|
|
86
103
|
except Exception as exc:
|
|
87
104
|
logger.debug("Auto-invoke failed: %s", exc)
|
|
88
105
|
return ""
|
|
@@ -210,10 +227,12 @@ class AutoInvoker:
|
|
|
210
227
|
logger.debug("VectorStore search failed: %s", exc)
|
|
211
228
|
|
|
212
229
|
# Fallback: text search for candidates (Mode A degradation)
|
|
230
|
+
# V3.3: Exclude archived/forgotten facts from candidates
|
|
213
231
|
try:
|
|
214
232
|
rows = self._db.execute(
|
|
215
233
|
"SELECT fact_id FROM atomic_facts "
|
|
216
234
|
"WHERE profile_id = ? AND content LIKE ? "
|
|
235
|
+
"AND COALESCE(lifecycle, 'active') NOT IN ('archived', 'forgotten') "
|
|
217
236
|
"ORDER BY access_count DESC LIMIT ?",
|
|
218
237
|
(profile_id, f"%{query[:50]}%", top_k),
|
|
219
238
|
)
|
|
@@ -431,11 +450,9 @@ class AutoInvoker:
|
|
|
431
450
|
return None
|
|
432
451
|
fact_data = dict(fact_rows[0])
|
|
433
452
|
|
|
434
|
-
# Skip archived facts unless config allows
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
and not self._config.include_archived
|
|
438
|
-
):
|
|
453
|
+
# Skip archived/forgotten facts unless config allows (V3.3: forgetting-aware)
|
|
454
|
+
lifecycle = fact_data.get("lifecycle", "")
|
|
455
|
+
if lifecycle in self._EXCLUDED_ZONES and not self._config.include_archived:
|
|
439
456
|
return None
|
|
440
457
|
|
|
441
458
|
# Get contextual description
|
|
@@ -482,3 +499,24 @@ class AutoInvoker:
|
|
|
482
499
|
f"(FOK >= {self._config.fok_threshold})_"
|
|
483
500
|
)
|
|
484
501
|
return "\n".join(lines)
|
|
502
|
+
|
|
503
|
+
# ------------------------------------------------------------------
|
|
504
|
+
# V3.3: Soft prompt injection
|
|
505
|
+
# ------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def _get_soft_prompt_text(self) -> str:
|
|
508
|
+
"""Retrieve soft prompt text via PromptInjector (V3.3).
|
|
509
|
+
|
|
510
|
+
Returns assembled soft prompt text, or "" if injector is not
|
|
511
|
+
wired or no active soft prompts exist. Errors are logged and
|
|
512
|
+
swallowed -- soft prompt failure MUST NOT block auto-invoke.
|
|
513
|
+
"""
|
|
514
|
+
if self._prompt_injector is None:
|
|
515
|
+
return ""
|
|
516
|
+
try:
|
|
517
|
+
return self._prompt_injector.get_injection_context(
|
|
518
|
+
self._config.profile_id,
|
|
519
|
+
)
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
logger.debug("Soft prompt injection failed: %s", exc)
|
|
522
|
+
return ""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3.3
|
|
4
|
+
|
|
5
|
+
"""AutoParameterizeHook — Trigger parameterization on consolidation events.
|
|
6
|
+
|
|
7
|
+
Runs the full pipeline: extract -> generate -> store -> lifecycle review.
|
|
8
|
+
Rate-limited to config.refresh_interval_hours. Tracks session effectiveness.
|
|
9
|
+
|
|
10
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime, timezone, timedelta
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from superlocalmemory.parameterization.pattern_extractor import PatternExtractor
|
|
21
|
+
from superlocalmemory.parameterization.soft_prompt_generator import SoftPromptGenerator
|
|
22
|
+
from superlocalmemory.parameterization.prompt_injector import PromptInjector
|
|
23
|
+
from superlocalmemory.parameterization.prompt_lifecycle import PromptLifecycleManager
|
|
24
|
+
from superlocalmemory.core.config import ParameterizationConfig
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AutoParameterizeHook:
|
|
30
|
+
"""Hook that triggers soft prompt parameterization on consolidation.
|
|
31
|
+
|
|
32
|
+
Called by the consolidation engine after a consolidation cycle completes.
|
|
33
|
+
Rate-limited to prevent excessive recomputation.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
extractor: PatternExtractor,
|
|
39
|
+
generator: SoftPromptGenerator,
|
|
40
|
+
injector: PromptInjector,
|
|
41
|
+
lifecycle: PromptLifecycleManager,
|
|
42
|
+
config: ParameterizationConfig,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._extractor = extractor
|
|
45
|
+
self._generator = generator
|
|
46
|
+
self._injector = injector
|
|
47
|
+
self._lifecycle = lifecycle
|
|
48
|
+
self._config = config
|
|
49
|
+
self._last_run: str | None = None
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Event handlers
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def on_consolidation_complete(self, profile_id: str) -> dict:
|
|
56
|
+
"""Triggered after consolidation engine finishes.
|
|
57
|
+
|
|
58
|
+
Runs: extract -> generate -> store -> lifecycle review.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
profile_id: Profile that was consolidated.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Status dict with pipeline results.
|
|
65
|
+
"""
|
|
66
|
+
if not self._config.enabled:
|
|
67
|
+
return {"status": "disabled"}
|
|
68
|
+
|
|
69
|
+
# Rate limit check
|
|
70
|
+
if self._last_run is not None:
|
|
71
|
+
try:
|
|
72
|
+
last = datetime.fromisoformat(self._last_run)
|
|
73
|
+
if last.tzinfo is None:
|
|
74
|
+
last = last.replace(tzinfo=timezone.utc)
|
|
75
|
+
now = datetime.now(timezone.utc)
|
|
76
|
+
interval = timedelta(hours=self._config.refresh_interval_hours)
|
|
77
|
+
if now - last < interval:
|
|
78
|
+
return {"status": "rate_limited"}
|
|
79
|
+
except (ValueError, TypeError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# Step 1: Extract patterns
|
|
83
|
+
patterns = self._extractor.extract(profile_id)
|
|
84
|
+
if not patterns:
|
|
85
|
+
return {"status": "no_patterns", "count": 0}
|
|
86
|
+
|
|
87
|
+
# Step 2: Generate prompts
|
|
88
|
+
prompts = self._generator.generate(patterns, profile_id)
|
|
89
|
+
if not prompts:
|
|
90
|
+
return {"status": "no_prompts", "patterns": len(patterns)}
|
|
91
|
+
|
|
92
|
+
# Step 3: Store prompts
|
|
93
|
+
stored = self._injector.store_prompts(prompts)
|
|
94
|
+
|
|
95
|
+
# Step 4: Run lifecycle review
|
|
96
|
+
lifecycle_stats = self._lifecycle.run_lifecycle_review(profile_id)
|
|
97
|
+
|
|
98
|
+
# Update last run timestamp
|
|
99
|
+
self._last_run = datetime.now(timezone.utc).isoformat()
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"status": "success",
|
|
103
|
+
"patterns": len(patterns),
|
|
104
|
+
"prompts": stored,
|
|
105
|
+
"lifecycle": lifecycle_stats,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def on_session_end(
|
|
109
|
+
self, profile_id: str, session_outcome: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Triggered at session end for effectiveness tracking.
|
|
112
|
+
|
|
113
|
+
Maps session outcome to feedback signals and updates
|
|
114
|
+
effectiveness for all active prompt categories.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
profile_id: Profile for the ending session.
|
|
118
|
+
session_outcome: "success" | "failure" | "partial"
|
|
119
|
+
"""
|
|
120
|
+
if not self._config.effectiveness_tracking:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Map outcome to signals
|
|
124
|
+
signal_map: dict[str, dict[str, float]] = {
|
|
125
|
+
"success": {"session_success": 1.0},
|
|
126
|
+
"failure": {"session_failure": 1.0},
|
|
127
|
+
"partial": {"session_success": 0.5},
|
|
128
|
+
}
|
|
129
|
+
signals = signal_map.get(session_outcome, {})
|
|
130
|
+
if not signals:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Update effectiveness for all active categories
|
|
134
|
+
for category in [
|
|
135
|
+
"identity", "tech_preference", "communication_style",
|
|
136
|
+
"workflow_pattern", "project_context", "decision_history",
|
|
137
|
+
"avoidance",
|
|
138
|
+
]:
|
|
139
|
+
try:
|
|
140
|
+
self._lifecycle.update_effectiveness(
|
|
141
|
+
profile_id, category, signals,
|
|
142
|
+
)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"Failed to update effectiveness for %s: %s",
|
|
146
|
+
category, exc,
|
|
147
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
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 -- Parent Heartbeat Monitor.
|
|
6
|
+
|
|
7
|
+
Daemon thread that checks if the parent process (IDE/Claude session) is
|
|
8
|
+
still alive. If the parent dies, initiates graceful shutdown to prevent
|
|
9
|
+
zombie SLM processes consuming 1.5-2 GB each.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HeartbeatMonitor:
|
|
23
|
+
"""Monitor parent process liveness via a daemon thread.
|
|
24
|
+
|
|
25
|
+
When the parent PID is detected as dead, calls the provided
|
|
26
|
+
shutdown_callback. The monitoring thread is a daemon thread
|
|
27
|
+
(auto-dies with the main process per HR-06).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
parent_pid: int,
|
|
33
|
+
interval_seconds: int,
|
|
34
|
+
shutdown_callback: Callable[[], None],
|
|
35
|
+
) -> None:
|
|
36
|
+
self._parent_pid = parent_pid
|
|
37
|
+
self._interval = interval_seconds
|
|
38
|
+
self._shutdown_callback = shutdown_callback
|
|
39
|
+
self._thread: Optional[threading.Thread] = None
|
|
40
|
+
self._stop_event = threading.Event()
|
|
41
|
+
self._running = False
|
|
42
|
+
|
|
43
|
+
# -- Lifecycle ----------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def start(self) -> None:
|
|
46
|
+
"""Start the heartbeat monitoring daemon thread."""
|
|
47
|
+
if self._running:
|
|
48
|
+
logger.warning("Heartbeat monitor already running")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# HR-02 equivalent: refuse to monitor PID 0 or 1
|
|
52
|
+
if self._parent_pid <= 1:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"Refusing to monitor PID %d (<= 1), heartbeat not started",
|
|
55
|
+
self._parent_pid,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
self._stop_event.clear()
|
|
60
|
+
self._thread = threading.Thread(
|
|
61
|
+
target=self._monitor_loop,
|
|
62
|
+
name="slm-heartbeat",
|
|
63
|
+
daemon=True,
|
|
64
|
+
)
|
|
65
|
+
self._thread.start()
|
|
66
|
+
self._running = True
|
|
67
|
+
logger.info(
|
|
68
|
+
"Heartbeat monitor started: watching parent PID %d every %ds",
|
|
69
|
+
self._parent_pid,
|
|
70
|
+
self._interval,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def stop(self) -> None:
|
|
74
|
+
"""Stop the heartbeat monitor gracefully."""
|
|
75
|
+
if not self._running:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self._stop_event.set()
|
|
79
|
+
if self._thread is not None and self._thread.is_alive():
|
|
80
|
+
self._thread.join(timeout=self._interval + 2)
|
|
81
|
+
|
|
82
|
+
self._running = False
|
|
83
|
+
logger.info("Heartbeat monitor stopped")
|
|
84
|
+
|
|
85
|
+
# -- Properties ---------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_running(self) -> bool:
|
|
89
|
+
"""Whether the monitor thread is active."""
|
|
90
|
+
return self._running
|
|
91
|
+
|
|
92
|
+
# -- Internal -----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _monitor_loop(self) -> None:
|
|
95
|
+
"""Heartbeat loop running in daemon thread.
|
|
96
|
+
|
|
97
|
+
Uses threading.Event.wait(timeout) instead of time.sleep()
|
|
98
|
+
because Event.wait() is immediately interruptible by stop(),
|
|
99
|
+
while sleep() blocks for the full duration.
|
|
100
|
+
"""
|
|
101
|
+
logger.debug(
|
|
102
|
+
"Heartbeat loop started for parent PID %d", self._parent_pid
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
while not self._stop_event.is_set():
|
|
106
|
+
stopped = self._stop_event.wait(timeout=self._interval)
|
|
107
|
+
if stopped:
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
if not self._is_parent_alive():
|
|
111
|
+
logger.warning(
|
|
112
|
+
"Parent PID %d died, initiating graceful shutdown",
|
|
113
|
+
self._parent_pid,
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
self._shutdown_callback()
|
|
117
|
+
except Exception:
|
|
118
|
+
logger.exception("Shutdown callback failed")
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
logger.debug("Heartbeat loop exited")
|
|
122
|
+
|
|
123
|
+
def _is_parent_alive(self) -> bool:
|
|
124
|
+
"""Check if parent PID is still a running process.
|
|
125
|
+
|
|
126
|
+
Conservative: returns True on PermissionError (parent exists
|
|
127
|
+
but is owned by another user).
|
|
128
|
+
"""
|
|
129
|
+
if self._parent_pid <= 1:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
os.kill(self._parent_pid, 0)
|
|
134
|
+
return True
|
|
135
|
+
except ProcessLookupError:
|
|
136
|
+
return False
|
|
137
|
+
except PermissionError:
|
|
138
|
+
return True # Alive, different user -- conservative
|
|
139
|
+
except OSError:
|
|
140
|
+
return False
|
|
@@ -0,0 +1,193 @@
|
|
|
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 -- PID File Manager.
|
|
6
|
+
|
|
7
|
+
Atomic JSON-based PID tracking. Records which SLM processes are running
|
|
8
|
+
and their parent PIDs for orphan detection.
|
|
9
|
+
|
|
10
|
+
File format: {"pids": [{"pid": 1234, "ppid": 5678, "started_at": "2026-03-30T14:25:01"}]}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# PidRecord -- frozen dataclass for a single process entry
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PidRecord:
|
|
32
|
+
"""A single PID record stored in the PID file."""
|
|
33
|
+
|
|
34
|
+
pid: int
|
|
35
|
+
ppid: int
|
|
36
|
+
started_at: str
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
"""Serialize to a JSON-compatible dictionary."""
|
|
40
|
+
return {
|
|
41
|
+
"pid": self.pid,
|
|
42
|
+
"ppid": self.ppid,
|
|
43
|
+
"started_at": self.started_at,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, d: dict) -> PidRecord:
|
|
48
|
+
"""Deserialize from a dictionary (as read from JSON)."""
|
|
49
|
+
return PidRecord(
|
|
50
|
+
pid=int(d["pid"]),
|
|
51
|
+
ppid=int(d["ppid"]),
|
|
52
|
+
started_at=str(d["started_at"]),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# PidManager -- atomic JSON PID file management
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class PidManager:
|
|
61
|
+
"""Manage a JSON file tracking running SLM process PIDs.
|
|
62
|
+
|
|
63
|
+
Uses atomic temp-file-then-rename writes so the file is never
|
|
64
|
+
corrupted by a crash mid-write.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, pid_file_path: Path) -> None:
|
|
68
|
+
self._path = pid_file_path
|
|
69
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# -- Read ---------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def read_all(self) -> list[PidRecord]:
|
|
74
|
+
"""Read all PID records from the PID file.
|
|
75
|
+
|
|
76
|
+
Returns empty list if the file is missing, corrupt, or malformed.
|
|
77
|
+
Corrupt files are deleted so the next write starts clean.
|
|
78
|
+
"""
|
|
79
|
+
if not self._path.exists():
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
84
|
+
data = json.loads(raw)
|
|
85
|
+
|
|
86
|
+
if not isinstance(data, dict) or "pids" not in data:
|
|
87
|
+
logger.warning("Malformed PID file %s, resetting", self._path)
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
PidRecord.from_dict(entry)
|
|
92
|
+
for entry in data["pids"]
|
|
93
|
+
if isinstance(entry, dict)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
logger.warning("Corrupt PID file %s, deleting", self._path)
|
|
98
|
+
try:
|
|
99
|
+
self._path.unlink(missing_ok=True)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
logger.warning("Cannot read PID file %s: %s", self._path, exc)
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
# -- Write (atomic) -----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _write_all(self, records: list[PidRecord]) -> None:
|
|
111
|
+
"""Atomically write all PID records to the PID file.
|
|
112
|
+
|
|
113
|
+
Uses temp-file-then-rename pattern:
|
|
114
|
+
- Write to a temp file in the same directory
|
|
115
|
+
- os.replace() is atomic on POSIX (single inode swap)
|
|
116
|
+
- On crash: either old file or new file, never corruption
|
|
117
|
+
"""
|
|
118
|
+
data = {"pids": [r.to_dict() for r in records]}
|
|
119
|
+
content = json.dumps(data, indent=2)
|
|
120
|
+
|
|
121
|
+
tmp_path: str | None = None
|
|
122
|
+
try:
|
|
123
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
124
|
+
dir=str(self._path.parent),
|
|
125
|
+
suffix=".tmp",
|
|
126
|
+
prefix="slm-pids-",
|
|
127
|
+
)
|
|
128
|
+
os.write(fd, content.encode("utf-8"))
|
|
129
|
+
os.fsync(fd)
|
|
130
|
+
os.close(fd)
|
|
131
|
+
os.replace(tmp_path, str(self._path))
|
|
132
|
+
tmp_path = None # Consumed by replace, no cleanup needed
|
|
133
|
+
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
logger.warning("Cannot write PID file %s: %s", self._path, exc)
|
|
136
|
+
|
|
137
|
+
finally:
|
|
138
|
+
if tmp_path is not None:
|
|
139
|
+
try:
|
|
140
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# -- Register / Remove --------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def register(self, pid: int, ppid: int) -> None:
|
|
147
|
+
"""Add current process to PID file (replaces stale entry for same PID)."""
|
|
148
|
+
records = self.read_all()
|
|
149
|
+
records = [r for r in records if r.pid != pid]
|
|
150
|
+
|
|
151
|
+
new_record = PidRecord(
|
|
152
|
+
pid=pid,
|
|
153
|
+
ppid=ppid,
|
|
154
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
155
|
+
)
|
|
156
|
+
records.append(new_record)
|
|
157
|
+
self._write_all(records)
|
|
158
|
+
|
|
159
|
+
def remove(self, pid: int) -> bool:
|
|
160
|
+
"""Remove a PID from the file. Returns True if found and removed."""
|
|
161
|
+
records = self.read_all()
|
|
162
|
+
new_records = [r for r in records if r.pid != pid]
|
|
163
|
+
|
|
164
|
+
if len(new_records) == len(records):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
self._write_all(new_records)
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
# -- Housekeeping -------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def cleanup_dead(self) -> int:
|
|
173
|
+
"""Remove all PIDs that are no longer running.
|
|
174
|
+
|
|
175
|
+
Returns number of dead PIDs removed.
|
|
176
|
+
"""
|
|
177
|
+
records = self.read_all()
|
|
178
|
+
alive: list[PidRecord] = []
|
|
179
|
+
removed = 0
|
|
180
|
+
|
|
181
|
+
for r in records:
|
|
182
|
+
try:
|
|
183
|
+
os.kill(r.pid, 0)
|
|
184
|
+
alive.append(r)
|
|
185
|
+
except ProcessLookupError:
|
|
186
|
+
removed += 1
|
|
187
|
+
except PermissionError:
|
|
188
|
+
alive.append(r) # Exists but owned by another user
|
|
189
|
+
|
|
190
|
+
if removed > 0:
|
|
191
|
+
self._write_all(alive)
|
|
192
|
+
|
|
193
|
+
return removed
|