superlocalmemory 3.4.24 → 3.4.30

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 (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +3 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/cli/commands.py +3 -0
  7. package/src/superlocalmemory/cli/daemon.py +90 -16
  8. package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
  9. package/src/superlocalmemory/cli/main.py +28 -0
  10. package/src/superlocalmemory/cli/post_install.py +15 -0
  11. package/src/superlocalmemory/cli/setup_wizard.py +20 -0
  12. package/src/superlocalmemory/cli/version_banner.py +183 -0
  13. package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
  14. package/src/superlocalmemory/core/clock_monitor.py +45 -0
  15. package/src/superlocalmemory/core/db_pool.py +80 -0
  16. package/src/superlocalmemory/core/engine.py +75 -30
  17. package/src/superlocalmemory/core/engine_capabilities.py +24 -0
  18. package/src/superlocalmemory/core/engine_lock.py +75 -0
  19. package/src/superlocalmemory/core/error_catalog.py +113 -0
  20. package/src/superlocalmemory/core/error_envelope.py +60 -0
  21. package/src/superlocalmemory/core/file_lock.py +92 -0
  22. package/src/superlocalmemory/core/loop_watchdog.py +56 -0
  23. package/src/superlocalmemory/core/priority_queue.py +61 -0
  24. package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
  25. package/src/superlocalmemory/core/rate_limit.py +151 -0
  26. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  27. package/src/superlocalmemory/core/recall_queue.py +370 -0
  28. package/src/superlocalmemory/core/recall_worker.py +10 -0
  29. package/src/superlocalmemory/core/safe_fs.py +108 -0
  30. package/src/superlocalmemory/hooks/auto_capture.py +34 -12
  31. package/src/superlocalmemory/hooks/auto_recall.py +36 -9
  32. package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
  33. package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
  34. package/src/superlocalmemory/mcp/resources.py +8 -5
  35. package/src/superlocalmemory/mcp/server.py +38 -9
  36. package/src/superlocalmemory/mcp/tools_active.py +21 -9
  37. package/src/superlocalmemory/mcp/tools_core.py +13 -9
  38. package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
  39. package/src/superlocalmemory/mcp/tools_learning.py +5 -3
  40. package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
  41. package/src/superlocalmemory/mcp/tools_v3.py +18 -22
  42. package/src/superlocalmemory/mcp/tools_v33.py +65 -2
  43. package/src/superlocalmemory/migrations/__init__.py +5 -0
  44. package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
  45. package/src/superlocalmemory/server/routes/events.py +1 -1
  46. package/src/superlocalmemory/server/routes/v3_api.py +0 -1
  47. package/src/superlocalmemory/server/unified_daemon.py +128 -12
@@ -18,6 +18,8 @@ import logging
18
18
  from pathlib import Path
19
19
  from typing import Any, Callable
20
20
 
21
+ from mcp.types import ToolAnnotations
22
+
21
23
  logger = logging.getLogger(__name__)
22
24
 
23
25
  _DB_PATH = str(Path.home() / ".superlocalmemory" / "memory.db")
@@ -98,7 +100,7 @@ def _record_recall_hits(
98
100
  def register_core_tools(server, get_engine: Callable) -> None:
99
101
  """Register the 13 core MCP tools on *server*."""
100
102
 
101
- @server.tool()
103
+ @server.tool(annotations=ToolAnnotations(idempotentHint=True))
102
104
  async def remember(
103
105
  content: str, tags: str = "", project: str = "",
104
106
  importance: int = 5, session_id: str = "",
@@ -163,7 +165,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
163
165
  logger.exception("remember failed")
164
166
  return {"success": False, "error": str(exc)}
165
167
 
166
- @server.tool()
168
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
167
169
  async def recall(
168
170
  query: str, limit: int = 10, agent_id: str = "mcp_client",
169
171
  session_id: str = "",
@@ -219,7 +221,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
219
221
  pass
220
222
  if not effective_sid:
221
223
  effective_sid = f"mcp:{agent_id}"
222
- # V3.3.19: Run in thread pool to avoid blocking MCP event loop
224
+ # V3.3.19: Run in thread pool to avoid blocking MCP event loop.
225
+ # V3.4.26: WorkerPool now concurrent — parallel calls no longer
226
+ # block behind a single threading.Lock. See worker_pool.py.
223
227
  result = await asyncio.to_thread(
224
228
  pool.recall, query, limit=limit, session_id=effective_sid,
225
229
  )
@@ -248,7 +252,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
248
252
  logger.exception("recall failed")
249
253
  return {"success": False, "error": str(exc)}
250
254
 
251
- @server.tool()
255
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
252
256
  async def search(query: str, limit: int = 10, profile_id: str = "") -> dict:
253
257
  """Full-text search across memories using FTS5 with BM25 ranking."""
254
258
  try:
@@ -269,7 +273,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
269
273
  logger.exception("search failed")
270
274
  return {"success": False, "error": str(exc)}
271
275
 
272
- @server.tool()
276
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
273
277
  async def fetch(fact_ids: str) -> dict:
274
278
  """Fetch full details for specific fact IDs (comma-separated)."""
275
279
  try:
@@ -295,7 +299,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
295
299
  logger.exception("fetch failed")
296
300
  return {"success": False, "error": str(exc)}
297
301
 
298
- @server.tool()
302
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
299
303
  async def list_recent(limit: int = 20, profile_id: str = "") -> dict:
300
304
  """List most recently stored memories, newest first."""
301
305
  try:
@@ -316,7 +320,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
316
320
  logger.exception("list_recent failed")
317
321
  return {"success": False, "error": str(exc)}
318
322
 
319
- @server.tool()
323
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
320
324
  async def get_status() -> dict:
321
325
  """Get memory system status: fact count, entity count, mode, profile, db size."""
322
326
  try:
@@ -473,7 +477,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
473
477
  logger.exception("correct_pattern failed")
474
478
  return {"success": False, "error": str(exc)}
475
479
 
476
- @server.tool()
480
+ @server.tool(annotations=ToolAnnotations(destructiveHint=True))
477
481
  async def delete_memory(fact_id: str, agent_id: str = "mcp_client") -> dict:
478
482
  """Delete a specific memory by exact fact ID.
479
483
 
@@ -505,7 +509,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
505
509
  logger.exception("delete_memory failed")
506
510
  return {"success": False, "error": str(exc)}
507
511
 
508
- @server.tool()
512
+ @server.tool(annotations=ToolAnnotations(idempotentHint=True))
509
513
  async def update_memory(
510
514
  fact_id: str, content: str, agent_id: str = "mcp_client",
511
515
  ) -> dict:
@@ -21,6 +21,8 @@ from datetime import datetime, timezone
21
21
  from pathlib import Path
22
22
  from typing import Callable
23
23
 
24
+ from mcp.types import ToolAnnotations
25
+
24
26
  logger = logging.getLogger(__name__)
25
27
 
26
28
  MEMORY_DB = Path.home() / ".superlocalmemory" / "memory.db"
@@ -125,7 +127,7 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
125
127
  logger.debug("evolve_skill failed: %s", exc)
126
128
  return {"success": False, "error": str(exc)}
127
129
 
128
- @server.tool()
130
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
129
131
  async def skill_health(
130
132
  skill_name: str = "",
131
133
  include_history: bool = False,
@@ -258,7 +260,7 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
258
260
  logger.debug("skill_health failed: %s", exc)
259
261
  return {"skills": [], "skill_count": 0, "error": str(exc)}
260
262
 
261
- @server.tool()
263
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
262
264
  async def skill_lineage(
263
265
  skill_name: str = "",
264
266
  ) -> dict:
@@ -22,6 +22,8 @@ import uuid
22
22
  from datetime import datetime, timezone
23
23
  from typing import Callable
24
24
 
25
+ from mcp.types import ToolAnnotations
26
+
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
29
  _MAX_SUMMARY_LEN = 500 # Truncate input/output summaries
@@ -80,7 +82,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
80
82
  logger.debug("log_tool_event failed: %s", exc)
81
83
  return {"success": False, "error": str(exc)}
82
84
 
83
- @server.tool()
85
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
84
86
  async def get_assertions(
85
87
  min_confidence: float = 0.0,
86
88
  category: str = "",
@@ -131,7 +133,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
131
133
  logger.debug("get_assertions failed: %s", exc)
132
134
  return {"assertions": [], "count": 0, "error": str(exc)}
133
135
 
134
- @server.tool()
136
+ @server.tool(annotations=ToolAnnotations(idempotentHint=True))
135
137
  async def reinforce_assertion(assertion_id: str) -> dict:
136
138
  """Reinforce a behavioral assertion (increase confidence).
137
139
 
@@ -144,7 +146,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
144
146
  engine = get_engine()
145
147
  return _update_assertion_confidence(engine._db, assertion_id, reinforce=True)
146
148
 
147
- @server.tool()
149
+ @server.tool(annotations=ToolAnnotations(idempotentHint=True))
148
150
  async def contradict_assertion(assertion_id: str) -> dict:
149
151
  """Contradict a behavioral assertion (decrease confidence).
150
152
 
@@ -24,6 +24,8 @@ import time
24
24
  import uuid
25
25
  from typing import Callable
26
26
 
27
+ from mcp.types import ToolAnnotations
28
+
27
29
  logger = logging.getLogger(__name__)
28
30
 
29
31
  # Unique peer ID for this MCP server session
@@ -156,7 +158,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
156
158
  "broker_response": result,
157
159
  }
158
160
 
159
- @server.tool()
161
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
160
162
  async def mesh_peers() -> dict:
161
163
  """List all active peer sessions on this machine.
162
164
 
@@ -267,7 +269,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
267
269
  })
268
270
  return result or {"error": "Lock operation failed"}
269
271
 
270
- @server.tool()
272
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
271
273
  async def mesh_events() -> dict:
272
274
  """Get recent mesh events (peer joins, leaves, messages, state changes).
273
275
 
@@ -276,7 +278,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
276
278
  result = _mesh_request("GET", "/events")
277
279
  return result or {"events": []}
278
280
 
279
- @server.tool()
281
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
280
282
  async def mesh_status() -> dict:
281
283
  """Get mesh broker health and statistics.
282
284
 
@@ -273,35 +273,31 @@ def register_v3_tools(server, get_engine: Callable) -> None:
273
273
  limit: Maximum results (default 10).
274
274
  """
275
275
  try:
276
- engine = get_engine()
277
- response = engine.recall(query, limit=limit)
276
+ from superlocalmemory.core.worker_pool import WorkerPool
277
+ raw = WorkerPool.shared().recall(query=query, limit=limit)
278
+ items = raw.get("results", []) if isinstance(raw, dict) else []
278
279
  results = []
279
- for r in response.results[:limit]:
280
+ for item in items[:limit]:
280
281
  results.append({
281
- "fact_id": r.fact.fact_id,
282
- "content": r.fact.content,
283
- "final_score": round(r.score, 4),
284
- "confidence": round(r.confidence, 3),
285
- "trust_score": round(r.trust_score, 3),
286
- "channel_scores": {
287
- k: round(v, 4) for k, v in r.channel_scores.items()
288
- },
289
- "evidence_chain": r.evidence_chain,
290
- "fact_type": r.fact.fact_type.value,
291
- "lifecycle": r.fact.lifecycle.value,
292
- "access_count": r.fact.access_count,
282
+ "fact_id": item.get("fact_id", ""),
283
+ "content": item.get("content", ""),
284
+ "final_score": round(float(item.get("score", 0.0)), 4),
285
+ "confidence": round(float(item.get("confidence", 0.0)), 3),
286
+ "trust_score": round(float(item.get("trust_score", 0.0)), 3),
287
+ "channel_scores": item.get("channel_scores", {}) or {},
288
+ "evidence_chain": item.get("evidence_chain", []) or [],
289
+ "fact_type": item.get("fact_type", ""),
290
+ "lifecycle": item.get("lifecycle", ""),
291
+ "access_count": int(item.get("access_count", 0)),
293
292
  })
294
293
  return {
295
294
  "success": True,
296
295
  "results": results,
297
296
  "count": len(results),
298
- "query_type": response.query_type,
299
- "channel_weights": {
300
- k: round(v, 3)
301
- for k, v in response.channel_weights.items()
302
- },
303
- "total_candidates": response.total_candidates,
304
- "retrieval_time_ms": round(response.retrieval_time_ms, 1),
297
+ "query_type": raw.get("query_type", "") if isinstance(raw, dict) else "",
298
+ "channel_weights": raw.get("channel_weights", {}) if isinstance(raw, dict) else {},
299
+ "total_candidates": raw.get("total_candidates", 0) if isinstance(raw, dict) else 0,
300
+ "retrieval_time_ms": round(float(raw.get("retrieval_time_ms", 0.0)) if isinstance(raw, dict) else 0.0, 1),
305
301
  }
306
302
  except Exception as exc:
307
303
  logger.exception("recall_trace failed")
@@ -20,12 +20,43 @@ import logging
20
20
  from pathlib import Path
21
21
  from typing import Callable
22
22
 
23
+ from mcp.types import ToolAnnotations
24
+
23
25
  logger = logging.getLogger(__name__)
24
26
 
25
27
  MEMORY_DIR = Path.home() / ".superlocalmemory"
26
28
  DB_PATH = MEMORY_DIR / "memory.db"
27
29
 
28
30
 
31
+ def _try_daemon_post(path: str, body: dict, timeout_s: float = 60.0) -> dict | None:
32
+ """POST to the daemon, return parsed dict or None if daemon unavailable.
33
+
34
+ Keeps heavy imports (CognitiveConsolidator, ForgettingScheduler,
35
+ ConsolidationWorker) out of the MCP process when a daemon is up.
36
+ Returns None on any failure so the caller can fall back to local
37
+ execution.
38
+ """
39
+ try:
40
+ from superlocalmemory.cli.daemon import _get_port, is_daemon_running
41
+ if not is_daemon_running():
42
+ return None
43
+ import json as _json
44
+ import urllib.request as _urq
45
+ port = _get_port()
46
+ req = _urq.Request(
47
+ f"http://127.0.0.1:{port}{path}",
48
+ data=_json.dumps(body).encode(),
49
+ headers={"Content-Type": "application/json"},
50
+ method="POST",
51
+ )
52
+ with _urq.urlopen(req, timeout=timeout_s) as resp:
53
+ raw = resp.read().decode() or "{}"
54
+ return _json.loads(raw)
55
+ except Exception as exc:
56
+ logger.warning("daemon POST %s failed: %s", path, exc)
57
+ return None
58
+
59
+
29
60
  def _emit_event(event_type: str, payload: dict | None = None,
30
61
  source_agent: str = "mcp_client") -> None: # V3.3.12: see also mcp/shared.py
31
62
  """Emit an event to the EventBus (best-effort, never raises)."""
@@ -44,7 +75,7 @@ def register_v33_tools(server, get_engine: Callable) -> None:
44
75
  # ------------------------------------------------------------------
45
76
  # 1. forget — Ebbinghaus forgetting decay cycle
46
77
  # ------------------------------------------------------------------
47
- @server.tool()
78
+ @server.tool(annotations=ToolAnnotations(destructiveHint=True))
48
79
  async def forget(
49
80
  profile_id: str = "",
50
81
  dry_run: bool = True,
@@ -188,6 +219,26 @@ def register_v33_tools(server, get_engine: Callable) -> None:
188
219
  engine = get_engine()
189
220
  pid = profile_id or engine.profile_id
190
221
 
222
+ # v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
223
+ # so the heavy CognitiveConsolidator import stays out of the
224
+ # MCP process. Fall back to local import only if no daemon.
225
+ daemon_result = _try_daemon_post(
226
+ "/consolidate/cognitive", {"profile_id": pid},
227
+ )
228
+ if daemon_result is not None:
229
+ _emit_event("ccq.consolidation_complete", {
230
+ "profile_id": pid,
231
+ "clusters_processed": daemon_result.get("clusters_processed", 0),
232
+ "blocks_created": daemon_result.get("blocks_created", 0),
233
+ })
234
+ return {
235
+ "success": True,
236
+ "clusters_processed": daemon_result.get("clusters_processed", 0),
237
+ "blocks_created": daemon_result.get("blocks_created", 0),
238
+ "profile_id": pid,
239
+ "via": "daemon",
240
+ }
241
+
191
242
  from superlocalmemory.encoding.cognitive_consolidator import (
192
243
  CognitiveConsolidator,
193
244
  )
@@ -216,7 +267,7 @@ def register_v33_tools(server, get_engine: Callable) -> None:
216
267
  # ------------------------------------------------------------------
217
268
  # 4. get_soft_prompts — Retrieve active soft prompts
218
269
  # ------------------------------------------------------------------
219
- @server.tool()
270
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
220
271
  async def get_soft_prompts(
221
272
  profile_id: str = "",
222
273
  ) -> dict:
@@ -379,6 +430,18 @@ def register_v33_tools(server, get_engine: Callable) -> None:
379
430
  try:
380
431
  engine = get_engine()
381
432
  pid = profile_id or engine.profile_id
433
+
434
+ # v3.4.26: prefer the daemon so ForgettingScheduler /
435
+ # ConsolidationWorker / EbbinghausCurve don't load inside
436
+ # the MCP process.
437
+ daemon_result = _try_daemon_post(
438
+ "/maintenance/run", {"profile_id": pid},
439
+ )
440
+ if daemon_result is not None:
441
+ daemon_result.setdefault("success", True)
442
+ daemon_result["via"] = "daemon"
443
+ return daemon_result
444
+
382
445
  results = {}
383
446
 
384
447
  # 1. Langevin dynamics step (lifecycle evolution)
@@ -0,0 +1,5 @@
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
+ """Schema and data migrations."""
@@ -0,0 +1,144 @@
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
+ """Idempotent migration from v3.4.25 to v3.4.26 data layout.
6
+
7
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _daemon_running() -> bool:
19
+ """Return True if an SLM daemon is holding the data dir.
20
+
21
+ Defined as a module-level indirection so tests can monkey-patch it
22
+ without reaching into ``cli.daemon``.
23
+ """
24
+ from superlocalmemory.cli.daemon import is_daemon_running
25
+ return bool(is_daemon_running())
26
+
27
+
28
+ def migrate_if_safe(data_dir: Path) -> dict[str, object]:
29
+ """Run :func:`migrate` only when it's safe to touch the data dir.
30
+
31
+ Safety guarantees:
32
+
33
+ 1. A live daemon means defer — the daemon is the authoritative DB
34
+ holder and will apply the migration on its next start.
35
+ 2. If the daemon probe itself fails, err on the safe side and defer.
36
+ 3. Concurrent CLI + daemon-start + npm-postinstall invocations on
37
+ the same data dir serialize through a file lock, so no two
38
+ processes can race into ``migrate()`` at the same moment.
39
+
40
+ Returns a dict with a ``status`` in
41
+ ``{applied, already_applied, deferred}``.
42
+ """
43
+ data_dir = Path(data_dir)
44
+
45
+ if is_ready(data_dir):
46
+ return {"status": "already_applied", "data_dir": str(data_dir)}
47
+
48
+ try:
49
+ daemon_up = _daemon_running()
50
+ except Exception as exc:
51
+ logger.warning(
52
+ "daemon probe failed, deferring migrate_if_safe: %s", exc,
53
+ )
54
+ daemon_up = True
55
+
56
+ if daemon_up:
57
+ return {
58
+ "status": "deferred",
59
+ "data_dir": str(data_dir),
60
+ "reason": "daemon is running — migration will apply on next daemon start",
61
+ }
62
+
63
+ # Serialize concurrent CLI / postinstall / daemon-boot callers.
64
+ data_dir.mkdir(parents=True, exist_ok=True)
65
+ lock_path = data_dir / ".migrate-v3.4.26.lock"
66
+ lock_fd = None
67
+ try:
68
+ import os
69
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
70
+ import sys
71
+ if sys.platform == "win32":
72
+ try:
73
+ import msvcrt
74
+ msvcrt.locking(lock_fd, msvcrt.LK_LOCK, 1)
75
+ except (IOError, OSError):
76
+ # Another process is mid-migrate — they will apply it.
77
+ return {
78
+ "status": "deferred",
79
+ "data_dir": str(data_dir),
80
+ "reason": "concurrent migrate_if_safe in progress",
81
+ }
82
+ else:
83
+ try:
84
+ import fcntl
85
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
86
+ except (IOError, OSError):
87
+ return {
88
+ "status": "deferred",
89
+ "data_dir": str(data_dir),
90
+ "reason": "concurrent migrate_if_safe in progress",
91
+ }
92
+
93
+ # Re-check the sentinel now that we own the lock — another
94
+ # process may have applied the migration while we waited.
95
+ if is_ready(data_dir):
96
+ return {"status": "already_applied", "data_dir": str(data_dir)}
97
+
98
+ result = migrate(data_dir)
99
+ result["status"] = "applied"
100
+ return result
101
+ finally:
102
+ if lock_fd is not None:
103
+ try:
104
+ import os as _os
105
+ _os.close(lock_fd)
106
+ except OSError:
107
+ pass
108
+
109
+
110
+ def migrate(data_dir: Path) -> dict[str, object]:
111
+ """Prepare v3.4.26 data directory. Safe to run any number of times.
112
+
113
+ memory.db is untouched; the migration only provisions the new
114
+ recall_queue.db and marks readiness with a sentinel file.
115
+ """
116
+ result: dict[str, object] = {
117
+ "data_dir": str(data_dir),
118
+ "created": [],
119
+ "already_present": [],
120
+ }
121
+ data_dir = Path(data_dir)
122
+ data_dir.mkdir(parents=True, exist_ok=True)
123
+
124
+ from superlocalmemory.core.recall_queue import RecallQueue
125
+
126
+ queue_path = data_dir / "recall_queue.db"
127
+ if queue_path.exists():
128
+ result["already_present"].append(str(queue_path))
129
+ else:
130
+ result["created"].append(str(queue_path))
131
+ q = RecallQueue(db_path=queue_path)
132
+ q.close()
133
+
134
+ marker = data_dir / ".slm-v3.4.26-ready"
135
+ if marker.exists():
136
+ result["already_present"].append(str(marker))
137
+ else:
138
+ marker.write_text("3.4.26\n", encoding="utf-8")
139
+ result["created"].append(str(marker))
140
+ return result
141
+
142
+
143
+ def is_ready(data_dir: Path) -> bool:
144
+ return (Path(data_dir) / ".slm-v3.4.26-ready").exists()
@@ -44,7 +44,7 @@ def _event_to_sse_bridge(event: dict):
44
44
  q.put_nowait(event)
45
45
  except _queue.Full:
46
46
  dead_queues.add(q)
47
- _sse_queues -= dead_queues
47
+ _sse_queues.difference_update(dead_queues)
48
48
 
49
49
 
50
50
  def register_event_listener():
@@ -558,7 +558,6 @@ async def recall_trace(request: Request):
558
558
 
559
559
  def _record_learning_signals(query: str, results: list) -> None:
560
560
  """Record feedback + co-retrieval + confidence boost for any recall."""
561
- from pathlib import Path
562
561
  from superlocalmemory.core.config import SLMConfig
563
562
 
564
563
  slm_dir = Path.home() / ".superlocalmemory"