superlocalmemory 3.8.5 → 3.8.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 (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
  35. package/src/superlocalmemory/core/component_registry.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +33 -6
  37. package/src/superlocalmemory/core/engine.py +94 -49
  38. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  39. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  40. package/src/superlocalmemory/core/mutations.py +32 -10
  41. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  42. package/src/superlocalmemory/core/remember_admission.py +152 -0
  43. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  44. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  45. package/src/superlocalmemory/learning/bandit.py +50 -1
  46. package/src/superlocalmemory/learning/source_quality.py +38 -35
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  48. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  49. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  50. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  51. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  52. package/src/superlocalmemory/retrieval/engine.py +8 -3
  53. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  54. package/src/superlocalmemory/server/loopback.py +7 -13
  55. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  56. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  57. package/src/superlocalmemory/server/routes/agents.py +3 -5
  58. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  59. package/src/superlocalmemory/server/routes/brain.py +6 -9
  60. package/src/superlocalmemory/server/routes/entity.py +3 -7
  61. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  62. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  63. package/src/superlocalmemory/server/routes/insights.py +2 -4
  64. package/src/superlocalmemory/server/routes/learning.py +2 -5
  65. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  66. package/src/superlocalmemory/server/routes/memories.py +122 -100
  67. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  68. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  69. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  70. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  71. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  72. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  73. package/src/superlocalmemory/storage/database.py +59 -0
  74. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  75. package/src/superlocalmemory/storage/memory_write.py +8 -12
  76. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  77. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  78. package/src/superlocalmemory/storage/read_connection.py +115 -0
  79. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  80. package/src/superlocalmemory/ui/index.html +1 -1
  81. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  82. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -75,12 +75,12 @@ class _CozoResult:
75
75
 
76
76
 
77
77
  class _CozoClientAdapter:
78
- """Bridge PyCozo 0.3 embedded bindings and later client conveniences.
78
+ """Bridge legacy PyCozo responses and the store-compatible client surface.
79
79
 
80
- PyCozo 0.3 is the last client compatible with the published macOS native
81
- binding. It returns dictionaries and exposes ``import_relations`` rather
82
- than ``put``; later clients return dataframe-like values and add ``put``.
83
- SLM only needs relation upserts and row results, so normalize those here.
80
+ SLM graph stores were established with PyCozo 0.3.0. That client returns
81
+ dictionaries and exposes ``import_relations`` rather than ``put``; later
82
+ clients return dataframe-like values and add ``put``. SLM only needs
83
+ relation upserts and row results, so normalize both forms here.
84
84
  """
85
85
 
86
86
  def __init__(self, client: Any) -> None:
@@ -29,7 +29,6 @@ import os
29
29
  import secrets
30
30
  import sqlite3
31
31
  import threading
32
- import time
33
32
  from dataclasses import dataclass, field
34
33
  from datetime import datetime, timedelta, timezone
35
34
  from pathlib import Path
@@ -262,6 +261,33 @@ class ContextualBandit:
262
261
  play_id=play_id,
263
262
  )
264
263
 
264
+ def choose_readonly(self, context: dict[str, Any]) -> BanditChoice:
265
+ """Sample an arm from a read-only snapshot without recording a play.
266
+
267
+ Recall uses this method so the established bandit weighting and
268
+ ensemble quality path remain available without turning a query into a
269
+ ``bandit_plays`` write. It deliberately bypasses the writer-oriented
270
+ thread-local connection factory: that factory configures WAL mode and
271
+ is not a physical read-only guarantee.
272
+ """
273
+ stratum = compute_stratum(context)
274
+ try:
275
+ posteriors = self._load_stratum_posteriors_readonly(stratum)
276
+ except sqlite3.Error as exc:
277
+ logger.warning(
278
+ "bandit.choose_readonly: posterior load failed stratum=%s: %s",
279
+ stratum,
280
+ exc,
281
+ )
282
+ posteriors = {}
283
+ arm_id = self._sample_best(posteriors)
284
+ return BanditChoice(
285
+ stratum=stratum,
286
+ arm_id=arm_id,
287
+ weights=dict(self._catalog[arm_id]),
288
+ play_id=None,
289
+ )
290
+
265
291
  def _sample_best(
266
292
  self,
267
293
  posteriors: dict[str, tuple[float, float]],
@@ -438,6 +464,29 @@ class ContextualBandit:
438
464
  for r in rows
439
465
  }
440
466
 
467
+ def _load_stratum_posteriors_readonly(
468
+ self,
469
+ stratum: str,
470
+ ) -> dict[str, tuple[float, float]]:
471
+ """Read posteriors through SQLite's URI read-only boundary."""
472
+ uri = f"{self._db_path.resolve().as_uri()}?mode=ro"
473
+ conn = sqlite3.connect(uri, uri=True, timeout=0.25)
474
+ try:
475
+ conn.row_factory = sqlite3.Row
476
+ conn.execute("PRAGMA query_only=ON")
477
+ conn.execute("PRAGMA busy_timeout=250")
478
+ rows = conn.execute(
479
+ "SELECT arm_id, alpha, beta FROM bandit_arms "
480
+ "WHERE profile_id = ? AND stratum = ?",
481
+ (self._profile, stratum),
482
+ ).fetchall()
483
+ finally:
484
+ conn.close()
485
+ return {
486
+ row["arm_id"]: (float(row["alpha"]), float(row["beta"]))
487
+ for row in rows
488
+ }
489
+
441
490
  def _insert_play(
442
491
  self,
443
492
  query_id: str,
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS source_quality_repair_state (
81
81
  _MAX_FACTS_PER_OUTCOME = 100
82
82
  _MAX_SOURCES_PER_OUTCOME = 100
83
83
  _PROVENANCE_QUERY_CHUNK = 500
84
+ _SCHEMA_INIT_LOCK = threading.RLock()
84
85
 
85
86
 
86
87
  class SourceQualityRepairUnavailable(RuntimeError):
@@ -114,45 +115,47 @@ class SourceQualityScorer:
114
115
  # ------------------------------------------------------------------
115
116
 
116
117
  def _ensure_schema(self) -> None:
117
- conn = self._connect()
118
- try:
119
- # Separate scorer instances can be constructed concurrently during
120
- # first startup (background history repair + outcome settlement).
121
- # Serialize the read/ALTER sequence at SQLite's transaction
122
- # boundary so two processes cannot both observe a legacy column as
123
- # missing and race into ``duplicate column name``.
124
- conn.execute("BEGIN IMMEDIATE")
125
- conn.execute(_CREATE_TABLE)
126
- conn.execute(_CREATE_UNIQUE)
127
- conn.execute(_CREATE_OBSERVATIONS)
128
- conn.execute(_CREATE_REPAIR_STATE)
129
- repair_columns = {
130
- str(row["name"])
131
- for row in conn.execute(
132
- "PRAGMA table_info(source_quality_repair_state)"
133
- ).fetchall()
134
- }
135
- if "last_settled_at" not in repair_columns:
136
- conn.execute(
137
- "ALTER TABLE source_quality_repair_state "
138
- "ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
139
- )
140
- if "last_outcome_id" not in repair_columns:
141
- conn.execute(
142
- "ALTER TABLE source_quality_repair_state "
143
- "ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
144
- )
145
- conn.commit()
146
- except Exception:
147
- conn.rollback()
148
- raise
149
- finally:
150
- conn.close()
118
+ # Serialize scorer instances in this process before asking SQLite for
119
+ # its cross-process BEGIN IMMEDIATE lease. This prevents two startup
120
+ # threads from racing the journal-mode/schema bootstrap while SQLite's
121
+ # busy timeout protects the equivalent multi-process boundary.
122
+ with _SCHEMA_INIT_LOCK:
123
+ conn = self._connect()
124
+ try:
125
+ conn.execute("BEGIN IMMEDIATE")
126
+ conn.execute(_CREATE_TABLE)
127
+ conn.execute(_CREATE_UNIQUE)
128
+ conn.execute(_CREATE_OBSERVATIONS)
129
+ conn.execute(_CREATE_REPAIR_STATE)
130
+ repair_columns = {
131
+ str(row["name"])
132
+ for row in conn.execute(
133
+ "PRAGMA table_info(source_quality_repair_state)"
134
+ ).fetchall()
135
+ }
136
+ if "last_settled_at" not in repair_columns:
137
+ conn.execute(
138
+ "ALTER TABLE source_quality_repair_state "
139
+ "ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
140
+ )
141
+ if "last_outcome_id" not in repair_columns:
142
+ conn.execute(
143
+ "ALTER TABLE source_quality_repair_state "
144
+ "ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
145
+ )
146
+ conn.commit()
147
+ except Exception:
148
+ conn.rollback()
149
+ raise
150
+ finally:
151
+ conn.close()
151
152
 
152
153
  def _connect(self) -> sqlite3.Connection:
153
154
  conn = sqlite3.connect(str(self._db_path), timeout=10)
155
+ # Install the busy handler before journal negotiation. On a fresh
156
+ # database, PRAGMA journal_mode itself may contend with another scorer.
157
+ conn.execute("PRAGMA busy_timeout=10000")
154
158
  conn.execute("PRAGMA journal_mode=WAL")
155
- conn.execute("PRAGMA busy_timeout=5000")
156
159
  conn.row_factory = sqlite3.Row
157
160
  return conn
158
161
 
@@ -34,9 +34,26 @@ class DaemonPoolProxy:
34
34
  envelopes — the adapter is responsible for surfacing those.
35
35
  """
36
36
 
37
- def __init__(self, port: int, *, timeout_s: float = 30.0) -> None: # v3.4.59: 8s→30s — observed recall takes 13.4s on dense graph (2.1M edges); 8s always timed out → degraded mode
37
+ def __init__(
38
+ self,
39
+ port: int | None,
40
+ *,
41
+ timeout_s: float = 30.0,
42
+ unavailable: bool = False,
43
+ ) -> None:
44
+ # v3.4.59: 8s→30s — dense graph recall can exceed the old timeout.
38
45
  self._port = port
39
46
  self._timeout = timeout_s
47
+ self._unavailable = unavailable
48
+
49
+ @staticmethod
50
+ def _unavailable_response() -> dict[str, Any]:
51
+ return {
52
+ "ok": False,
53
+ "code": "DAEMON_UNAVAILABLE",
54
+ "retryable": True,
55
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
56
+ }
40
57
 
41
58
  def recall(
42
59
  self, query: str, limit: int = 10, session_id: str = "",
@@ -45,6 +62,8 @@ class DaemonPoolProxy:
45
62
  include_shared: bool | None = None,
46
63
  window: str | None = None,
47
64
  ) -> dict[str, Any]:
65
+ if self._unavailable:
66
+ return self._unavailable_response()
48
67
  _params: dict[str, Any] = {
49
68
  "q": query,
50
69
  "limit": limit,
@@ -75,15 +94,17 @@ class DaemonPoolProxy:
75
94
  )
76
95
  except Exception as exc:
77
96
  logger.warning("daemon /recall failed: %s", exc)
78
- return {"ok": False, "error": str(exc)}
97
+ return self._unavailable_response()
79
98
  if not isinstance(data, dict):
80
- return {"ok": False, "error": "owned daemon unavailable"}
99
+ return self._unavailable_response()
81
100
  data.setdefault("ok", True)
82
101
  return data
83
102
 
84
103
  def store(
85
104
  self, content: str, metadata: dict | None = None,
86
105
  ) -> dict[str, Any]:
106
+ if self._unavailable:
107
+ return self._unavailable_response()
87
108
  body = {
88
109
  "content": content,
89
110
  "tags": (metadata or {}).get("tags", ""),
@@ -101,9 +122,9 @@ class DaemonPoolProxy:
101
122
  data = daemon_request("POST", "/remember", body)
102
123
  except Exception as exc:
103
124
  logger.warning("daemon /remember failed: %s", exc)
104
- return {"ok": False, "error": str(exc)}
125
+ return self._unavailable_response()
105
126
  if not isinstance(data, dict):
106
- return {"ok": False, "error": "owned daemon unavailable"}
127
+ return self._unavailable_response()
107
128
  data.setdefault("ok", True)
108
129
  return data
109
130
 
@@ -111,17 +132,19 @@ class DaemonPoolProxy:
111
132
  def choose_pool() -> Any:
112
133
  """Return the best available pool for this MCP process.
113
134
 
114
- Preference order:
115
- 1. Running daemon use HTTP proxy (keeps ONNX in ONE process)
116
- 2. No daemon fall back to ``WorkerPool.shared()`` (spawns a
117
- local subprocess with a FULL engine). This keeps single-user
118
- / first-launch scenarios working.
135
+ The daemon is the sole canonical writer. A bounded daemon auto-start is
136
+ attempted for first use; if it cannot become healthy, return a facade that
137
+ reports a retryable ``DAEMON_UNAVAILABLE`` envelope. Never construct a
138
+ process-local ``WorkerPool`` from an MCP client.
119
139
  """
120
140
  try:
121
- from superlocalmemory.cli.daemon import _get_port, is_daemon_running
122
- if is_daemon_running():
141
+ from superlocalmemory.cli.daemon import (
142
+ _get_port,
143
+ ensure_daemon,
144
+ is_daemon_running,
145
+ )
146
+ if is_daemon_running() or ensure_daemon():
123
147
  return DaemonPoolProxy(port=_get_port())
124
148
  except Exception as exc:
125
- logger.warning("daemon probe failed falling back to subprocess pool: %s", exc)
126
- from superlocalmemory.core.worker_pool import WorkerPool
127
- return WorkerPool.shared()
149
+ logger.warning("daemon probe or bounded start failed: %s", exc)
150
+ return DaemonPoolProxy(port=None, unavailable=True)
@@ -19,12 +19,12 @@ from __future__ import annotations
19
19
  import asyncio
20
20
  import datetime
21
21
  import logging
22
- import sqlite3
23
22
  import uuid
24
23
  from typing import TYPE_CHECKING, Callable
25
24
 
26
- from superlocalmemory.infra.data_root import canonical_data_root, state_path
25
+ from superlocalmemory.infra.data_root import state_path
27
26
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
27
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from superlocalmemory.mcp._pool_adapter import PoolRecallResponse
@@ -67,7 +67,8 @@ def _sqlite_emergency_recall(
67
67
  f"AND f.created_at >= datetime('now', '-{int(max_age_days)} days') "
68
68
  if max_age_days > 0 else ""
69
69
  )
70
- conn = sqlite3.connect(str(state_path("memory.db")), timeout=5.0)
70
+ memory_db = state_path("memory.db").resolve()
71
+ conn = ReadConnectionFactory(memory_db, timeout_ms=250).open()
71
72
  try:
72
73
  rows = conn.execute(
73
74
  f"""SELECT f.fact_id, f.content, f.memory_id, f.created_at,
@@ -139,21 +140,6 @@ def _emit_event(event_type: str, payload: dict | None = None,
139
140
  logger.warning("event emit failed: type=%s err=%s", event_type, exc)
140
141
 
141
142
 
142
- def _register_agent(agent_id: str, profile_id: str) -> bool:
143
- """Register an agent in the AgentRegistry (best-effort)."""
144
- try:
145
- from superlocalmemory.core.registry import AgentRegistry
146
- registry_path = canonical_data_root() / "agents.json"
147
- registry = AgentRegistry(persist_path=registry_path)
148
- registry.register_agent(agent_id, profile_id)
149
- return True
150
- except Exception as exc:
151
- logger.warning(
152
- "agent registry write failed: agent=%s err=%s", agent_id, exc,
153
- )
154
- return False
155
-
156
-
157
143
  def register_active_tools(server, get_engine: Callable) -> None:
158
144
  """Register 3 active memory tools on *server*."""
159
145
 
@@ -401,29 +387,6 @@ def register_active_tools(server, get_engine: Callable) -> None:
401
387
  f"-{uuid.uuid4().hex[:8]}"
402
388
  )
403
389
 
404
- # Register agent + emit event (v3.4.39: SLM_AGENT_ID env support)
405
- agent_id = _get_agent_id()
406
- if hasattr(engine, "_hooks"):
407
- registration_auth = authorize_mcp_mutation(
408
- engine,
409
- "update",
410
- mutation_source="mcp-agent-registration",
411
- profile_id=pid,
412
- )
413
- if _register_agent(agent_id, pid):
414
- registration_auth.complete()
415
- else:
416
- # A LIGHT client without the policy registry is read-capable,
417
- # but must not fall back to an unauthorised registry write.
418
- logger.info(
419
- "agent registration skipped: policy hooks unavailable"
420
- )
421
- _emit_event("agent.connected", {
422
- "agent_id": agent_id,
423
- "project_path": project_path,
424
- "memory_count": len(memories),
425
- })
426
-
427
390
  return {
428
391
  "success": True,
429
392
  "session_id": session_id,
@@ -20,7 +20,7 @@ from typing import Callable
20
20
  from mcp.types import ToolAnnotations
21
21
 
22
22
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
23
- from superlocalmemory.infra.data_root import canonical_data_root, state_path
23
+ from superlocalmemory.infra.data_root import state_path
24
24
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
25
25
 
26
26
  logger = logging.getLogger(__name__)
@@ -58,67 +58,6 @@ def _emit_event(event_type: str, payload: dict | None = None,
58
58
  pass
59
59
 
60
60
 
61
- def _record_recall_hits(
62
- get_engine: Callable,
63
- query: str,
64
- results: list[dict],
65
- *,
66
- profile_id: str = "",
67
- query_id: str = "",
68
- fact_ids_candidates: list[str] | None = None,
69
- ) -> None:
70
- """Record honest shown-state signals (LLD-02 §4.9).
71
-
72
- v3.4.22: No more fake positives. For every candidate we enqueue a
73
- ``shown`` / ``not_shown`` flip based on whether it was returned in the
74
- top-K presented to the user. Outcome/reward arrives in v3.4.22 via the
75
- action-outcomes pipeline.
76
-
77
- Non-blocking: all work funnels through ``signals.enqueue_shown_flip``
78
- (module-level queue + background drain). Failures are swallowed —
79
- signal quality is never load-bearing on recall correctness.
80
- """
81
- try:
82
- from superlocalmemory.learning.signals import (
83
- LearningSignals,
84
- enqueue_shown_flip,
85
- )
86
-
87
- pid = profile_id
88
- if not pid:
89
- pid = get_engine().profile_id
90
- slm_dir = canonical_data_root()
91
-
92
- shown_ids = [r.get("fact_id", "") for r in results[:10]
93
- if r.get("fact_id")]
94
- candidates = (fact_ids_candidates
95
- if fact_ids_candidates is not None
96
- else shown_ids)
97
- if not candidates:
98
- return
99
-
100
- # Shown-flip enqueue per §4.9. No synthetic positives.
101
- shown_set = set(shown_ids)
102
- if query_id:
103
- for fid in candidates:
104
- enqueue_shown_flip(query_id, fid, shown=(fid in shown_set))
105
-
106
- # Legacy zero-cost signals — unchanged (co-retrieval + confidence).
107
- try:
108
- signals = LearningSignals(slm_dir / "learning.db")
109
- signals.record_co_retrieval(pid, shown_ids)
110
- except Exception:
111
- pass
112
- try:
113
- mem_db = str(slm_dir / "memory.db")
114
- for fid in shown_ids[:5]:
115
- LearningSignals.boost_confidence(mem_db, fid)
116
- except Exception:
117
- pass
118
- except Exception:
119
- pass
120
-
121
-
122
61
  def register_core_tools(server, get_engine: Callable) -> None:
123
62
  """Register the 13 core MCP tools on *server*."""
124
63
 
@@ -212,10 +151,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
212
151
  await _asyncio.sleep(0.05 * (attempt + 1))
213
152
  return {
214
153
  "success": False,
154
+ "code": "DAEMON_UNAVAILABLE",
215
155
  "retryable": True,
216
156
  "error": (
217
- "Canonical daemon is temporarily unavailable; retry the "
218
- "same remember operation without starting a second writer."
157
+ "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
219
158
  ),
220
159
  }
221
160
  except Exception as dexc:
@@ -241,11 +180,22 @@ def register_core_tools(server, get_engine: Callable) -> None:
241
180
  worker_meta,
242
181
  )
243
182
  if not isinstance(stored, dict) or not stored.get("ok"):
244
- raise RuntimeError(
245
- (stored or {}).get("error", "canonical worker store failed")
246
- if isinstance(stored, dict)
247
- else "canonical worker returned an invalid response"
248
- )
183
+ if isinstance(stored, dict) and stored.get("code") == "DAEMON_UNAVAILABLE":
184
+ return {
185
+ "success": False,
186
+ "code": "DAEMON_UNAVAILABLE",
187
+ "retryable": True,
188
+ "error": stored.get(
189
+ "error",
190
+ "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
191
+ ),
192
+ }
193
+ return {
194
+ "success": False,
195
+ "code": "DAEMON_UNAVAILABLE",
196
+ "retryable": True,
197
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
198
+ }
249
199
  fact_ids = list(stored.get("fact_ids") or [])
250
200
  materialization_state = str(
251
201
  stored.get("materialization_state") or "complete"
@@ -275,9 +225,14 @@ def register_core_tools(server, get_engine: Callable) -> None:
275
225
  else "Queryable now; canonical enrichment is still running."
276
226
  ),
277
227
  }
278
- except Exception as exc:
228
+ except Exception:
279
229
  logger.exception("remember failed")
280
- return {"success": False, "error": str(exc)}
230
+ return {
231
+ "success": False,
232
+ "code": "DAEMON_UNAVAILABLE",
233
+ "retryable": True,
234
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
235
+ }
281
236
 
282
237
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
283
238
  async def recall(
@@ -378,22 +333,6 @@ def register_core_tools(server, get_engine: Callable) -> None:
378
333
  window=window or None,
379
334
  )
380
335
  if result.get("ok"):
381
- # Record implicit feedback: every returned result is a recall_hit
382
- try:
383
- _record_recall_hits(
384
- get_engine,
385
- query,
386
- result.get("results", []),
387
- profile_id=str(result.get("profile", "")),
388
- )
389
- except Exception:
390
- pass # Feedback is non-critical, never block recall
391
- _emit_event("memory.recalled", {
392
- "query": query[:80],
393
- "result_count": result.get("result_count", 0),
394
- "query_type": result.get("query_type", "unknown"),
395
- "agent_id": agent_id,
396
- }, source_agent=agent_id)
397
336
  return {
398
337
  "success": True,
399
338
  "results": result.get("results", []),
@@ -16,13 +16,11 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import logging
19
- import sqlite3
20
- from datetime import datetime, timezone
21
- from pathlib import Path
22
19
  from typing import Callable
23
20
 
24
21
  from mcp.types import ToolAnnotations
25
22
  from superlocalmemory.infra.data_root import state_path
23
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
26
24
 
27
25
  logger = logging.getLogger(__name__)
28
26
 
@@ -146,10 +144,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
146
144
  try:
147
145
  engine = get_engine()
148
146
  profile_id = engine.profile_id if engine else "default"
149
- db_path = str(state_path("memory.db"))
150
-
151
- conn = sqlite3.connect(db_path, timeout=10)
152
- conn.row_factory = sqlite3.Row
147
+ db_path = state_path("memory.db")
148
+ conn = ReadConnectionFactory(db_path).open()
153
149
 
154
150
  # Gather per-skill invocation stats from tool_events
155
151
  # Skills are logged as tool_name='Skill' with actual skill name in input_summary
@@ -274,9 +270,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
274
270
  skill_name: Specific skill name (empty = all skills)
275
271
  """
276
272
  try:
277
- db_path = str(state_path("memory.db"))
278
- conn = sqlite3.connect(db_path, timeout=10)
279
- conn.row_factory = sqlite3.Row
273
+ db_path = state_path("memory.db")
274
+ conn = ReadConnectionFactory(db_path).open()
280
275
 
281
276
  if skill_name:
282
277
  rows = conn.execute(