superlocalmemory 3.4.1 → 3.4.3

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 (37) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +11 -2
  3. package/scripts/postinstall.js +26 -7
  4. package/src/superlocalmemory/cli/commands.py +42 -60
  5. package/src/superlocalmemory/cli/daemon.py +107 -47
  6. package/src/superlocalmemory/cli/main.py +10 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +137 -9
  8. package/src/superlocalmemory/core/config.py +28 -0
  9. package/src/superlocalmemory/core/consolidation_engine.py +38 -1
  10. package/src/superlocalmemory/core/engine.py +9 -0
  11. package/src/superlocalmemory/core/health_monitor.py +313 -0
  12. package/src/superlocalmemory/core/reranker_worker.py +19 -5
  13. package/src/superlocalmemory/ingestion/__init__.py +13 -0
  14. package/src/superlocalmemory/ingestion/adapter_manager.py +234 -0
  15. package/src/superlocalmemory/ingestion/base_adapter.py +177 -0
  16. package/src/superlocalmemory/ingestion/calendar_adapter.py +340 -0
  17. package/src/superlocalmemory/ingestion/credentials.py +118 -0
  18. package/src/superlocalmemory/ingestion/gmail_adapter.py +369 -0
  19. package/src/superlocalmemory/ingestion/parsers.py +100 -0
  20. package/src/superlocalmemory/ingestion/transcript_adapter.py +156 -0
  21. package/src/superlocalmemory/learning/consolidation_worker.py +47 -1
  22. package/src/superlocalmemory/learning/entity_compiler.py +377 -0
  23. package/src/superlocalmemory/mesh/__init__.py +12 -0
  24. package/src/superlocalmemory/mesh/broker.py +344 -0
  25. package/src/superlocalmemory/retrieval/entity_channel.py +12 -6
  26. package/src/superlocalmemory/server/api.py +6 -7
  27. package/src/superlocalmemory/server/routes/entity.py +95 -0
  28. package/src/superlocalmemory/server/routes/ingest.py +110 -0
  29. package/src/superlocalmemory/server/routes/mesh.py +186 -0
  30. package/src/superlocalmemory/server/unified_daemon.py +691 -0
  31. package/src/superlocalmemory/storage/schema_v343.py +229 -0
  32. package/src/superlocalmemory.egg-info/PKG-INFO +0 -597
  33. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -287
  34. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  35. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  36. package/src/superlocalmemory.egg-info/requires.txt +0 -47
  37. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,234 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the Elastic License 2.0 - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Adapter lifecycle manager — start, stop, enable, disable ingestion adapters.
