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.
Files changed (53) 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 +309 -0
  6. package/src/superlocalmemory/cli/main.py +44 -0
  7. package/src/superlocalmemory/core/config.py +282 -11
  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/spreading_activation.py +1 -1
  42. package/src/superlocalmemory/retrieval/strategy.py +16 -6
  43. package/src/superlocalmemory/retrieval/vector_store.py +1 -1
  44. package/src/superlocalmemory/server/routes/agents.py +68 -8
  45. package/src/superlocalmemory/server/routes/learning.py +18 -1
  46. package/src/superlocalmemory/server/routes/lifecycle.py +36 -17
  47. package/src/superlocalmemory/server/routes/v3_api.py +503 -1
  48. package/src/superlocalmemory/storage/database.py +206 -0
  49. package/src/superlocalmemory/storage/embedding_migrator.py +178 -0
  50. package/src/superlocalmemory/storage/migration_v33.py +140 -0
  51. package/src/superlocalmemory/storage/quantized_store.py +261 -0
  52. package/src/superlocalmemory/storage/schema_v32.py +137 -0
  53. 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
+ }