superlocalmemory 3.4.25 → 3.4.31

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 (55) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +8 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +3 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/cli/daemon.py +90 -16
  7. package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
  8. package/src/superlocalmemory/cli/main.py +28 -0
  9. package/src/superlocalmemory/cli/pending_store.py +55 -3
  10. package/src/superlocalmemory/cli/post_install.py +15 -0
  11. package/src/superlocalmemory/cli/setup_wizard.py +20 -0
  12. package/src/superlocalmemory/cli/version_banner.py +183 -0
  13. package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
  14. package/src/superlocalmemory/core/clock_monitor.py +45 -0
  15. package/src/superlocalmemory/core/db_pool.py +80 -0
  16. package/src/superlocalmemory/core/engine.py +75 -30
  17. package/src/superlocalmemory/core/engine_capabilities.py +24 -0
  18. package/src/superlocalmemory/core/engine_lock.py +75 -0
  19. package/src/superlocalmemory/core/error_catalog.py +113 -0
  20. package/src/superlocalmemory/core/error_envelope.py +60 -0
  21. package/src/superlocalmemory/core/file_lock.py +92 -0
  22. package/src/superlocalmemory/core/loop_watchdog.py +56 -0
  23. package/src/superlocalmemory/core/maintenance_scheduler.py +8 -0
  24. package/src/superlocalmemory/core/priority_queue.py +61 -0
  25. package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
  26. package/src/superlocalmemory/core/rate_limit.py +151 -0
  27. package/src/superlocalmemory/core/recall_queue.py +370 -0
  28. package/src/superlocalmemory/core/recall_worker.py +10 -0
  29. package/src/superlocalmemory/core/safe_fs.py +108 -0
  30. package/src/superlocalmemory/hooks/auto_capture.py +34 -12
  31. package/src/superlocalmemory/hooks/auto_recall.py +36 -9
  32. package/src/superlocalmemory/learning/signals.py +7 -1
  33. package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
  34. package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
  35. package/src/superlocalmemory/mcp/resources.py +8 -5
  36. package/src/superlocalmemory/mcp/server.py +38 -9
  37. package/src/superlocalmemory/mcp/tools_active.py +21 -9
  38. package/src/superlocalmemory/mcp/tools_core.py +13 -9
  39. package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
  40. package/src/superlocalmemory/mcp/tools_learning.py +5 -3
  41. package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
  42. package/src/superlocalmemory/mcp/tools_v3.py +18 -22
  43. package/src/superlocalmemory/mcp/tools_v33.py +65 -2
  44. package/src/superlocalmemory/migrations/__init__.py +5 -0
  45. package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
  46. package/src/superlocalmemory/server/routes/data_io.py +21 -2
  47. package/src/superlocalmemory/server/routes/memories.py +91 -0
  48. package/src/superlocalmemory/server/routes/stats.py +16 -2
  49. package/src/superlocalmemory/server/unified_daemon.py +128 -12
  50. package/src/superlocalmemory/ui/index.html +35 -25
  51. package/src/superlocalmemory/ui/js/core.js +20 -4
  52. package/src/superlocalmemory/ui/js/fact-detail.js +62 -73
  53. package/src/superlocalmemory/ui/js/memories.js +34 -2
  54. package/src/superlocalmemory/ui/js/modal.js +41 -2
  55. package/src/superlocalmemory/ui/js/search.js +27 -0
