superlocalmemory 3.7.6 → 3.7.8
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 +30 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/pyproject.toml +6 -7
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +171 -11
- package/src/superlocalmemory/cli/setup_wizard.py +18 -1
- package/src/superlocalmemory/infra/auth_middleware.py +33 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_core.py +216 -20
- package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
- package/src/superlocalmemory/optimize/cache/manager.py +7 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
- package/src/superlocalmemory/server/api.py +17 -0
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +12 -6
- package/src/superlocalmemory/server/routes/chat.py +63 -12
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/memories.py +58 -11
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +128 -52
- package/src/superlocalmemory/server/ui.py +10 -0
- package/src/superlocalmemory/server/unified_daemon.py +290 -74
- package/src/superlocalmemory/storage/migration_runner.py +17 -3
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/storage/schema_v32.py +0 -9
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Linearizable active-profile runtime state for the unified daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable, Iterator
|
|
15
|
+
|
|
16
|
+
from superlocalmemory.infra.data_root import state_path
|
|
17
|
+
|
|
18
|
+
_RUNTIME_BIND_LOCK = threading.Lock()
|
|
19
|
+
_REQUEST_PROFILE: ContextVar[str | None] = ContextVar(
|
|
20
|
+
"slm_request_profile", default=None,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_request_profile() -> str | None:
|
|
25
|
+
"""Return the immutable profile snapshot admitted for this request."""
|
|
26
|
+
return _REQUEST_PROFILE.get()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ProfileSnapshot:
|
|
31
|
+
"""One immutable active-profile generation."""
|
|
32
|
+
|
|
33
|
+
profile_id: str
|
|
34
|
+
generation: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProfileRuntime:
|
|
38
|
+
"""Coordinate profile-sensitive operations and exclusive transitions."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, profile_id: str, *, generation: int = 0) -> None:
|
|
41
|
+
self._condition = threading.Condition(threading.Lock())
|
|
42
|
+
self._snapshot = ProfileSnapshot(profile_id, generation)
|
|
43
|
+
self._active_operations = 0
|
|
44
|
+
self._transitioning = False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def snapshot(self) -> ProfileSnapshot:
|
|
48
|
+
with self._condition:
|
|
49
|
+
return self._snapshot
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def transitioning(self) -> bool:
|
|
53
|
+
with self._condition:
|
|
54
|
+
return self._transitioning
|
|
55
|
+
|
|
56
|
+
def acquire_operation(self) -> ProfileSnapshot:
|
|
57
|
+
"""Admit an operation only when no profile transition is active."""
|
|
58
|
+
with self._condition:
|
|
59
|
+
while self._transitioning:
|
|
60
|
+
self._condition.wait()
|
|
61
|
+
self._active_operations += 1
|
|
62
|
+
return self._snapshot
|
|
63
|
+
|
|
64
|
+
def release_operation(self) -> None:
|
|
65
|
+
with self._condition:
|
|
66
|
+
if self._active_operations <= 0:
|
|
67
|
+
raise RuntimeError("profile runtime operation lease underflow")
|
|
68
|
+
self._active_operations -= 1
|
|
69
|
+
if self._active_operations == 0:
|
|
70
|
+
self._condition.notify_all()
|
|
71
|
+
|
|
72
|
+
@contextmanager
|
|
73
|
+
def operation(self) -> Iterator[ProfileSnapshot]:
|
|
74
|
+
snapshot = self.acquire_operation()
|
|
75
|
+
try:
|
|
76
|
+
yield snapshot
|
|
77
|
+
finally:
|
|
78
|
+
self.release_operation()
|
|
79
|
+
|
|
80
|
+
def transition(
|
|
81
|
+
self,
|
|
82
|
+
target_profile: str,
|
|
83
|
+
commit: Callable[[ProfileSnapshot, str], None],
|
|
84
|
+
) -> ProfileSnapshot:
|
|
85
|
+
"""Drain admitted operations, commit, then publish a new generation."""
|
|
86
|
+
with self._condition:
|
|
87
|
+
while self._transitioning:
|
|
88
|
+
self._condition.wait()
|
|
89
|
+
if target_profile == self._snapshot.profile_id:
|
|
90
|
+
return self._snapshot
|
|
91
|
+
self._transitioning = True
|
|
92
|
+
while self._active_operations:
|
|
93
|
+
self._condition.wait()
|
|
94
|
+
previous = self._snapshot
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
commit(previous, target_profile)
|
|
98
|
+
except BaseException:
|
|
99
|
+
with self._condition:
|
|
100
|
+
self._transitioning = False
|
|
101
|
+
self._condition.notify_all()
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
with self._condition:
|
|
105
|
+
self._snapshot = ProfileSnapshot(
|
|
106
|
+
profile_id=target_profile,
|
|
107
|
+
generation=previous.generation + 1,
|
|
108
|
+
)
|
|
109
|
+
self._transitioning = False
|
|
110
|
+
self._condition.notify_all()
|
|
111
|
+
return self._snapshot
|
|
112
|
+
|
|
113
|
+
def reconfigure(self, commit: Callable[[ProfileSnapshot], None]) -> ProfileSnapshot:
|
|
114
|
+
"""Run a same-profile engine transition behind the operation barrier."""
|
|
115
|
+
with self._condition:
|
|
116
|
+
while self._transitioning:
|
|
117
|
+
self._condition.wait()
|
|
118
|
+
self._transitioning = True
|
|
119
|
+
while self._active_operations:
|
|
120
|
+
self._condition.wait()
|
|
121
|
+
snapshot = self._snapshot
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
commit(snapshot)
|
|
125
|
+
except BaseException:
|
|
126
|
+
with self._condition:
|
|
127
|
+
self._transitioning = False
|
|
128
|
+
self._condition.notify_all()
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
with self._condition:
|
|
132
|
+
self._transitioning = False
|
|
133
|
+
self._condition.notify_all()
|
|
134
|
+
return self._snapshot
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class ActiveProfilePersistence:
|
|
139
|
+
"""Rollback handle for the two compatibility configuration stores."""
|
|
140
|
+
|
|
141
|
+
previous: dict[Path, bytes | None]
|
|
142
|
+
_rolled_back: bool = False
|
|
143
|
+
|
|
144
|
+
def rollback(self) -> None:
|
|
145
|
+
if self._rolled_back:
|
|
146
|
+
return
|
|
147
|
+
for path, content in self.previous.items():
|
|
148
|
+
if content is None:
|
|
149
|
+
path.unlink(missing_ok=True)
|
|
150
|
+
else:
|
|
151
|
+
_atomic_write_bytes(path, content)
|
|
152
|
+
self._rolled_back = True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_json_object(path: Path, *, default: dict) -> dict:
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return dict(default)
|
|
158
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
159
|
+
if not isinstance(value, dict):
|
|
160
|
+
raise ValueError(f"{path.name} must contain a JSON object")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _atomic_write_bytes(path: Path, content: bytes) -> None:
|
|
165
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
fd, temporary = tempfile.mkstemp(
|
|
167
|
+
dir=str(path.parent),
|
|
168
|
+
prefix=f".{path.name}.",
|
|
169
|
+
suffix=".tmp",
|
|
170
|
+
)
|
|
171
|
+
temporary_path = Path(temporary)
|
|
172
|
+
try:
|
|
173
|
+
with os.fdopen(fd, "wb") as handle:
|
|
174
|
+
handle.write(content)
|
|
175
|
+
handle.flush()
|
|
176
|
+
os.fsync(handle.fileno())
|
|
177
|
+
os.replace(temporary_path, path)
|
|
178
|
+
except BaseException:
|
|
179
|
+
temporary_path.unlink(missing_ok=True)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _json_bytes(payload: dict) -> bytes:
|
|
184
|
+
return (json.dumps(payload, indent=2) + "\n").encode("utf-8")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def persist_active_profile(profile_id: str) -> ActiveProfilePersistence:
|
|
188
|
+
"""Atomically update config plus the legacy profiles cache, with rollback."""
|
|
189
|
+
config_path = Path(state_path("config.json"))
|
|
190
|
+
profiles_path = Path(state_path("profiles.json"))
|
|
191
|
+
paths = (config_path, profiles_path)
|
|
192
|
+
previous = {
|
|
193
|
+
path: path.read_bytes() if path.exists() else None
|
|
194
|
+
for path in paths
|
|
195
|
+
}
|
|
196
|
+
config = _load_json_object(config_path, default={})
|
|
197
|
+
profiles = _load_json_object(
|
|
198
|
+
profiles_path,
|
|
199
|
+
default={
|
|
200
|
+
"profiles": {
|
|
201
|
+
"default": {
|
|
202
|
+
"name": "default",
|
|
203
|
+
"description": "Default memory profile",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
config["active_profile"] = profile_id
|
|
209
|
+
profiles["active_profile"] = profile_id
|
|
210
|
+
profile_catalog = profiles.get("profiles")
|
|
211
|
+
if isinstance(profile_catalog, dict):
|
|
212
|
+
selected = profile_catalog.get(profile_id)
|
|
213
|
+
if isinstance(selected, dict):
|
|
214
|
+
selected["last_used"] = datetime.now(timezone.utc).isoformat()
|
|
215
|
+
rollback = ActiveProfilePersistence(previous)
|
|
216
|
+
try:
|
|
217
|
+
_atomic_write_bytes(config_path, _json_bytes(config))
|
|
218
|
+
_atomic_write_bytes(profiles_path, _json_bytes(profiles))
|
|
219
|
+
except BaseException:
|
|
220
|
+
rollback.rollback()
|
|
221
|
+
raise
|
|
222
|
+
return rollback
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_profile_runtime(app_state) -> ProfileRuntime:
|
|
226
|
+
"""Return the daemon runtime, lazily deriving its initial profile."""
|
|
227
|
+
runtime = getattr(app_state, "profile_runtime", None)
|
|
228
|
+
if runtime is not None:
|
|
229
|
+
return runtime
|
|
230
|
+
with _RUNTIME_BIND_LOCK:
|
|
231
|
+
runtime = getattr(app_state, "profile_runtime", None)
|
|
232
|
+
if runtime is not None:
|
|
233
|
+
return runtime
|
|
234
|
+
engine = getattr(app_state, "engine", None)
|
|
235
|
+
config = getattr(app_state, "config", None)
|
|
236
|
+
profile_id = (
|
|
237
|
+
getattr(engine, "profile_id", "")
|
|
238
|
+
or getattr(config, "active_profile", "")
|
|
239
|
+
or "default"
|
|
240
|
+
)
|
|
241
|
+
runtime = ProfileRuntime(str(profile_id))
|
|
242
|
+
app_state.profile_runtime = runtime
|
|
243
|
+
return runtime
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def bind_profile_runtime(app_state, engine, config) -> ProfileRuntime:
|
|
247
|
+
"""Attach the authoritative runtime after daemon engine initialization."""
|
|
248
|
+
runtime = ProfileRuntime(str(engine.profile_id))
|
|
249
|
+
app_state.profile_runtime = runtime
|
|
250
|
+
app_state.engine = engine
|
|
251
|
+
app_state.config = config
|
|
252
|
+
return runtime
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def commit_daemon_profile_switch(
|
|
256
|
+
app_state,
|
|
257
|
+
previous: ProfileSnapshot,
|
|
258
|
+
target_profile: str,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Persist and rebind a quiescent resident engine, rolling back on error."""
|
|
261
|
+
engine = getattr(app_state, "engine", None)
|
|
262
|
+
if engine is None:
|
|
263
|
+
raise RuntimeError("resident engine is unavailable")
|
|
264
|
+
rows = engine._db.execute(
|
|
265
|
+
"SELECT 1 FROM profiles WHERE profile_id = ?",
|
|
266
|
+
(target_profile,),
|
|
267
|
+
)
|
|
268
|
+
if not rows:
|
|
269
|
+
raise RuntimeError(
|
|
270
|
+
f"profile '{target_profile}' no longer exists at commit time"
|
|
271
|
+
)
|
|
272
|
+
app_config = getattr(app_state, "config", None)
|
|
273
|
+
engine_config = getattr(engine, "_config", None)
|
|
274
|
+
persistence = None
|
|
275
|
+
try:
|
|
276
|
+
# Requests are drained and runtime generation is not yet published.
|
|
277
|
+
# Rebind the in-memory engine first, then make compatibility files the
|
|
278
|
+
# final commit step so they can never lead daemon runtime truth.
|
|
279
|
+
engine.profile_id = target_profile
|
|
280
|
+
if app_config is not None:
|
|
281
|
+
app_config.active_profile = target_profile
|
|
282
|
+
if engine_config is not None:
|
|
283
|
+
engine_config.active_profile = target_profile
|
|
284
|
+
persistence = persist_active_profile(target_profile)
|
|
285
|
+
except BaseException:
|
|
286
|
+
engine.profile_id = previous.profile_id
|
|
287
|
+
if app_config is not None:
|
|
288
|
+
app_config.active_profile = previous.profile_id
|
|
289
|
+
if engine_config is not None:
|
|
290
|
+
engine_config.active_profile = previous.profile_id
|
|
291
|
+
if persistence is not None:
|
|
292
|
+
persistence.rollback()
|
|
293
|
+
raise
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def reconfigure_daemon_engine(app_state, new_config, *, mode_change: bool) -> None:
|
|
297
|
+
"""Exclusively rebuild every daemon engine reference for a new config."""
|
|
298
|
+
callback = getattr(app_state, "reconfigure_engine", None)
|
|
299
|
+
if not callable(callback):
|
|
300
|
+
# Direct route/unit usage without a resident daemon retains the
|
|
301
|
+
# established persistence-only behavior.
|
|
302
|
+
new_config.save(mode_change=mode_change)
|
|
303
|
+
return
|
|
304
|
+
runtime = get_profile_runtime(app_state)
|
|
305
|
+
|
|
306
|
+
def _commit(snapshot: ProfileSnapshot) -> None:
|
|
307
|
+
# The candidate may have been loaded before a concurrent profile
|
|
308
|
+
# switch. Runtime truth always wins over that stale config snapshot.
|
|
309
|
+
new_config.active_profile = snapshot.profile_id
|
|
310
|
+
callback(new_config, mode_change=mode_change)
|
|
311
|
+
|
|
312
|
+
runtime.reconfigure(_commit)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ProfileRuntimeMiddleware:
|
|
316
|
+
"""Hold an operation lease for each daemon HTTP request."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, app, *, app_state) -> None:
|
|
319
|
+
self._app = app
|
|
320
|
+
self._app_state = app_state
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _is_transition_request(path: str, method: str) -> bool:
|
|
324
|
+
profile_switch = (
|
|
325
|
+
path.startswith("/api/profiles/")
|
|
326
|
+
and path.endswith("/switch")
|
|
327
|
+
)
|
|
328
|
+
config_transition = method in {"POST", "PUT", "PATCH"} and path in {
|
|
329
|
+
"/api/v3/mode",
|
|
330
|
+
"/api/v3/mode/set",
|
|
331
|
+
"/api/v3/embedding/config",
|
|
332
|
+
"/api/v3/scope/config",
|
|
333
|
+
}
|
|
334
|
+
return profile_switch or config_transition
|
|
335
|
+
|
|
336
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
337
|
+
if scope.get("type") != "http":
|
|
338
|
+
await self._app(scope, receive, send)
|
|
339
|
+
return
|
|
340
|
+
path = str(scope.get("path", ""))
|
|
341
|
+
method = str(scope.get("method", "GET")).upper()
|
|
342
|
+
# Mounted MCP tools proxy profile-sensitive work back through the
|
|
343
|
+
# canonical daemon routes. Leasing the outer MCP request would make an
|
|
344
|
+
# embedded switch wait on itself.
|
|
345
|
+
if path.startswith("/mcp") or self._is_transition_request(path, method):
|
|
346
|
+
await self._app(scope, receive, send)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
import asyncio
|
|
350
|
+
|
|
351
|
+
runtime = get_profile_runtime(self._app_state)
|
|
352
|
+
acquire_task = asyncio.create_task(
|
|
353
|
+
asyncio.to_thread(runtime.acquire_operation)
|
|
354
|
+
)
|
|
355
|
+
try:
|
|
356
|
+
# Shield the worker so cancellation cannot strand a lease that the
|
|
357
|
+
# thread acquires after this coroutine has already unwound.
|
|
358
|
+
snapshot = await asyncio.shield(acquire_task)
|
|
359
|
+
except asyncio.CancelledError:
|
|
360
|
+
await acquire_task
|
|
361
|
+
runtime.release_operation()
|
|
362
|
+
raise
|
|
363
|
+
scope.setdefault("state", {})["profile_snapshot"] = snapshot
|
|
364
|
+
token = _REQUEST_PROFILE.set(snapshot.profile_id)
|
|
365
|
+
try:
|
|
366
|
+
await self._app(scope, receive, send)
|
|
367
|
+
finally:
|
|
368
|
+
_REQUEST_PROFILE.reset(token)
|
|
369
|
+
# Release is lock-only and must not itself be cancellation-prone.
|
|
370
|
+
runtime.release_operation()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
__all__ = [
|
|
374
|
+
"ActiveProfilePersistence",
|
|
375
|
+
"ProfileRuntime",
|
|
376
|
+
"ProfileRuntimeMiddleware",
|
|
377
|
+
"ProfileSnapshot",
|
|
378
|
+
"bind_profile_runtime",
|
|
379
|
+
"commit_daemon_profile_switch",
|
|
380
|
+
"current_request_profile",
|
|
381
|
+
"get_profile_runtime",
|
|
382
|
+
"persist_active_profile",
|
|
383
|
+
"reconfigure_daemon_engine",
|
|
384
|
+
]
|
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import logging
|
|
41
41
|
import threading
|
|
42
|
+
from contextlib import nullcontext
|
|
42
43
|
from dataclasses import dataclass
|
|
43
44
|
|
|
44
45
|
logger = logging.getLogger(__name__)
|
|
@@ -118,7 +119,7 @@ def _heal_embedder(engine, *, log) -> bool:
|
|
|
118
119
|
|
|
119
120
|
|
|
120
121
|
def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
121
|
-
log=logger) -> RecallHealth:
|
|
122
|
+
log=logger, runtime=None) -> RecallHealth:
|
|
122
123
|
"""One monitor tick: re-warm (Tier 1), probe (Tier 2), self-heal (Tier 3).
|
|
123
124
|
|
|
124
125
|
Mutates and returns ``state``. Never raises — a timed-out / failing recall
|
|
@@ -129,7 +130,9 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
129
130
|
# Tier 1: re-warm. A real full-fusion recall keeps the graph page cache hot
|
|
130
131
|
# and the embedder resident.
|
|
131
132
|
try:
|
|
132
|
-
|
|
133
|
+
lease = runtime.operation() if runtime is not None else nullcontext()
|
|
134
|
+
with lease:
|
|
135
|
+
resp = engine.recall(probe, limit=3, fast=False)
|
|
133
136
|
except Exception as exc:
|
|
134
137
|
state.healthy = False
|
|
135
138
|
state.consecutive_failures += 1
|
|
@@ -183,7 +186,7 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
183
186
|
|
|
184
187
|
def health_monitor_loop(engine, *, interval_s: int, stop_event: threading.Event,
|
|
185
188
|
state: RecallHealth, probe: str = DEFAULT_PROBE,
|
|
186
|
-
log=logger) -> None:
|
|
189
|
+
log=logger, runtime=None) -> None:
|
|
187
190
|
"""Background loop. Sleeps ``interval_s`` between ticks; exits promptly when
|
|
188
191
|
``stop_event`` is set. An initial short delay avoids racing boot warmup."""
|
|
189
192
|
# Initial delay (bounded) so we don't pile onto the boot warmup threads.
|
|
@@ -191,7 +194,9 @@ def health_monitor_loop(engine, *, interval_s: int, stop_event: threading.Event,
|
|
|
191
194
|
return
|
|
192
195
|
while not stop_event.is_set():
|
|
193
196
|
try:
|
|
194
|
-
run_health_tick(
|
|
197
|
+
run_health_tick(
|
|
198
|
+
engine, state, probe=probe, log=log, runtime=runtime,
|
|
199
|
+
)
|
|
195
200
|
except Exception as exc: # pragma: no cover - belt & suspenders
|
|
196
201
|
log.warning("recall-health: tick crashed (non-fatal): %s", exc)
|
|
197
202
|
if stop_event.wait(interval_s):
|
|
@@ -204,7 +209,8 @@ _GLOBAL_STATE = RecallHealth()
|
|
|
204
209
|
|
|
205
210
|
|
|
206
211
|
def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
|
|
207
|
-
probe: str = DEFAULT_PROBE, log=None
|
|
212
|
+
probe: str = DEFAULT_PROBE, log=None,
|
|
213
|
+
runtime=None):
|
|
208
214
|
"""Start the monitor as a daemon thread. Returns ``(thread, stop_event,
|
|
209
215
|
state)``. The state is the shared module-level state read by
|
|
210
216
|
:func:`get_recall_health`."""
|
|
@@ -215,7 +221,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
|
|
|
215
221
|
target=health_monitor_loop,
|
|
216
222
|
kwargs=dict(
|
|
217
223
|
engine=engine, interval_s=interval_s, stop_event=stop,
|
|
218
|
-
state=state, probe=probe, log=log,
|
|
224
|
+
state=state, probe=probe, log=log, runtime=runtime,
|
|
219
225
|
),
|
|
220
226
|
daemon=True,
|
|
221
227
|
name="recall-health",
|
|
@@ -76,7 +76,7 @@ async def chat_stream(request: Request):
|
|
|
76
76
|
limit = min(body.get("limit", 10), 20)
|
|
77
77
|
|
|
78
78
|
return StreamingResponse(
|
|
79
|
-
_stream_chat(query, mode, limit),
|
|
79
|
+
_stream_chat(request.app.state, query, mode, limit),
|
|
80
80
|
media_type="text/event-stream",
|
|
81
81
|
headers={
|
|
82
82
|
"Cache-Control": "no-cache",
|
|
@@ -89,15 +89,18 @@ async def chat_stream(request: Request):
|
|
|
89
89
|
# ── Core Chat Logic ──────────────────────────────────────────────
|
|
90
90
|
|
|
91
91
|
async def _stream_chat(
|
|
92
|
-
query: str, mode: str, limit: int,
|
|
92
|
+
app_state, query: str, mode: str, limit: int,
|
|
93
93
|
) -> AsyncGenerator[str, None]:
|
|
94
94
|
"""Retrieve memories, then stream LLM response with citations."""
|
|
95
95
|
|
|
96
|
-
# Step 1: Retrieve memories via
|
|
96
|
+
# Step 1: Retrieve memories via the daemon's resident engine (run in
|
|
97
|
+
# executor to avoid blocking the event loop).
|
|
97
98
|
memories = []
|
|
98
99
|
try:
|
|
99
100
|
loop = asyncio.get_event_loop()
|
|
100
|
-
memories = await loop.run_in_executor(
|
|
101
|
+
memories = await loop.run_in_executor(
|
|
102
|
+
None, _recall_memories, app_state, query, limit,
|
|
103
|
+
)
|
|
101
104
|
except Exception as exc:
|
|
102
105
|
yield _sse_event("error", json.dumps({"message": f"Retrieval failed: {exc}"}))
|
|
103
106
|
yield _sse_event("done", "")
|
|
@@ -314,14 +317,62 @@ async def _stream_openai_compat(
|
|
|
314
317
|
|
|
315
318
|
# ── Retrieval Helper ─────────────────────────────────────────────
|
|
316
319
|
|
|
317
|
-
def _recall_memories(query: str, limit: int) -> list:
|
|
318
|
-
"""Run full recall via
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
320
|
+
def _recall_memories(app_state, query: str, limit: int) -> list:
|
|
321
|
+
"""Run full recall via the daemon's resident, lease-protected engine.
|
|
322
|
+
|
|
323
|
+
v3.7.8 CRITICAL fix: this used to go through ``WorkerPool.shared()``, a
|
|
324
|
+
long-lived subprocess that caches its OWN ``MemoryEngine`` + profile_id
|
|
325
|
+
at process init and is never recycled on a profile switch. That meant a
|
|
326
|
+
switch could serve the OLD profile's memories to this chat endpoint for
|
|
327
|
+
up to 120s — a cross-profile data leak. ``application.state.engine`` is
|
|
328
|
+
rebound synchronously by ``commit_daemon_profile_switch``, so reading it
|
|
329
|
+
(via the queue-consumer's engine adapter, which already holds the same
|
|
330
|
+
profile-runtime lease the daemon's own ``/recall`` route uses) always
|
|
331
|
+
reflects the current profile.
|
|
332
|
+
"""
|
|
333
|
+
adapter = getattr(app_state, "engine_recall_adapter", None)
|
|
334
|
+
if adapter is not None:
|
|
335
|
+
result = adapter.recall(query, limit=limit)
|
|
336
|
+
if result.get("ok"):
|
|
337
|
+
return result.get("results", [])
|
|
338
|
+
return []
|
|
339
|
+
# Fallback (adapter unavailable, e.g. queue-consumer failed to start):
|
|
340
|
+
# recall directly against the resident engine under the same profile
|
|
341
|
+
# lease the HTTP /recall route uses. Never fall back to WorkerPool —
|
|
342
|
+
# that reintroduces the stale-profile leak this fix closes.
|
|
343
|
+
return _recall_via_resident_engine(app_state, query, limit)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _recall_via_resident_engine(app_state, query: str, limit: int) -> list:
|
|
347
|
+
"""Lease-protected direct-engine recall, mirroring the /recall route."""
|
|
348
|
+
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
349
|
+
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
350
|
+
|
|
351
|
+
runtime = get_profile_runtime(app_state)
|
|
352
|
+
with runtime.operation():
|
|
353
|
+
engine = get_engine_lazy(app_state)
|
|
354
|
+
if engine is None:
|
|
355
|
+
return []
|
|
356
|
+
response = engine.recall(query, limit=limit)
|
|
357
|
+
memory_ids = list({
|
|
358
|
+
r.fact.memory_id for r in response.results[:limit]
|
|
359
|
+
if r.fact.memory_id
|
|
360
|
+
})
|
|
361
|
+
memory_map = (
|
|
362
|
+
engine._db.get_memory_content_batch(memory_ids) if memory_ids else {}
|
|
363
|
+
)
|
|
364
|
+
from superlocalmemory.server.recall_serializer import (
|
|
365
|
+
serialize_recall_response,
|
|
366
|
+
)
|
|
367
|
+
_rc = getattr(engine._config, "retrieval", None)
|
|
368
|
+
results, _no_confident_match = serialize_recall_response(
|
|
369
|
+
response,
|
|
370
|
+
limit=limit,
|
|
371
|
+
memory_map=memory_map,
|
|
372
|
+
per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
|
|
373
|
+
total_max=getattr(_rc, "recall_total_max_chars", 12000),
|
|
374
|
+
)
|
|
375
|
+
return results
|
|
325
376
|
|
|
326
377
|
|
|
327
378
|
# ── SSE Formatting ───────────────────────────────────────────────
|
|
@@ -233,7 +233,12 @@ def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
|
|
|
233
233
|
|
|
234
234
|
|
|
235
235
|
def get_active_profile() -> str:
|
|
236
|
-
"""Read
|
|
236
|
+
"""Read request runtime truth, falling back to the compatibility cache."""
|
|
237
|
+
from superlocalmemory.server.profile_runtime import current_request_profile
|
|
238
|
+
|
|
239
|
+
runtime_profile = current_request_profile()
|
|
240
|
+
if runtime_profile:
|
|
241
|
+
return runtime_profile
|
|
237
242
|
config_file = MEMORY_DIR / "profiles.json"
|
|
238
243
|
if config_file.exists():
|
|
239
244
|
try:
|
|
@@ -330,22 +335,10 @@ def sync_profiles() -> list[dict]:
|
|
|
330
335
|
|
|
331
336
|
|
|
332
337
|
def set_active_profile_everywhere(name: str) -> None:
|
|
333
|
-
"""Persist
|
|
334
|
-
|
|
335
|
-
config = _load_profiles_json()
|
|
336
|
-
config['active_profile'] = name
|
|
337
|
-
_save_profiles_json(config)
|
|
338
|
+
"""Persist active profile through the crash-safe compatibility writer."""
|
|
339
|
+
from superlocalmemory.server.profile_runtime import persist_active_profile
|
|
338
340
|
|
|
339
|
-
|
|
340
|
-
config_path = MEMORY_DIR / "config.json"
|
|
341
|
-
cfg = {}
|
|
342
|
-
if config_path.exists():
|
|
343
|
-
try:
|
|
344
|
-
cfg = json.loads(config_path.read_text())
|
|
345
|
-
except (json.JSONDecodeError, IOError):
|
|
346
|
-
pass
|
|
347
|
-
cfg['active_profile'] = name
|
|
348
|
-
config_path.write_text(json.dumps(cfg, indent=2))
|
|
341
|
+
persist_active_profile(name)
|
|
349
342
|
|
|
350
343
|
|
|
351
344
|
def delete_profile_from_db(name: str) -> None:
|
|
@@ -662,14 +662,28 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
|
|
|
662
662
|
if not members:
|
|
663
663
|
raise HTTPException(status_code=404, detail="Cluster not found")
|
|
664
664
|
# Generate cluster summary
|
|
665
|
+
# v3.7.8: previously routed through WorkerPool.shared(), a subprocess
|
|
666
|
+
# cache never recycled on a profile switch (CRITICAL cross-profile
|
|
667
|
+
# leak — up to 120s stale). Use the daemon's own resident,
|
|
668
|
+
# lease-protected engine's config instead; the summarizer only
|
|
669
|
+
# consumes already profile-filtered `texts` above, so this closes
|
|
670
|
+
# the stale-engine window without changing behavior.
|
|
665
671
|
summary = ""
|
|
666
672
|
try:
|
|
667
|
-
from superlocalmemory.core.worker_pool import WorkerPool
|
|
668
|
-
pool = WorkerPool.shared()
|
|
669
673
|
texts = [m.get("content", "")[:200] for m in members[:10] if m.get("content")]
|
|
670
674
|
if texts:
|
|
671
|
-
|
|
672
|
-
|
|
675
|
+
from superlocalmemory.core.summarizer import Summarizer
|
|
676
|
+
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
677
|
+
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
678
|
+
|
|
679
|
+
runtime = get_profile_runtime(request.app.state)
|
|
680
|
+
with runtime.operation():
|
|
681
|
+
engine = get_engine_lazy(request.app.state)
|
|
682
|
+
if engine is not None:
|
|
683
|
+
summarizer = Summarizer(engine._config)
|
|
684
|
+
summary = summarizer.summarize_cluster(
|
|
685
|
+
[{"content": t} for t in texts]
|
|
686
|
+
) or ""
|
|
673
687
|
except Exception:
|
|
674
688
|
pass
|
|
675
689
|
|
|
@@ -687,14 +701,47 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
|
|
|
687
701
|
|
|
688
702
|
@router.get("/api/memories/{memory_id}/facts")
|
|
689
703
|
async def get_memory_facts(request: Request, memory_id: str):
|
|
690
|
-
"""Get original memory text with all its child atomic facts.
|
|
704
|
+
"""Get original memory text with all its child atomic facts.
|
|
705
|
+
|
|
706
|
+
v3.7.8: previously routed through WorkerPool.shared(), a subprocess
|
|
707
|
+
engine cached at process init and never recycled on a profile switch
|
|
708
|
+
(CRITICAL cross-profile leak — served the OLD profile's facts for up to
|
|
709
|
+
120s after a switch). Uses the daemon's own resident, lease-protected
|
|
710
|
+
engine instead, exactly like the /recall route.
|
|
711
|
+
"""
|
|
691
712
|
try:
|
|
692
|
-
from superlocalmemory.
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
713
|
+
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
714
|
+
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
715
|
+
|
|
716
|
+
runtime = get_profile_runtime(request.app.state)
|
|
717
|
+
with runtime.operation():
|
|
718
|
+
engine = get_engine_lazy(request.app.state)
|
|
719
|
+
if engine is None:
|
|
720
|
+
raise HTTPException(status_code=503, detail="Engine not initialized")
|
|
721
|
+
active_profile = engine.profile_id
|
|
722
|
+
mem_map = engine._db.get_memory_content_batch([memory_id])
|
|
723
|
+
original = mem_map.get(memory_id, "")
|
|
724
|
+
facts = engine._db.get_facts_by_memory_id(memory_id, active_profile)
|
|
725
|
+
fact_list = [
|
|
726
|
+
{
|
|
727
|
+
"fact_id": f.fact_id,
|
|
728
|
+
"content": f.content,
|
|
729
|
+
"fact_type": (
|
|
730
|
+
f.fact_type.value if hasattr(f.fact_type, "value")
|
|
731
|
+
else str(f.fact_type)
|
|
732
|
+
),
|
|
733
|
+
"confidence": round(f.confidence, 3),
|
|
734
|
+
"created_at": f.created_at,
|
|
735
|
+
}
|
|
736
|
+
for f in facts
|
|
737
|
+
]
|
|
738
|
+
return {
|
|
739
|
+
"ok": True,
|
|
740
|
+
"memory_id": memory_id,
|
|
741
|
+
"original_content": original,
|
|
742
|
+
"facts": fact_list,
|
|
743
|
+
"fact_count": len(fact_list),
|
|
744
|
+
}
|
|
698
745
|
except HTTPException:
|
|
699
746
|
raise
|
|
700
747
|
except Exception as e:
|