superlocalmemory 3.2.3 → 3.3.1

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 (51) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +106 -71
  3. package/package.json +1 -2
  4. package/pyproject.toml +16 -1
  5. package/src/superlocalmemory/cli/commands.py +419 -15
  6. package/src/superlocalmemory/cli/main.py +44 -0
  7. package/src/superlocalmemory/core/config.py +276 -4
  8. package/src/superlocalmemory/core/consolidation_engine.py +37 -0
  9. package/src/superlocalmemory/core/engine.py +21 -0
  10. package/src/superlocalmemory/core/engine_wiring.py +58 -8
  11. package/src/superlocalmemory/dynamics/activation_guided_quantization.py +374 -0
  12. package/src/superlocalmemory/dynamics/eap_scheduler.py +276 -0
  13. package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +171 -0
  14. package/src/superlocalmemory/encoding/cognitive_consolidator.py +804 -0
  15. package/src/superlocalmemory/hooks/auto_invoker.py +46 -8
  16. package/src/superlocalmemory/hooks/auto_parameterize.py +147 -0
  17. package/src/superlocalmemory/infra/heartbeat_monitor.py +140 -0
  18. package/src/superlocalmemory/infra/pid_manager.py +193 -0
  19. package/src/superlocalmemory/infra/process_reaper.py +572 -0
  20. package/src/superlocalmemory/learning/consolidation_quantization_worker.py +115 -0
  21. package/src/superlocalmemory/learning/forgetting_scheduler.py +263 -0
  22. package/src/superlocalmemory/learning/quantization_scheduler.py +320 -0
  23. package/src/superlocalmemory/math/ebbinghaus.py +309 -0
  24. package/src/superlocalmemory/math/fisher_quantized.py +251 -0
  25. package/src/superlocalmemory/math/hopfield.py +279 -0
  26. package/src/superlocalmemory/math/polar_quant.py +379 -0
  27. package/src/superlocalmemory/math/qjl.py +115 -0
  28. package/src/superlocalmemory/mcp/server.py +2 -0
  29. package/src/superlocalmemory/mcp/tools_v3.py +10 -0
  30. package/src/superlocalmemory/mcp/tools_v33.py +351 -0
  31. package/src/superlocalmemory/parameterization/__init__.py +47 -0
  32. package/src/superlocalmemory/parameterization/pattern_extractor.py +534 -0
  33. package/src/superlocalmemory/parameterization/pii_filter.py +106 -0
  34. package/src/superlocalmemory/parameterization/prompt_injector.py +216 -0
  35. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +275 -0
  36. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +425 -0
  37. package/src/superlocalmemory/retrieval/engine.py +21 -3
  38. package/src/superlocalmemory/retrieval/forgetting_filter.py +145 -0
  39. package/src/superlocalmemory/retrieval/hopfield_channel.py +335 -0
  40. package/src/superlocalmemory/retrieval/quantization_aware_search.py +133 -0
  41. package/src/superlocalmemory/retrieval/strategy.py +16 -6
  42. package/src/superlocalmemory/server/routes/agents.py +68 -8
  43. package/src/superlocalmemory/server/routes/learning.py +18 -1
  44. package/src/superlocalmemory/server/routes/lifecycle.py +36 -17
  45. package/src/superlocalmemory/server/routes/v3_api.py +503 -1
  46. package/src/superlocalmemory/storage/database.py +206 -0
  47. package/src/superlocalmemory/storage/embedding_migrator.py +178 -0
  48. package/src/superlocalmemory/storage/migration_v33.py +140 -0
  49. package/src/superlocalmemory/storage/quantized_store.py +261 -0
  50. package/src/superlocalmemory/storage/schema_v32.py +137 -0
  51. 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 not results:
83
- return ""
93
+ memory_context = self.format_for_injection(results) if results else ""
84
94
 
85
- return self.format_for_injection(results)
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
- if (
436
- fact_data.get("lifecycle") in ("archived",)
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