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.
- package/package.json +1 -1
- package/pyproject.toml +11 -2
- package/scripts/postinstall.js +26 -7
- package/src/superlocalmemory/cli/commands.py +42 -60
- package/src/superlocalmemory/cli/daemon.py +107 -47
- package/src/superlocalmemory/cli/main.py +10 -0
- package/src/superlocalmemory/cli/setup_wizard.py +137 -9
- package/src/superlocalmemory/core/config.py +28 -0
- package/src/superlocalmemory/core/consolidation_engine.py +38 -1
- package/src/superlocalmemory/core/engine.py +9 -0
- package/src/superlocalmemory/core/health_monitor.py +313 -0
- package/src/superlocalmemory/core/reranker_worker.py +19 -5
- package/src/superlocalmemory/ingestion/__init__.py +13 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +234 -0
- package/src/superlocalmemory/ingestion/base_adapter.py +177 -0
- package/src/superlocalmemory/ingestion/calendar_adapter.py +340 -0
- package/src/superlocalmemory/ingestion/credentials.py +118 -0
- package/src/superlocalmemory/ingestion/gmail_adapter.py +369 -0
- package/src/superlocalmemory/ingestion/parsers.py +100 -0
- package/src/superlocalmemory/ingestion/transcript_adapter.py +156 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +47 -1
- package/src/superlocalmemory/learning/entity_compiler.py +377 -0
- package/src/superlocalmemory/mesh/__init__.py +12 -0
- package/src/superlocalmemory/mesh/broker.py +344 -0
- package/src/superlocalmemory/retrieval/entity_channel.py +12 -6
- package/src/superlocalmemory/server/api.py +6 -7
- package/src/superlocalmemory/server/routes/entity.py +95 -0
- package/src/superlocalmemory/server/routes/ingest.py +110 -0
- package/src/superlocalmemory/server/routes/mesh.py +186 -0
- package/src/superlocalmemory/server/unified_daemon.py +691 -0
- package/src/superlocalmemory/storage/schema_v343.py +229 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -597
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -287
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -47
- 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)
|