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.
- package/CHANGELOG.md +92 -0
- package/README.md +8 -1
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/daemon.py +90 -16
- package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
- package/src/superlocalmemory/cli/main.py +28 -0
- package/src/superlocalmemory/cli/pending_store.py +55 -3
- package/src/superlocalmemory/cli/post_install.py +15 -0
- package/src/superlocalmemory/cli/setup_wizard.py +20 -0
- package/src/superlocalmemory/cli/version_banner.py +183 -0
- package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
- package/src/superlocalmemory/core/clock_monitor.py +45 -0
- package/src/superlocalmemory/core/db_pool.py +80 -0
- package/src/superlocalmemory/core/engine.py +75 -30
- package/src/superlocalmemory/core/engine_capabilities.py +24 -0
- package/src/superlocalmemory/core/engine_lock.py +75 -0
- package/src/superlocalmemory/core/error_catalog.py +113 -0
- package/src/superlocalmemory/core/error_envelope.py +60 -0
- package/src/superlocalmemory/core/file_lock.py +92 -0
- package/src/superlocalmemory/core/loop_watchdog.py +56 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +8 -0
- package/src/superlocalmemory/core/priority_queue.py +61 -0
- package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
- package/src/superlocalmemory/core/rate_limit.py +151 -0
- package/src/superlocalmemory/core/recall_queue.py +370 -0
- package/src/superlocalmemory/core/recall_worker.py +10 -0
- package/src/superlocalmemory/core/safe_fs.py +108 -0
- package/src/superlocalmemory/hooks/auto_capture.py +34 -12
- package/src/superlocalmemory/hooks/auto_recall.py +36 -9
- package/src/superlocalmemory/learning/signals.py +7 -1
- package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
- package/src/superlocalmemory/mcp/resources.py +8 -5
- package/src/superlocalmemory/mcp/server.py +38 -9
- package/src/superlocalmemory/mcp/tools_active.py +21 -9
- package/src/superlocalmemory/mcp/tools_core.py +13 -9
- package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
- package/src/superlocalmemory/mcp/tools_learning.py +5 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
- package/src/superlocalmemory/mcp/tools_v3.py +18 -22
- package/src/superlocalmemory/mcp/tools_v33.py +65 -2
- package/src/superlocalmemory/migrations/__init__.py +5 -0
- package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
- package/src/superlocalmemory/server/routes/data_io.py +21 -2
- package/src/superlocalmemory/server/routes/memories.py +91 -0
- package/src/superlocalmemory/server/routes/stats.py +16 -2
- package/src/superlocalmemory/server/unified_daemon.py +128 -12
- package/src/superlocalmemory/ui/index.html +35 -25
- package/src/superlocalmemory/ui/js/core.js +20 -4
- package/src/superlocalmemory/ui/js/fact-detail.js +62 -73
- package/src/superlocalmemory/ui/js/memories.js +34 -2
- package/src/superlocalmemory/ui/js/modal.js +41 -2
- 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
|