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.
Files changed (44) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin/skills/slm-recall/SKILL.md +4 -3
  7. package/plugin-src/manifest.json +1 -1
  8. package/plugin-src/requirements.txt +1 -1
  9. package/plugin-src/skills/slm-recall/SKILL.md +4 -3
  10. package/pyproject.toml +6 -6
  11. package/src/superlocalmemory/__init__.py +1 -1
  12. package/src/superlocalmemory/cli/commands.py +169 -9
  13. package/src/superlocalmemory/cli/setup_wizard.py +53 -2
  14. package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
  15. package/src/superlocalmemory/core/config.py +1 -1
  16. package/src/superlocalmemory/core/engine.py +3 -2
  17. package/src/superlocalmemory/core/engine_wiring.py +3 -1
  18. package/src/superlocalmemory/core/scale_engine.py +9 -1
  19. package/src/superlocalmemory/core/store_pipeline.py +1 -1
  20. package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
  21. package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -1
  22. package/src/superlocalmemory/infra/auth_middleware.py +5 -5
  23. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  24. package/src/superlocalmemory/mcp/server.py +1 -0
  25. package/src/superlocalmemory/mcp/tools_active.py +11 -7
  26. package/src/superlocalmemory/mcp/tools_core.py +178 -20
  27. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  28. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  29. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  30. package/src/superlocalmemory/retrieval/engine.py +16 -8
  31. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  32. package/src/superlocalmemory/server/recall_health.py +13 -7
  33. package/src/superlocalmemory/server/routes/chat.py +2 -2
  34. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  35. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  36. package/src/superlocalmemory/server/routes/v3_api.py +97 -20
  37. package/src/superlocalmemory/server/unified_daemon.py +261 -60
  38. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  39. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  40. package/src/superlocalmemory/ui/index.html +32 -1
  41. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  42. package/src/superlocalmemory/ui/js/memory-chat.js +2 -2
  43. package/src/superlocalmemory/ui/js/profiles.js +11 -2
  44. 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 6-channel recall path warm,
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
- resp = engine.recall(probe, limit=3, fast=False)
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(engine, state, probe=probe, log=log)
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 → 6-channel retrieval → format context → LLM stream → SSE
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 6-channel retrieval via WorkerPool (synchronous, runs in executor)."""
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 the active profile from profiles.json. Falls back to 'default'."""
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 the active profile to BOTH profiles.json and config.json."""
334
- # profiles.json
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
- # config.json (read by Engine/MCP on startup)
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
- set_active_profile_everywhere, delete_profile_from_db,
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
- json_config = _load_profiles_json()
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
- previous = _load_profiles_json().get('active_profile', 'default')
112
- set_active_profile_everywhere(name)
113
-
114
- # Update last_used in profiles.json
115
- json_config = _load_profiles_json()
116
- if name in json_config.get('profiles', {}):
117
- json_config['profiles'][name]['last_used'] = datetime.now(timezone.utc).isoformat()
118
- _save_profiles_json(json_config)
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
- json_config = _load_profiles_json()
188
- if json_config.get('active_profile') == name:
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",