@@ -0,0 +1,151 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Layered token-bucket rate limiter.
6
+
7
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+
16
+ from superlocalmemory.core.error_envelope import ErrorCode
17
+
18
+
19
+ class RateLimitedError(Exception):
20
+ def __init__(
21
+ self,
22
+ layer: str,
23
+ retry_after_ms: int,
24
+ message: str | None = None,
25
+ **extras: object,
26
+ ) -> None:
27
+ self.layer = layer
28
+ self.retry_after_ms = retry_after_ms
29
+ self.code = ErrorCode.RATE_LIMITED
30
+ self.extras = extras
31
+ super().__init__(message or f"{layer} rate limit exceeded")
32
+
33
+
34
+ class TokenBucket:
35
+ __slots__ = ("rate", "capacity", "tokens", "last_refill")
36
+
37
+ def __init__(self, rate_per_sec: float, capacity: float | None = None) -> None:
38
+ self.rate = float(rate_per_sec)
39
+ self.capacity = float(capacity) if capacity is not None else float(rate_per_sec)
40
+ self.tokens = self.capacity
41
+ self.last_refill = time.monotonic()
42
+
43
+ def _refill(self, now: float) -> None:
44
+ delta = now - self.last_refill
45
+ if delta > 0:
46
+ self.tokens = min(self.capacity, self.tokens + delta * self.rate)
47
+ self.last_refill = now
48
+
49
+ def can_consume(self, cost: float = 1.0) -> bool:
50
+ self._refill(time.monotonic())
51
+ return self.tokens >= cost
52
+
53
+ def try_consume(self, cost: float = 1.0) -> bool:
54
+ now = time.monotonic()
55
+ self._refill(now)
56
+ if self.tokens >= cost:
57
+ self.tokens -= cost
58
+ return True
59
+ return False
60
+
61
+ def consume(self, cost: float = 1.0) -> None:
62
+ self.tokens -= cost
63
+
64
+ def ms_to_refill(self, cost: float = 1.0) -> int:
65
+ self._refill(time.monotonic())
66
+ needed = max(0.0, cost - self.tokens)
67
+ if self.rate <= 0:
68
+ return 1_000_000
69
+ return int((needed / self.rate) * 1000) + 1
70
+
71
+
72
+ @dataclass
73
+ class _PidEntry:
74
+ bucket: TokenBucket
75
+ last_used: float = field(default_factory=time.monotonic)
76
+
77
+
78
+ class LayeredRateLimiter:
79
+ def __init__(
80
+ self,
81
+ *,
82
+ global_rps: float = 100.0,
83
+ per_pid_rps: float = 30.0,
84
+ per_agent_rps: float = 10.0,
85
+ idle_ttl_s: float = 60.0,
86
+ ) -> None:
87
+ self._global = TokenBucket(global_rps)
88
+ self._per_pid_rps = per_pid_rps
89
+ self._per_agent_rps = per_agent_rps
90
+ self._idle_ttl_s = idle_ttl_s
91
+ self._per_pid: dict[int, _PidEntry] = {}
92
+ self._per_agent: dict[str, _PidEntry] = {}
93
+ self._lock = threading.RLock()
94
+
95
+ def _sweep(self, now: float) -> None:
96
+ cutoff = now - self._idle_ttl_s
97
+ for k in [p for p, e in self._per_pid.items() if e.last_used < cutoff]:
98
+ del self._per_pid[k]
99
+ for k in [a for a, e in self._per_agent.items() if e.last_used < cutoff]:
100
+ del self._per_agent[a]
101
+
102
+ def _get_pid(self, pid: int, now: float) -> TokenBucket:
103
+ entry = self._per_pid.get(pid)
104
+ if entry is None:
105
+ entry = _PidEntry(TokenBucket(self._per_pid_rps), now)
106
+ self._per_pid[pid] = entry
107
+ entry.last_used = now
108
+ return entry.bucket
109
+
110
+ def _get_agent(self, agent_id: str, now: float) -> TokenBucket:
111
+ entry = self._per_agent.get(agent_id)
112
+ if entry is None:
113
+ entry = _PidEntry(TokenBucket(self._per_agent_rps), now)
114
+ self._per_agent[agent_id] = entry
115
+ entry.last_used = now
116
+ return entry.bucket
117
+
118
+ def check_and_consume(self, *, pid: int, agent_id: str | None = None) -> None:
119
+ """Peek all layers; raise on any reject without touching others.
120
+
121
+ On admission, consume one token from each applicable bucket.
122
+ """
123
+ with self._lock:
124
+ now = time.monotonic()
125
+ self._sweep(now)
126
+ pid_bucket = self._get_pid(pid, now)
127
+ agent_bucket = self._get_agent(agent_id, now) if agent_id else None
128
+ if not self._global.can_consume():
129
+ raise RateLimitedError(
130
+ "global", self._global.ms_to_refill(),
131
+ )
132
+ if not pid_bucket.can_consume():
133
+ raise RateLimitedError(
134
+ "per-pid", pid_bucket.ms_to_refill(), pid=pid,
135
+ )
136
+ if agent_bucket is not None and not agent_bucket.can_consume():
137
+ raise RateLimitedError(
138
+ "per-agent", agent_bucket.ms_to_refill(), agent=agent_id,
139
+ )
140
+ self._global.consume()
141
+ pid_bucket.consume()
142
+ if agent_bucket is not None:
143
+ agent_bucket.consume()
144
+
145
+ def per_pid_size(self) -> int:
146
+ with self._lock:
147
+ return len(self._per_pid)
148
+
149
+ def per_agent_size(self) -> int:
150
+ with self._lock:
151
+ return len(self._per_agent)
@@ -0,0 +1,370 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """DB-backed recall queue — schema, enqueue, claim, complete, poll.
6
+
7
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import sqlite3
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any, Iterable, Optional
20
+
21
+ from superlocalmemory.core.safe_fs import _safe_open_db
22
+
23
+
24
+ class QueueTimeoutError(Exception):
25
+ def __init__(self, request_id: str, elapsed_s: float) -> None:
26
+ self.request_id = request_id
27
+ self.elapsed_s = elapsed_s
28
+ super().__init__(f"poll timed out after {elapsed_s:.2f}s")
29
+
30
+
31
+ class QueueCancelledError(Exception):
32
+ def __init__(self, request_id: str) -> None:
33
+ self.request_id = request_id
34
+ super().__init__(f"request {request_id} was cancelled")
35
+
36
+
37
+ class DeadLetterError(Exception):
38
+ def __init__(self, request_id: str, reason: str) -> None:
39
+ self.request_id = request_id
40
+ self.reason = reason
41
+ super().__init__(f"request {request_id}: {reason}")
42
+
43
+
44
+ _SCHEMA = """
45
+ CREATE TABLE IF NOT EXISTS recall_requests (
46
+ request_id TEXT PRIMARY KEY,
47
+ query_hash TEXT NOT NULL,
48
+ job_type TEXT NOT NULL DEFAULT 'recall',
49
+ idempotency_key TEXT,
50
+ session_id TEXT NOT NULL DEFAULT '',
51
+ agent_id TEXT NOT NULL DEFAULT '',
52
+ namespace TEXT NOT NULL DEFAULT '',
53
+ tenant_id TEXT NOT NULL DEFAULT '',
54
+ query TEXT NOT NULL DEFAULT '',
55
+ limit_n INTEGER NOT NULL DEFAULT 10,
56
+ mode TEXT NOT NULL DEFAULT 'B',
57
+ priority TEXT NOT NULL DEFAULT 'high',
58
+ weight INTEGER NOT NULL DEFAULT 70,
59
+ claim_expires_at REAL,
60
+ received INTEGER NOT NULL DEFAULT 0,
61
+ completed INTEGER NOT NULL DEFAULT 0,
62
+ cancelled INTEGER NOT NULL DEFAULT 0,
63
+ dead_letter INTEGER NOT NULL DEFAULT 0,
64
+ result_json TEXT,
65
+ error_reason TEXT,
66
+ subscriber_count INTEGER NOT NULL DEFAULT 1,
67
+ last_subscribe_at REAL,
68
+ created_at REAL NOT NULL,
69
+ worker_pid INTEGER,
70
+ worker_create_time INTEGER,
71
+ worker_progress TEXT,
72
+ stall_timeout_s REAL,
73
+ cost_usd REAL NOT NULL DEFAULT 0.0,
74
+ CHECK (completed IN (0, 1)),
75
+ CHECK (cancelled IN (0, 1)),
76
+ CHECK (dead_letter IN (0, 1)),
77
+ CHECK (completed + cancelled + dead_letter <= 1),
78
+ CHECK (NOT (completed = 1 AND result_json IS NULL)),
79
+ CHECK (subscriber_count >= 0)
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_recall_visible
83
+ ON recall_requests(priority, created_at, request_id)
84
+ WHERE completed = 0 AND cancelled = 0 AND dead_letter = 0;
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_recall_dedup
87
+ ON recall_requests(query_hash)
88
+ WHERE completed = 0 AND cancelled = 0 AND dead_letter = 0;
89
+
90
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recall_idem_key
91
+ ON recall_requests(job_type, idempotency_key)
92
+ WHERE idempotency_key IS NOT NULL
93
+ AND completed = 0 AND cancelled = 0 AND dead_letter = 0;
94
+ """
95
+
96
+
97
+ def _now() -> float:
98
+ return time.time()
99
+
100
+
101
+ def _make_request_id() -> str:
102
+ return "r-" + uuid.uuid4().hex[:12]
103
+
104
+
105
+ def _query_hash(
106
+ *, session_id: str, agent_id: str, query: str, limit_n: int,
107
+ mode: str, tenant_id: str,
108
+ ) -> str:
109
+ blob = "||".join((
110
+ tenant_id, session_id, agent_id, mode, str(limit_n), query,
111
+ )).encode("utf-8")
112
+ return hashlib.blake2b(blob, digest_size=16).hexdigest()
113
+
114
+
115
+ class RecallQueue:
116
+ def __init__(self, db_path: Path | str) -> None:
117
+ self._db_path = Path(db_path)
118
+ self._lock = threading.RLock()
119
+ self._conn = _safe_open_db(self._db_path)
120
+ self._conn.row_factory = sqlite3.Row
121
+ self._conn.execute("PRAGMA journal_mode=WAL")
122
+ self._conn.execute("PRAGMA synchronous=NORMAL")
123
+ self._conn.execute("PRAGMA busy_timeout=5000")
124
+ self._conn.execute("PRAGMA foreign_keys=ON")
125
+ self._conn.execute("PRAGMA mmap_size=67108864")
126
+ for stmt in _SCHEMA.strip().split(";"):
127
+ s = stmt.strip()
128
+ if s:
129
+ self._conn.execute(s)
130
+ self._closed = False
131
+
132
+ # ----- schema-level helpers (tests / ops) -----
133
+ def _raw_execute(self, sql: str, params: Iterable[Any] = ()) -> None:
134
+ with self._lock:
135
+ self._conn.execute(sql, tuple(params))
136
+
137
+ def _get_row(self, request_id: str) -> Optional[sqlite3.Row]:
138
+ with self._lock:
139
+ cur = self._conn.execute(
140
+ "SELECT * FROM recall_requests WHERE request_id = ?",
141
+ (request_id,),
142
+ )
143
+ return cur.fetchone()
144
+
145
+ def _force_cancelled(self, request_id: str) -> None:
146
+ with self._lock:
147
+ self._conn.execute(
148
+ "UPDATE recall_requests SET cancelled=1 WHERE request_id=?",
149
+ (request_id,),
150
+ )
151
+
152
+ # ----- enqueue -----
153
+ def enqueue(
154
+ self,
155
+ *,
156
+ query: str,
157
+ limit_n: int,
158
+ mode: str,
159
+ agent_id: str,
160
+ session_id: str,
161
+ tenant_id: str = "",
162
+ namespace: str = "",
163
+ priority: str = "high",
164
+ stall_timeout_s: float = 25.0,
165
+ ) -> str:
166
+ qhash = _query_hash(
167
+ session_id=session_id, agent_id=agent_id, query=query,
168
+ limit_n=limit_n, mode=mode, tenant_id=tenant_id,
169
+ )
170
+ with self._lock:
171
+ self._conn.execute("BEGIN IMMEDIATE")
172
+ try:
173
+ cur = self._conn.execute(
174
+ "SELECT request_id FROM recall_requests "
175
+ "WHERE query_hash = ? AND completed = 0 "
176
+ "AND cancelled = 0 AND dead_letter = 0 "
177
+ "LIMIT 1",
178
+ (qhash,),
179
+ )
180
+ existing = cur.fetchone()
181
+ if existing is not None:
182
+ rid = existing["request_id"]
183
+ self._conn.execute(
184
+ "UPDATE recall_requests "
185
+ "SET subscriber_count = subscriber_count + 1, "
186
+ " last_subscribe_at = ? "
187
+ "WHERE request_id = ?",
188
+ (_now(), rid),
189
+ )
190
+ self._conn.execute("COMMIT")
191
+ return rid
192
+ rid = _make_request_id()
193
+ self._conn.execute(
194
+ "INSERT INTO recall_requests "
195
+ "(request_id, query_hash, job_type, session_id, agent_id, "
196
+ " namespace, tenant_id, query, limit_n, mode, priority, "
197
+ " weight, created_at, subscriber_count, last_subscribe_at, "
198
+ " stall_timeout_s) "
199
+ "VALUES (?, ?, 'recall', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
200
+ (
201
+ rid, qhash, session_id, agent_id, namespace, tenant_id,
202
+ query, limit_n, mode, priority,
203
+ 70 if priority == "high" else 30,
204
+ _now(), _now(), stall_timeout_s,
205
+ ),
206
+ )
207
+ self._conn.execute("COMMIT")
208
+ return rid
209
+ except Exception:
210
+ self._conn.execute("ROLLBACK")
211
+ raise
212
+
213
+ def enqueue_job(
214
+ self,
215
+ *,
216
+ job_type: str,
217
+ idempotency_key: str,
218
+ agent_id: str,
219
+ session_id: str,
220
+ priority: str = "low",
221
+ stall_timeout_s: float = 40.0,
222
+ query: str = "",
223
+ ) -> str:
224
+ with self._lock:
225
+ self._conn.execute("BEGIN IMMEDIATE")
226
+ try:
227
+ cur = self._conn.execute(
228
+ "SELECT request_id FROM recall_requests "
229
+ "WHERE job_type = ? AND idempotency_key = ? "
230
+ "AND completed = 0 AND cancelled = 0 AND dead_letter = 0 "
231
+ "LIMIT 1",
232
+ (job_type, idempotency_key),
233
+ )
234
+ existing = cur.fetchone()
235
+ if existing is not None:
236
+ self._conn.execute("COMMIT")
237
+ return existing["request_id"]
238
+ rid = _make_request_id()
239
+ self._conn.execute(
240
+ "INSERT INTO recall_requests "
241
+ "(request_id, query_hash, job_type, idempotency_key, "
242
+ " session_id, agent_id, query, priority, weight, "
243
+ " created_at, subscriber_count, last_subscribe_at, "
244
+ " stall_timeout_s) "
245
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
246
+ (
247
+ rid, idempotency_key, job_type, idempotency_key,
248
+ session_id, agent_id, query, priority,
249
+ 70 if priority == "high" else 30,
250
+ _now(), _now(), stall_timeout_s,
251
+ ),
252
+ )
253
+ self._conn.execute("COMMIT")
254
+ return rid
255
+ except Exception:
256
+ self._conn.execute("ROLLBACK")
257
+ raise
258
+
259
+ # ----- claim / complete / fence -----
260
+ def claim_pending(
261
+ self,
262
+ *,
263
+ priority: str = "high",
264
+ stall_timeout_s: float = 25.0,
265
+ ) -> Optional[dict[str, Any]]:
266
+ now = _now()
267
+ with self._lock:
268
+ self._conn.execute("BEGIN IMMEDIATE")
269
+ try:
270
+ cur = self._conn.execute(
271
+ "SELECT request_id, received FROM recall_requests "
272
+ "WHERE priority = ? "
273
+ "AND completed = 0 AND cancelled = 0 AND dead_letter = 0 "
274
+ "AND (claim_expires_at IS NULL OR claim_expires_at < ?) "
275
+ "ORDER BY created_at, request_id LIMIT 1",
276
+ (priority, now),
277
+ )
278
+ row = cur.fetchone()
279
+ if row is None:
280
+ self._conn.execute("COMMIT")
281
+ return None
282
+ rid = row["request_id"]
283
+ received = row["received"] + 1
284
+ expires = now + stall_timeout_s
285
+ self._conn.execute(
286
+ "UPDATE recall_requests "
287
+ "SET received = ?, claim_expires_at = ? "
288
+ "WHERE request_id = ?",
289
+ (received, expires, rid),
290
+ )
291
+ self._conn.execute("COMMIT")
292
+ out = self._get_row(rid)
293
+ return dict(out) if out is not None else None
294
+ except Exception:
295
+ self._conn.execute("ROLLBACK")
296
+ raise
297
+
298
+ def complete(
299
+ self, request_id: str, *, received: int, result_json: str,
300
+ ) -> int:
301
+ with self._lock:
302
+ cur = self._conn.execute(
303
+ "UPDATE recall_requests "
304
+ "SET completed = 1, result_json = ? "
305
+ "WHERE request_id = ? AND received = ? "
306
+ "AND completed = 0 AND cancelled = 0 AND dead_letter = 0",
307
+ (result_json, request_id, received),
308
+ )
309
+ if cur.rowcount == 0:
310
+ import logging as _log
311
+ _log.getLogger(__name__).warning(
312
+ "fenced out: request_id=%s received=%d (stale or already terminal)",
313
+ request_id, received,
314
+ )
315
+ return cur.rowcount
316
+
317
+ def mark_dead_letter(self, request_id: str, *, reason: str) -> None:
318
+ with self._lock:
319
+ self._conn.execute(
320
+ "UPDATE recall_requests "
321
+ "SET dead_letter = 1, error_reason = ? "
322
+ "WHERE request_id = ? AND completed = 0 AND cancelled = 0",
323
+ (reason, request_id),
324
+ )
325
+
326
+ def unsubscribe(self, request_id: str) -> None:
327
+ with self._lock:
328
+ self._conn.execute(
329
+ "UPDATE recall_requests "
330
+ "SET subscriber_count = MAX(0, subscriber_count - 1) "
331
+ "WHERE request_id = ?",
332
+ (request_id,),
333
+ )
334
+
335
+ # ----- poll with DLQ fast-fail -----
336
+ _POLL_SCHEDULE = (0.05, 0.1, 0.2, 0.3, 0.5)
337
+
338
+ def poll_result(self, request_id: str, *, timeout_s: float) -> dict[str, Any]:
339
+ deadline = time.monotonic() + timeout_s
340
+ idx = 0
341
+ while True:
342
+ row = self._get_row(request_id)
343
+ if row is not None:
344
+ if row["dead_letter"]:
345
+ raise DeadLetterError(
346
+ request_id,
347
+ reason=row["error_reason"] or "max_receives_exceeded",
348
+ )
349
+ if row["cancelled"] and not row["completed"]:
350
+ raise QueueCancelledError(request_id)
351
+ if row["completed"]:
352
+ return json.loads(row["result_json"])
353
+ remaining = deadline - time.monotonic()
354
+ if remaining <= 0:
355
+ raise QueueTimeoutError(request_id, timeout_s)
356
+ sleep_for = min(
357
+ self._POLL_SCHEDULE[min(idx, len(self._POLL_SCHEDULE) - 1)],
358
+ remaining,
359
+ )
360
+ time.sleep(sleep_for)
361
+ idx += 1
362
+
363
+ def close(self) -> None:
364
+ if self._closed:
365
+ return
366
+ self._closed = True
367
+ try:
368
+ self._conn.close()
369
+ except Exception:
370
+ pass
@@ -69,6 +69,8 @@ def _handle_recall(query: str, limit: int, session_id: str = "") -> dict:
69
69
 
70
70
  results = []
71
71
  for r in response.results[:limit]:
72
+ fact_type = getattr(r.fact, "fact_type", None)
73
+ lifecycle = getattr(r.fact, "lifecycle", None)
72
74
  results.append({
73
75
  "fact_id": r.fact.fact_id,
74
76
  "memory_id": r.fact.memory_id,
@@ -80,6 +82,10 @@ def _handle_recall(query: str, limit: int, session_id: str = "") -> dict:
80
82
  "channel_scores": {
81
83
  k: round(v, 4) for k, v in (r.channel_scores or {}).items()
82
84
  },
85
+ "fact_type": fact_type.value if fact_type and hasattr(fact_type, "value") else "",
86
+ "lifecycle": lifecycle.value if lifecycle and hasattr(lifecycle, "value") else "",
87
+ "access_count": getattr(r.fact, "access_count", 0),
88
+ "evidence_chain": list(getattr(r, "evidence_chain", []) or []),
83
89
  })
84
90
  return {
85
91
  "ok": True,
@@ -87,6 +93,10 @@ def _handle_recall(query: str, limit: int, session_id: str = "") -> dict:
87
93
  "query_type": response.query_type,
88
94
  "result_count": len(results),
89
95
  "retrieval_time_ms": round(response.retrieval_time_ms, 1),
96
+ "channel_weights": {
97
+ k: round(v, 3) for k, v in (response.channel_weights or {}).items()
98
+ },
99
+ "total_candidates": getattr(response, "total_candidates", 0),
90
100
  "results": results,
91
101
  }
92
102
 
@@ -0,0 +1,108 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Filesystem safety helpers for SQLite DB open paths.
6
+
7
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sqlite3
14
+ import stat
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ class SafeFsError(RuntimeError):
20
+ """Raised when a data-directory or DB-open precondition fails."""
21
+
22
+
23
+ _FORBIDDEN_COMPONENTS = (
24
+ "iCloud Drive", "Mobile Documents", "CloudDocs",
25
+ "Dropbox", "Google Drive", "OneDrive", "Box Sync",
26
+ # macOS 13+ canonical cloud-storage mount point. Every modern
27
+ # Dropbox / OneDrive / Google Drive / Box install on macOS routes
28
+ # through here, bypassing the brand-name checks above.
29
+ "Library/CloudStorage",
30
+ )
31
+
32
+
33
+ def validate_data_dir(path: Path) -> None:
34
+ """Refuse paths that live under a cloud-sync folder.
35
+
36
+ Cloud-synced directories silently corrupt SQLite WAL files.
37
+ """
38
+ resolved = str(path.resolve())
39
+ lowered = resolved.lower()
40
+ for comp in _FORBIDDEN_COMPONENTS:
41
+ if comp.lower() in lowered:
42
+ raise SafeFsError(
43
+ f"SLM data directory {resolved} appears to live under "
44
+ f"{comp!r} — cloud-synced folders are not supported "
45
+ f"(SQLite WAL corrupts silently on replication). "
46
+ f"Set SLM_DATA_DIR to a local path such as "
47
+ f"{Path.home()}/.slm-local"
48
+ )
49
+
50
+
51
+ def _ensure_parent_0700(path: Path) -> None:
52
+ parent = path.parent
53
+ if not parent.exists():
54
+ parent.mkdir(parents=True, exist_ok=True)
55
+ if sys.platform == "win32":
56
+ return
57
+ st = parent.stat()
58
+ if st.st_uid != os.getuid():
59
+ raise SafeFsError(
60
+ f"{parent} is not owned by current user (uid={os.getuid()})"
61
+ )
62
+ if (st.st_mode & 0o077) != 0:
63
+ os.chmod(parent, 0o700)
64
+
65
+
66
+ def _safe_open_db(path: Path) -> sqlite3.Connection:
67
+ """Open a SQLite DB with TOCTOU-tight symlink protection.
68
+
69
+ Pattern:
70
+ 1. Enforce parent directory 0700.
71
+ 2. lstat the DB path; refuse if it's already a symlink.
72
+ 3. Open with O_NOFOLLOW to catch a last-moment swap.
73
+ 4. fstat and compare inode with the pre-open lstat.
74
+ 5. Close the fd and let sqlite3.connect reopen by path so WAL
75
+ and SHM sibling files can be created naturally.
76
+ """
77
+ _ensure_parent_0700(path)
78
+ if sys.platform != "win32":
79
+ if path.exists():
80
+ pre_st = os.lstat(str(path))
81
+ if stat.S_ISLNK(pre_st.st_mode):
82
+ raise SafeFsError(f"{path} is a symlink — refused")
83
+ if pre_st.st_uid != os.getuid():
84
+ raise SafeFsError(f"{path} not owned by current user")
85
+ if (pre_st.st_mode & 0o077) != 0:
86
+ os.chmod(str(path), 0o600)
87
+ else:
88
+ pre_st = None
89
+ try:
90
+ flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
91
+ fd = os.open(str(path), flags, 0o600)
92
+ except OSError as exc:
93
+ raise SafeFsError(f"Cannot safely open {path}: {exc}") from exc
94
+ try:
95
+ post_st = os.fstat(fd)
96
+ if pre_st is not None and (
97
+ (pre_st.st_ino, pre_st.st_dev)
98
+ != (post_st.st_ino, post_st.st_dev)
99
+ ):
100
+ raise SafeFsError(
101
+ f"{path} changed between lstat and open — refused"
102
+ )
103
+ finally:
104
+ os.close(fd)
105
+ conn = sqlite3.connect(
106
+ str(path), isolation_level=None, timeout=5.0, check_same_thread=False,
107
+ )
108
+ return conn