6
+
7
+ All adapters run as separate subprocesses managed via PID files.
8
+ Config stored in ~/.superlocalmemory/adapters.json.
9
+
10
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
+ License: Elastic-2.0
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger("superlocalmemory.ingestion.manager")
24
+
25
+ _SLM_HOME = Path.home() / ".superlocalmemory"
26
+ _ADAPTERS_CONFIG = _SLM_HOME / "adapters.json"
27
+ _VALID_ADAPTERS = ("gmail", "calendar", "transcript")
28
+
29
+ # Module paths for each adapter
30
+ _ADAPTER_MODULES = {
31
+ "gmail": "superlocalmemory.ingestion.gmail_adapter",
32
+ "calendar": "superlocalmemory.ingestion.calendar_adapter",
33
+ "transcript": "superlocalmemory.ingestion.transcript_adapter",
34
+ }
35
+
36
+
37
+ def _load_config() -> dict:
38
+ if _ADAPTERS_CONFIG.exists():
39
+ return json.loads(_ADAPTERS_CONFIG.read_text())
40
+ return {name: {"enabled": False} for name in _VALID_ADAPTERS}
41
+
42
+
43
+ def _save_config(config: dict) -> None:
44
+ _ADAPTERS_CONFIG.parent.mkdir(parents=True, exist_ok=True)
45
+ _ADAPTERS_CONFIG.write_text(json.dumps(config, indent=2))
46
+
47
+
48
+ def _pid_file(name: str) -> Path:
49
+ return _SLM_HOME / f"adapter-{name}.pid"
50
+
51
+
52
+ def _is_running(name: str) -> tuple[bool, int | None]:
53
+ """Check if adapter is running. Returns (running, pid)."""
54
+ pf = _pid_file(name)
55
+ if not pf.exists():
56
+ return False, None
57
+ try:
58
+ pid = int(pf.read_text().strip())
59
+ try:
60
+ import psutil
61
+ return psutil.pid_exists(pid), pid
62
+ except ImportError:
63
+ os.kill(pid, 0)
64
+ return True, pid
65
+ except (ValueError, ProcessLookupError, PermissionError):
66
+ pf.unlink(missing_ok=True)
67
+ return False, None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Public API
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def list_adapters() -> list[dict]:
75
+ """List all adapters with their status."""
76
+ config = _load_config()
77
+ result = []
78
+ for name in _VALID_ADAPTERS:
79
+ ac = config.get(name, {})
80
+ running, pid = _is_running(name)
81
+ result.append({
82
+ "name": name,
83
+ "enabled": ac.get("enabled", False),
84
+ "running": running,
85
+ "pid": pid,
86
+ "tier": ac.get("tier", ""),
87
+ "watch_dir": ac.get("watch_dir", ""),
88
+ })
89
+ return result
90
+
91
+
92
+ def enable_adapter(name: str) -> dict:
93
+ """Enable an adapter in config."""
94
+ if name not in _VALID_ADAPTERS:
95
+ return {"ok": False, "error": f"Unknown adapter: {name}. Valid: {_VALID_ADAPTERS}"}
96
+ config = _load_config()
97
+ config.setdefault(name, {})["enabled"] = True
98
+ _save_config(config)
99
+ return {"ok": True, "message": f"{name} adapter enabled. Run `slm adapters start {name}` to start."}
100
+
101
+
102
+ def disable_adapter(name: str) -> dict:
103
+ """Disable an adapter. Stops it if running."""
104
+ if name not in _VALID_ADAPTERS:
105
+ return {"ok": False, "error": f"Unknown adapter: {name}"}
106
+ stop_adapter(name)
107
+ config = _load_config()
108
+ config.setdefault(name, {})["enabled"] = False
109
+ _save_config(config)
110
+ return {"ok": True, "message": f"{name} adapter disabled"}
111
+
112
+
113
+ def start_adapter(name: str) -> dict:
114
+ """Start an adapter subprocess."""
115
+ if name not in _VALID_ADAPTERS:
116
+ return {"ok": False, "error": f"Unknown adapter: {name}"}
117
+
118
+ config = _load_config()
119
+ if not config.get(name, {}).get("enabled"):
120
+ return {"ok": False, "error": f"{name} not enabled. Run `slm adapters enable {name}` first."}
121
+
122
+ running, pid = _is_running(name)
123
+ if running:
124
+ return {"ok": True, "message": f"{name} already running (PID {pid})"}
125
+
126
+ module = _ADAPTER_MODULES.get(name)
127
+ if not module:
128
+ return {"ok": False, "error": f"No module for {name}"}
129
+
130
+ cmd = [sys.executable, "-m", module]
131
+ log_dir = _SLM_HOME / "logs"
132
+ log_dir.mkdir(parents=True, exist_ok=True)
133
+ log_path = log_dir / f"adapter-{name}.log"
134
+
135
+ kwargs: dict = {}
136
+ if sys.platform == "win32":
137
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
138
+ else:
139
+ kwargs["start_new_session"] = True
140
+
141
+ with open(log_path, "a") as lf:
142
+ proc = subprocess.Popen(cmd, stdout=lf, stderr=lf, **kwargs)
143
+
144
+ _pid_file(name).write_text(str(proc.pid))
145
+ return {"ok": True, "message": f"{name} started (PID {proc.pid})", "pid": proc.pid}
146
+
147
+
148
+ def stop_adapter(name: str) -> dict:
149
+ """Stop a running adapter."""
150
+ running, pid = _is_running(name)
151
+ if not running:
152
+ return {"ok": True, "message": f"{name} not running"}
153
+
154
+ try:
155
+ import psutil
156
+ proc = psutil.Process(pid)
157
+ proc.terminate()
158
+ proc.wait(timeout=10)
159
+ except ImportError:
160
+ os.kill(pid, 15) # SIGTERM
161
+ except Exception:
162
+ pass
163
+
164
+ _pid_file(name).unlink(missing_ok=True)
165
+ return {"ok": True, "message": f"{name} stopped"}
166
+
167
+
168
+ def status_adapters() -> list[dict]:
169
+ """Get detailed status of all adapters."""
170
+ return list_adapters()
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # CLI handler (called from commands.py)
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def handle_adapters_cli(args: list[str]) -> None:
178
+ """Handle `slm adapters <action> [name]` commands."""
179
+ if not args:
180
+ args = ["list"]
181
+
182
+ action = args[0]
183
+ name = args[1] if len(args) > 1 else ""
184
+
185
+ if action == "list":
186
+ adapters = list_adapters()
187
+ print(" Ingestion Adapters:")
188
+ print(" " + "-" * 50)
189
+ for a in adapters:
190
+ status = "running" if a["running"] else ("enabled" if a["enabled"] else "disabled")
191
+ pid_str = f" (PID {a['pid']})" if a["pid"] else ""
192
+ print(f" {a['name']:12s} {status:10s}{pid_str}")
193
+ print()
194
+
195
+ elif action == "enable":
196
+ if not name:
197
+ print(" Usage: slm adapters enable <gmail|calendar|transcript>")
198
+ return
199
+ result = enable_adapter(name)
200
+ print(f" {result.get('message', result.get('error', ''))}")
201
+
202
+ elif action == "disable":
203
+ if not name:
204
+ print(" Usage: slm adapters disable <name>")
205
+ return
206
+ result = disable_adapter(name)
207
+ print(f" {result.get('message', result.get('error', ''))}")
208
+
209
+ elif action == "start":
210
+ if not name:
211
+ print(" Usage: slm adapters start <name>")
212
+ return
213
+ result = start_adapter(name)
214
+ print(f" {result.get('message', result.get('error', ''))}")
215
+
216
+ elif action == "stop":
217
+ if not name:
218
+ print(" Usage: slm adapters stop <name>")
219
+ return
220
+ result = stop_adapter(name)
221
+ print(f" {result.get('message', result.get('error', ''))}")
222
+
223
+ elif action == "status":
224
+ adapters = status_adapters()
225
+ for a in adapters:
226
+ status = "RUNNING" if a["running"] else ("enabled" if a["enabled"] else "off")
227
+ print(f" {a['name']:12s} [{status}]", end="")
228
+ if a["pid"]:
229
+ print(f" PID={a['pid']}", end="")
230
+ print()
231
+
232
+ else:
233
+ print(f" Unknown action: {action}")
234
+ print(" Usage: slm adapters <list|enable|disable|start|stop|status> [name]")
@@ -0,0 +1,177 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the Elastic License 2.0 - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Base adapter class for all ingestion adapters.
6
+
7
+ All adapters inherit this. Enforces stateless, safe, cross-platform operation:
8
+ - Clean shutdown via stop event + parent PID watchdog
9
+ - Rate limiting per hour
10
+ - Batch throttling with interruptible delays
11
+ - Retry on 429 responses
12
+ - Structured logging
13
+
14
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
+ License: Elastic-2.0
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import os
23
+ import signal
24
+ import sys
25
+ import threading
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import NamedTuple
30
+
31
+ logger = logging.getLogger("superlocalmemory.ingestion")
32
+
33
+
34
+ @dataclass
35
+ class AdapterConfig:
36
+ daemon_port: int = 8765
37
+ batch_size: int = 50
38
+ batch_delay_sec: float = 5.0
39
+ rate_limit_per_hour: int = 100
40
+
41
+
42
+ class IngestItem(NamedTuple):
43
+ content: str
44
+ dedup_key: str
45
+ metadata: dict = {}
46
+
47
+
48
+ class BaseAdapter:
49
+ """All ingestion adapters inherit this class.
50
+
51
+ Provides: run loop, rate limiting, retry, shutdown, parent watchdog.
52
+ Subclasses implement: fetch_items(), wait_for_next_cycle(), source_type.
53
+ """
54
+
55
+ source_type: str = "unknown"
56
+
57
+ def __init__(self, config: AdapterConfig | None = None):
58
+ self.config = config or AdapterConfig()
59
+ self.daemon_url = f"http://127.0.0.1:{self.config.daemon_port}"
60
+ self._items_this_hour = 0
61
+ self._hour_start = time.time()
62
+ self._stop_event = threading.Event()
63
+ self._parent_pid = os.getppid()
64
+ self._total_ingested = 0
65
+
66
+ def run(self) -> None:
67
+ """Main adapter loop. Subclasses don't override this."""
68
+ self._setup_signals()
69
+ logger.info("%s adapter started (PID %d)", self.source_type, os.getpid())
70
+
71
+ while not self._stop_event.is_set():
72
+ # Parent watchdog: exit if daemon died
73
+ try:
74
+ import psutil
75
+ if not psutil.pid_exists(self._parent_pid):
76
+ logger.info("Parent daemon died, adapter exiting")
77
+ break
78
+ except ImportError:
79
+ try:
80
+ os.kill(self._parent_pid, 0)
81
+ except (ProcessLookupError, PermissionError):
82
+ logger.info("Parent daemon died, adapter exiting")
83
+ break
84
+
85
+ try:
86
+ items = self.fetch_items()
87
+ except Exception as exc:
88
+ logger.warning("fetch_items failed: %s", exc)
89
+ self._stop_event.wait(30)
90
+ continue
91
+
92
+ if not items:
93
+ self.wait_for_next_cycle()
94
+ continue
95
+
96
+ # Process in batches
97
+ for i in range(0, len(items), self.config.batch_size):
98
+ if self._stop_event.is_set():
99
+ break
100
+ batch = items[i:i + self.config.batch_size]
101
+ for item in batch:
102
+ if self._stop_event.is_set():
103
+ break
104
+ if self._rate_limited():
105
+ logger.info("Rate limit reached (%d/hr), waiting",
106
+ self.config.rate_limit_per_hour)
107
+ self._stop_event.wait(60)
108
+ continue
109
+ self._ingest(item)
110
+ # Interruptible batch delay
111
+ self._stop_event.wait(self.config.batch_delay_sec)
112
+
113
+ self.wait_for_next_cycle()
114
+
115
+ logger.info("%s adapter stopped (total ingested: %d)",
116
+ self.source_type, self._total_ingested)
117
+
118
+ def stop(self) -> None:
119
+ self._stop_event.set()
120
+
121
+ # -- Subclass interface --
122
+
123
+ def fetch_items(self) -> list[IngestItem]:
124
+ """Fetch items from the source. Subclass MUST implement."""
125
+ raise NotImplementedError
126
+
127
+ def wait_for_next_cycle(self) -> None:
128
+ """Wait before next fetch cycle. Default: 5 min interruptible."""
129
+ self._stop_event.wait(300)
130
+
131
+ # -- Internal --
132
+
133
+ def _ingest(self, item: IngestItem) -> bool:
134
+ """POST to daemon /ingest endpoint. Returns True on success."""
135
+ try:
136
+ import urllib.request
137
+ payload = json.dumps({
138
+ "content": item.content,
139
+ "source_type": self.source_type,
140
+ "dedup_key": item.dedup_key,
141
+ "metadata": item.metadata if item.metadata else {},
142
+ }).encode()
143
+ req = urllib.request.Request(
144
+ f"{self.daemon_url}/ingest",
145
+ data=payload,
146
+ headers={"Content-Type": "application/json"},
147
+ method="POST",
148
+ )
149
+ resp = urllib.request.urlopen(req, timeout=30)
150
+ data = json.loads(resp.read().decode())
151
+ if data.get("ingested"):
152
+ self._items_this_hour += 1
153
+ self._total_ingested += 1
154
+ return True
155
+ return False # Already ingested (dedup)
156
+ except Exception as exc:
157
+ error_str = str(exc)
158
+ if "429" in error_str:
159
+ logger.info("Daemon returned 429, backing off 5s")
160
+ self._stop_event.wait(5)
161
+ return self._ingest(item) # Retry once
162
+ logger.debug("Ingest failed: %s", exc)
163
+ return False
164
+
165
+ def _rate_limited(self) -> bool:
166
+ if time.time() - self._hour_start > 3600:
167
+ self._items_this_hour = 0
168
+ self._hour_start = time.time()
169
+ return self._items_this_hour >= self.config.rate_limit_per_hour
170
+
171
+ def _setup_signals(self) -> None:
172
+ """Set up clean shutdown on SIGTERM."""
173
+ def _handler(sig, frame):
174
+ self.stop()
175
+ signal.signal(signal.SIGTERM, _handler)
176
+ if sys.platform != "win32":
177
+ signal.signal(signal.SIGINT, _handler)