superlocalmemory 3.7.5 → 3.7.7
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 +27 -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/skills/slm-recall/SKILL.md +4 -3
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +4 -3
- package/pyproject.toml +6 -6
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +169 -9
- package/src/superlocalmemory/cli/setup_wizard.py +53 -2
- package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
- package/src/superlocalmemory/core/config.py +1 -1
- package/src/superlocalmemory/core/engine.py +3 -2
- package/src/superlocalmemory/core/engine_wiring.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +9 -1
- package/src/superlocalmemory/core/store_pipeline.py +1 -1
- package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -1
- package/src/superlocalmemory/infra/auth_middleware.py +5 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_active.py +11 -7
- package/src/superlocalmemory/mcp/tools_core.py +178 -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/retrieval/engine.py +16 -8
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +13 -7
- package/src/superlocalmemory/server/routes/chat.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +97 -20
- package/src/superlocalmemory/server/unified_daemon.py +261 -60
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- package/src/superlocalmemory/ui/js/memory-chat.js +2 -2
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +42 -6
|
@@ -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
|
+
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Runtime recall-health monitor — keep the
|
|
1
|
+
"""Runtime recall-health monitor — keep the full recall path warm,
|
|
2
2
|
detect a "warm-but-broken" embedder at runtime, and self-heal it (v3.6.8).
|
|
3
3
|
|
|
4
4
|
Why this exists
|
|
@@ -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",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
"""Ask My Memory — SSE chat endpoint.
|
|
6
6
|
|
|
7
|
-
Flow: query →
|
|
7
|
+
Flow: query → full recall (five-producer fusion) → format context → LLM stream → SSE
|
|
8
8
|
Mode A: No LLM, returns formatted retrieval results.
|
|
9
9
|
Mode B: Ollama local streaming via /api/chat.
|
|
10
10
|
Mode C: Cloud LLM streaming (OpenAI-compatible).
|
|
@@ -315,7 +315,7 @@ async def _stream_openai_compat(
|
|
|
315
315
|
# ── Retrieval Helper ─────────────────────────────────────────────
|
|
316
316
|
|
|
317
317
|
def _recall_memories(query: str, limit: int) -> list:
|
|
318
|
-
"""Run
|
|
318
|
+
"""Run full recall via WorkerPool (synchronous, runs in executor)."""
|
|
319
319
|
from superlocalmemory.core.worker_pool import WorkerPool
|
|
320
320
|
pool = WorkerPool.shared()
|
|
321
321
|
result = pool.recall(query, limit=limit)
|
|
@@ -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:
|
|
@@ -10,6 +10,7 @@ Routes: /api/profiles, /api/profiles/{name}/switch,
|
|
|
10
10
|
SQLite is the single source of truth for profiles. profiles.json
|
|
11
11
|
is kept in sync as a cache for backward compatibility.
|
|
12
12
|
"""
|
|
13
|
+
import asyncio
|
|
13
14
|
import logging
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
|
|
@@ -21,9 +22,13 @@ from .helpers import (
|
|
|
21
22
|
get_db_connection, validate_profile_name,
|
|
22
23
|
ProfileSwitch, DB_PATH,
|
|
23
24
|
sync_profiles, ensure_profile_in_db, ensure_profile_in_json,
|
|
24
|
-
|
|
25
|
+
delete_profile_from_db,
|
|
25
26
|
_load_profiles_json, _save_profiles_json,
|
|
26
27
|
)
|
|
28
|
+
from superlocalmemory.server.profile_runtime import (
|
|
29
|
+
commit_daemon_profile_switch,
|
|
30
|
+
get_profile_runtime,
|
|
31
|
+
)
|
|
27
32
|
|
|
28
33
|
logger = logging.getLogger("superlocalmemory.routes.profiles")
|
|
29
34
|
router = APIRouter()
|
|
@@ -54,12 +59,11 @@ def _get_memory_count(profile: str) -> int:
|
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
@router.get("/api/profiles")
|
|
57
|
-
async def list_profiles():
|
|
62
|
+
async def list_profiles(request: Request):
|
|
58
63
|
"""List available memory profiles (synced from SQLite + profiles.json)."""
|
|
59
64
|
try:
|
|
60
65
|
merged = sync_profiles()
|
|
61
|
-
|
|
62
|
-
active = json_config.get('active_profile', 'default')
|
|
66
|
+
active = get_profile_runtime(request.app.state).snapshot.profile_id
|
|
63
67
|
|
|
64
68
|
profiles = []
|
|
65
69
|
for p in merged:
|
|
@@ -108,14 +112,17 @@ async def switch_profile(name: str, request: Request):
|
|
|
108
112
|
source_agent_id="http-profile-switch",
|
|
109
113
|
profile_id=name,
|
|
110
114
|
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
runtime = get_profile_runtime(request.app.state)
|
|
116
|
+
previous = runtime.snapshot.profile_id
|
|
117
|
+
snapshot = await asyncio.to_thread(
|
|
118
|
+
runtime.transition,
|
|
119
|
+
name,
|
|
120
|
+
lambda prior, target: commit_daemon_profile_switch(
|
|
121
|
+
request.app.state,
|
|
122
|
+
prior,
|
|
123
|
+
target,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
119
126
|
|
|
120
127
|
count = _get_memory_count(name)
|
|
121
128
|
|
|
@@ -130,6 +137,7 @@ async def switch_profile(name: str, request: Request):
|
|
|
130
137
|
return {
|
|
131
138
|
"success": True, "active_profile": name,
|
|
132
139
|
"previous_profile": previous, "memory_count": count,
|
|
140
|
+
"generation": snapshot.generation,
|
|
133
141
|
"message": f"Switched to profile '{name}' ({count} memories).",
|
|
134
142
|
}
|
|
135
143
|
|
|
@@ -184,10 +192,12 @@ async def delete_profile(name: str, request: Request):
|
|
|
184
192
|
if name not in merged_ids:
|
|
185
193
|
raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
|
|
186
194
|
|
|
187
|
-
|
|
188
|
-
if
|
|
195
|
+
runtime = get_profile_runtime(request.app.state)
|
|
196
|
+
if runtime.snapshot.profile_id == name:
|
|
189
197
|
raise HTTPException(status_code=400, detail="Cannot delete active profile.")
|
|
190
198
|
|
|
199
|
+
json_config = _load_profiles_json()
|
|
200
|
+
|
|
191
201
|
authorization = authorize_route_mutation(
|
|
192
202
|
request,
|
|
193
203
|
operation="delete",
|