superlocalmemory 3.8.1 → 3.8.2

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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  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 +2 -2
  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 +3 -5
  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 +2 -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 +3 -5
  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 +1 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +360 -2
  35. package/src/superlocalmemory/cli/main.py +62 -3
  36. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  37. package/src/superlocalmemory/core/component_healer.py +144 -0
  38. package/src/superlocalmemory/core/component_registry.py +487 -0
  39. package/src/superlocalmemory/core/config.py +21 -0
  40. package/src/superlocalmemory/core/embeddings.py +14 -1
  41. package/src/superlocalmemory/core/engine.py +9 -5
  42. package/src/superlocalmemory/core/ingestion_command.py +36 -16
  43. package/src/superlocalmemory/core/maintenance.py +43 -0
  44. package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
  45. package/src/superlocalmemory/core/recall_pipeline.py +39 -3
  46. package/src/superlocalmemory/core/store_pipeline.py +42 -0
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  48. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  49. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  50. package/src/superlocalmemory/mcp/tools_core.py +17 -2
  51. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  52. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  53. package/src/superlocalmemory/server/routes/behavioral.py +6 -2
  54. package/src/superlocalmemory/server/routes/learning.py +13 -3
  55. package/src/superlocalmemory/server/routes/memories.py +8 -3
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +266 -2
  58. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  59. package/src/superlocalmemory/ui/index.html +3 -2
  60. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  61. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  62. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  63. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  64. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  65. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  66. package/src/superlocalmemory/ui/js/od-settings.js +72 -3
@@ -109,6 +109,7 @@ def run_maintenance(
109
109
  db: DatabaseManager,
110
110
  config: SLMConfig,
111
111
  profile_id: str = "default",
112
+ embedder: object | None = None,
112
113
  ) -> dict[str, int]:
113
114
  """Run background maintenance on mathematical layers.
114
115
 
@@ -116,6 +117,11 @@ def run_maintenance(
116
117
  db: Database manager.
117
118
  config: Full SLM configuration.
118
119
  profile_id: Scope to this profile.
120
+ embedder: Optional embedder for self-healing NULL-embedding backfill.
121
+ When provided and NULL embeddings exist, up to 100 facts are
122
+ embedded per maintenance pass so the DB converges over time.
123
+ Pass ``None`` (default) to skip the backfill — existing callers
124
+ are unaffected.
119
125
 
120
126
  Returns:
121
127
  Dict of counts: langevin_updated, sheaf_checked, etc.
@@ -128,6 +134,7 @@ def run_maintenance(
128
134
  "entity_summaries_consolidated": 0, # V3.4.40
129
135
  "orphan_metadata_gc": 0, # v3.6.4 (P1-3)
130
136
  "expansion_backfilled": 0, # T3b
137
+ "embeddings_backfilled": 0, # v3.8.x NULL-embedding self-heal
131
138
  }
132
139
 
133
140
  # P1-3 (embeddings-vector-02): sweep orphaned embedding_metadata left by
@@ -138,6 +145,42 @@ def run_maintenance(
138
145
  except Exception as exc: # pragma: no cover - defensive
139
146
  logger.debug("orphan metadata GC skipped: %s", exc)
140
147
 
148
+ # v3.8.x: self-healing NULL-embedding backfill. Facts stored while the
149
+ # embedder was unavailable end up with NULL embedding and are invisible to
150
+ # semantic recall. When an embedder is available, embed up to 100 facts
151
+ # per maintenance pass so the DB converges without blocking the caller.
152
+ if embedder is not None:
153
+ try:
154
+ from superlocalmemory.storage.embedding_migrator import (
155
+ backfill_missing_embeddings,
156
+ )
157
+
158
+ # Guard: skip entirely when nothing needs backfilling.
159
+ null_rows = db.execute(
160
+ "SELECT count(*) AS c FROM atomic_facts "
161
+ "WHERE embedding IS NULL AND profile_id = ?",
162
+ (profile_id,),
163
+ )
164
+ null_count = int(null_rows[0]["c"]) if null_rows else 0
165
+ if null_count > 0:
166
+ result = backfill_missing_embeddings(
167
+ config,
168
+ db,
169
+ embedder,
170
+ batch_size=50,
171
+ limit=100,
172
+ )
173
+ counts["embeddings_backfilled"] = result["embedded"]
174
+ if result["embedded"] > 0:
175
+ logger.info(
176
+ "Maintenance embedding backfill: %d facts embedded, "
177
+ "%d remaining.",
178
+ result["embedded"],
179
+ result["remaining_null"],
180
+ )
181
+ except Exception as exc:
182
+ logger.debug("embedding backfill skipped during maintenance: %s", exc)
183
+
141
184
  facts = db.get_all_facts(profile_id)
142
185
  if not facts:
143
186
  return counts
@@ -40,10 +40,16 @@ class MaintenanceScheduler:
40
40
  db: DatabaseManager,
41
41
  config: SLMConfig,
42
42
  profile_id: str = "default",
43
+ embedder: object | None = None,
43
44
  ) -> None:
44
45
  self._db = db
45
46
  self._config = config
46
47
  self._profile_id = profile_id
48
+ # v3.8.2 self-heal: when provided, periodic maintenance backfills
49
+ # NULL embeddings so a DB stays fully queryable over time even if
50
+ # facts were stored while the embedder was unavailable. Runs
51
+ # independently of forgetting.enabled (see _run).
52
+ self._embedder = embedder
47
53
  self._timer: threading.Timer | None = None
48
54
  self._running = False
49
55
  self._interval = config.forgetting.scheduler_interval_minutes * 60.0
@@ -79,6 +85,28 @@ class MaintenanceScheduler:
79
85
  """Execute maintenance + auto-backup check, then schedule next run."""
80
86
  if not self._running:
81
87
  return
88
+ # v3.8.2 self-heal: bounded NULL-embedding backfill runs every cycle
89
+ # INDEPENDENTLY of forgetting.enabled and across ALL profiles — a fact
90
+ # stored while the embedder was down must become queryable again without
91
+ # the user touching anything. Idempotent + bounded (200/pass) so it
92
+ # converges quietly and is a no-op once coverage is complete.
93
+ if self._embedder is not None:
94
+ try:
95
+ from superlocalmemory.storage.embedding_migrator import (
96
+ backfill_missing_embeddings,
97
+ )
98
+ r = backfill_missing_embeddings(
99
+ self._config, self._db, self._embedder,
100
+ limit=50, all_profiles=True,
101
+ )
102
+ if r.get("embedded"):
103
+ logger.info(
104
+ "Self-heal backfill: %d embedded, %d remaining",
105
+ r["embedded"], r["remaining_null"],
106
+ )
107
+ except Exception as exc:
108
+ logger.debug("Self-heal backfill skipped: %s", exc)
109
+
82
110
  for profile_id in self._profile_ids():
83
111
  if self._config.forgetting.enabled:
84
112
  try:
@@ -604,6 +604,33 @@ def apply_v2_bandit_ensemble(
604
604
  # run_recall (was MemoryEngine.recall)
605
605
  # ---------------------------------------------------------------------------
606
606
 
607
+ def resolve_hot_path_fast(fast: bool | None, config: "SLMConfig") -> bool:
608
+ """Resolve the recall ``fast`` flag when a caller leaves it unset (None).
609
+
610
+ v3.8.2 client-driven agentic: the agent hot path (CLI / MCP / plugins) is
611
+ consumed by a frontier LLM (Claude Code, Copilot, Codex, …) that reformulates
612
+ multi-hop / low-confidence queries far better than the local Ollama model.
613
+ So an unset ``fast`` defaults to True — skip the internal agentic round and
614
+ let the calling LLM drive refinement — whenever ``retrieval.client_driven_agentic``
615
+ is on (the ship default). An explicit ``True``/``False`` from the caller
616
+ always wins (the dashboard search path passes ``True`` for a snappy list;
617
+ a no-smart-client deployment can pass ``False``). Env override
618
+ ``SLM_HOT_PATH_INTERNAL_AGENTIC=1`` forces internal-agentic-on globally.
619
+
620
+ This is the single resolution point: every recall path (HTTP, MCP, CLI,
621
+ in-process adapter) funnels through ``run_recall`` and calls this, so the
622
+ client-driven default is consistent everywhere by construction.
623
+ """
624
+ if fast is not None:
625
+ return bool(fast)
626
+ import os
627
+ rc = getattr(config, "retrieval", None)
628
+ client_driven = bool(getattr(rc, "client_driven_agentic", True))
629
+ if os.environ.get("SLM_HOT_PATH_INTERNAL_AGENTIC") == "1":
630
+ client_driven = False
631
+ return client_driven
632
+
633
+
607
634
  def run_recall(
608
635
  query: str,
609
636
  profile_id: str,
@@ -620,7 +647,7 @@ def run_recall(
620
647
  hooks: HookRegistry,
621
648
  access_log: Any = None,
622
649
  auto_linker: Any = None,
623
- fast: bool = False,
650
+ fast: bool | None = None,
624
651
  include_global: bool = False,
625
652
  include_shared: bool = False,
626
653
  window: str | tuple[str, str] | None = None,
@@ -632,8 +659,11 @@ def run_recall(
632
659
 
633
660
  Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.
634
661
 
635
- ``fast=True`` skips remote agentic verification while retaining the six
636
- local retrieval channels.
662
+ ``fast=True`` skips the internal agentic verification round while retaining
663
+ the six local retrieval channels + reranker. ``fast=None`` (unset) resolves
664
+ to the client-driven-agentic default (see ``resolve_hot_path_fast``): the
665
+ agent hot path skips the internal round and delegates refinement to the
666
+ calling LLM. ``fast=False`` forces the internal agentic round.
637
667
  """
638
668
  # Pre-operation hooks
639
669
  hook_ctx = {
@@ -646,6 +676,12 @@ def run_recall(
646
676
 
647
677
  m = mode or config.mode
648
678
 
679
+ # v3.8.2: resolve the client-driven-agentic default when a caller left
680
+ # ``fast`` unset (None). After this line ``fast`` is a concrete bool, so
681
+ # the agentic gate below (``if not fast``) behaves identically for every
682
+ # entry point that funnels through here.
683
+ fast = resolve_hot_path_fast(fast, config)
684
+
649
685
  # v3.5.0 diagnostic: per-stage recall timing under SLM_RECALL_TIMING=1.
650
686
  # Zero overhead when the env var is unset. Permanent observability hook.
651
687
  import os as _os_t
@@ -89,6 +89,48 @@ def _record_fact_entity_association(
89
89
  "WHERE entity_id=? AND profile_id=?",
90
90
  (entity_id, profile_id),
91
91
  )
92
+ elif not claimed:
93
+ # An empty result has TWO causes: (a) the 'historical-backfill'
94
+ # repair-state row is missing — M028 DDL ran but its INSERT rolled
95
+ # back (partial migration) — so the JOIN returned zero rows and the
96
+ # main INSERT produced no output; or (b) the association already
97
+ # exists and the ON CONFLICT update was correctly skipped (an
98
+ # idempotent re-run). Only (a) needs the fallback insert; (b) is a
99
+ # no-op. Disambiguate by probing the repair-state table directly —
100
+ # a DIFFERENT table, so this stays a constant-query, idempotent
101
+ # effect and never re-fires the insert on an already-applied row.
102
+ repair_ready = db.execute(
103
+ "SELECT 1 FROM fact_entity_association_repair_state "
104
+ "WHERE repair_key='historical-backfill' LIMIT 1",
105
+ )
106
+ if repair_ready:
107
+ # (b) idempotent skip — the association was already applied.
108
+ return
109
+ # (a) partial migration: insert with count_applied=1 (no historical-
110
+ # rowid check needed — all associations in this state are
111
+ # post-migration). ON CONFLICT DO NOTHING makes this retry-safe.
112
+ fallback = db.execute(
113
+ "INSERT INTO fact_entity_associations "
114
+ "(profile_id,fact_id,entity_id,first_operation_id,count_applied) "
115
+ "SELECT ?,?,?,?,1 "
116
+ "FROM canonical_entities AS entity "
117
+ "JOIN atomic_facts AS fact "
118
+ "ON fact.fact_id=? AND fact.profile_id=? "
119
+ "WHERE entity.entity_id=? AND entity.profile_id=? "
120
+ "ON CONFLICT(profile_id,fact_id,entity_id) DO NOTHING "
121
+ "RETURNING count_applied",
122
+ (
123
+ profile_id, fact_id, entity_id, operation_id,
124
+ fact_id, profile_id,
125
+ entity_id, profile_id,
126
+ ),
127
+ )
128
+ if fallback:
129
+ db.execute(
130
+ "UPDATE canonical_entities SET fact_count=fact_count+1 "
131
+ "WHERE entity_id=? AND profile_id=?",
132
+ (entity_id, profile_id),
133
+ )
92
134
 
93
135
 
94
136
  def _init_langevin_position(dim: int = 8) -> list[float]:
@@ -40,7 +40,7 @@ class DaemonPoolProxy:
40
40
 
41
41
  def recall(
42
42
  self, query: str, limit: int = 10, session_id: str = "",
43
- fast: bool = False,
43
+ fast: bool | None = None,
44
44
  include_global: bool | None = None,
45
45
  include_shared: bool | None = None,
46
46
  window: str | None = None,
@@ -49,8 +49,12 @@ class DaemonPoolProxy:
49
49
  "q": query,
50
50
  "limit": limit,
51
51
  "session_id": session_id or "",
52
- "fast": "true" if fast else "false",
53
52
  }
53
+ # v3.8.2 client-driven agentic: only send ``fast`` when the caller set it
54
+ # explicitly. Unset (None) lets the daemon resolve the configured
55
+ # client-driven-agentic default — the same way scope flags are handled.
56
+ if fast is not None:
57
+ _params["fast"] = "true" if fast else "false"
54
58
  # v3.6.15 multi-scope: only send the scope flags when explicitly set, so
55
59
  # an unset value lets the daemon resolve the configured default (shared
56
60
  # is opt-in). "None" must NOT become the string "none" on the wire.
@@ -98,7 +98,10 @@ def pool_recall(query: str, limit: int = 10, **kwargs: Any) -> PoolRecallRespons
98
98
  "query": query,
99
99
  "limit": limit,
100
100
  "session_id": str(kwargs.get("session_id") or ""),
101
- "fast": bool(kwargs.get("fast", False)),
101
+ # v3.8.2 client-driven agentic: pass ``fast`` through unchanged. None
102
+ # (unset) flows to the daemon/engine which resolves the configured
103
+ # client-driven-agentic default; an explicit bool always wins.
104
+ "fast": kwargs.get("fast", None),
102
105
  }
103
106
  if "include_global" in kwargs:
104
107
  _recall_kwargs["include_global"] = kwargs["include_global"]
@@ -227,7 +227,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
227
227
  # thread so the async MCP event loop is not stalled — same
228
228
  # fix class as #34 mesh tools deadlock.
229
229
  response = await asyncio.to_thread(
230
- pool_recall, search_query, limit=max_results, fast=False,
230
+ pool_recall, search_query, limit=max_results, fast=None,
231
231
  )
232
232
  except (PoolError, Exception) as exc:
233
233
  logger.warning(
@@ -282,13 +282,28 @@ def register_core_tools(server, get_engine: Callable) -> None:
282
282
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
283
283
  async def recall(
284
284
  query: str, limit: int = CANONICAL_RECALL_LIMIT, agent_id: str = "mcp_client",
285
- session_id: str = "", fast: bool = False,
285
+ session_id: str = "", fast: bool | None = None,
286
286
  include_global: bool | None = None,
287
287
  include_shared: bool | None = None,
288
288
  window: str = "",
289
289
  ) -> dict:
290
290
  """Search memories through hybrid retrieval, RRF fusion, and reranking.
291
291
 
292
+ Fast local retrieval (six channels + reranker) returns in ~1-2s. This
293
+ tool does NOT run an internal LLM reformulation round — YOU (the calling
294
+ model) are the reasoner. Drive refinement using the confidence signals
295
+ in the response:
296
+ • ``no_confident_match: true`` → nothing cleared the evidence floor.
297
+ Do NOT invent a memory. Rewrite the query into 1-3 more specific
298
+ sub-queries (split multi-hop questions; try entity names, synonyms,
299
+ or a broader phrasing) and call ``recall`` again before concluding
300
+ the information is unknown.
301
+ • ``answer_confidence`` low / ``abstained: true`` → the top hit is
302
+ weak. Re-query with a sharper phrasing, or widen with
303
+ ``include_shared=true`` / ``include_global=true`` if appropriate.
304
+ • Confident match → use it directly; no second call needed.
305
+ One extra targeted recall is cheap and beats a wrong "not found".
306
+
292
307
  Optional ``session_id`` threads through to the
293
308
  engine's outcome-queue so PostToolUse / Stop hooks can attach
294
309
  engagement signals to this recall. Claude Code should pass its
@@ -357,7 +372,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
357
372
  # block behind a single threading.Lock. See worker_pool.py.
358
373
  result = await asyncio.to_thread(
359
374
  pool.recall, query, limit=limit, session_id=effective_sid,
360
- fast=bool(fast),
375
+ fast=fast,
361
376
  include_global=include_global,
362
377
  include_shared=include_shared,
363
378
  window=window or None,
@@ -27,6 +27,7 @@ License: AGPL-3.0-or-later
27
27
  from __future__ import annotations
28
28
 
29
29
  import logging
30
+ import time
30
31
  from typing import TYPE_CHECKING
31
32
 
32
33
  from superlocalmemory.retrieval.scope_policy import (
@@ -71,6 +72,7 @@ class BridgeDiscovery:
71
72
  *,
72
73
  include_global: bool = False,
73
74
  include_shared: bool = False,
75
+ time_budget_s: float = 0.4,
74
76
  ) -> list[tuple[str, float]]:
75
77
  """Find bridge facts connecting seed results.
76
78
 
@@ -110,9 +112,19 @@ class BridgeDiscovery:
110
112
 
111
113
  bridges: list[tuple[str, float]] = []
112
114
  seen = set(seed_ids)
115
+ # v3.8.2: bound the per-entity get_facts_by_entity fan-out. On a dense
116
+ # entity graph (M5: 3.3k entities / 208k edges) a single recall could
117
+ # issue 200+ DB round-trips here — the primary 3.8 full-mode latency
118
+ # spike (observed 7.6s). Bridges are a SUPPLEMENTARY post-fusion boost
119
+ # (score x0.8, only added if not already found), so truncating them
120
+ # under a wall-clock budget is quality-safe: the ranked channels have
121
+ # already returned the core results.
122
+ deadline = time.monotonic() + time_budget_s
113
123
 
114
124
  # Check consecutive pairs for entity overlap
115
125
  for i in range(len(seed_ids) - 1):
126
+ if time.monotonic() > deadline:
127
+ break
116
128
  fact_a = seed_facts.get(seed_ids[i])
117
129
  fact_b = seed_facts.get(seed_ids[i + 1])
118
130
  if not fact_a or not fact_b:
@@ -128,6 +140,8 @@ class BridgeDiscovery:
128
140
  # Strategy 1: Entity bridge (union minus intersection)
129
141
  bridge_entities = (entities_a | entities_b) - (entities_a & entities_b)
130
142
  for eid in bridge_entities:
143
+ if time.monotonic() > deadline:
144
+ break
131
145
  entity_facts = self._db.get_facts_by_entity(
132
146
  eid,
133
147
  profile_id,
@@ -139,14 +139,19 @@ class SpreadingActivation:
139
139
  )
140
140
  # Owner-partitioned vector indexes cannot discover opted-in peers.
141
141
  # Add visible external embeddings with the same cosine seed signal.
142
- try:
143
- external_facts = self._db.get_external_visible_facts(
144
- profile_id,
145
- include_global=include_global,
146
- include_shared=include_shared,
147
- )
148
- except Exception:
149
- external_facts = []
142
+ # v3.8.2 perf: external (global/shared) facts only matter for a
143
+ # cross-scope read. For the default personal scope this query always
144
+ # returns [] — skip it to remove a per-recall DB round-trip.
145
+ external_facts: list = []
146
+ if include_global or include_shared:
147
+ try:
148
+ external_facts = self._db.get_external_visible_facts(
149
+ profile_id,
150
+ include_global=include_global,
151
+ include_shared=include_shared,
152
+ )
153
+ except Exception:
154
+ external_facts = []
150
155
  q_vec = np.array(query, dtype=np.float32)
151
156
  q_norm = float(np.linalg.norm(q_vec))
152
157
  combined = {fact_id: score for fact_id, score in seed_results}
@@ -162,18 +167,26 @@ class SpreadingActivation:
162
167
  continue
163
168
  score = (float(np.dot(q_vec, fact_vec) / denominator) + 1.0) / 2.0
164
169
  combined[fact.fact_id] = max(combined.get(fact.fact_id, 0.0), score)
165
- allowed_seeds = authorized_fact_ids(
166
- self._db,
167
- combined,
168
- profile_id,
169
- include_global=include_global,
170
- include_shared=include_shared,
171
- )
172
- seed_results = [
173
- (fact_id, score)
174
- for fact_id, score in combined.items()
175
- if fact_id in allowed_seeds
176
- ]
170
+ # v3.8.2 perf: seeds come from this profile's own vector index /
171
+ # get_all_facts(profile_id), so for personal scope they are already
172
+ # authorized. Only re-authorize when a cross-scope read merged in
173
+ # global/shared candidates. filter_authorized_results below remains the
174
+ # security net on the returned set.
175
+ if include_global or include_shared:
176
+ allowed_seeds = authorized_fact_ids(
177
+ self._db,
178
+ combined,
179
+ profile_id,
180
+ include_global=include_global,
181
+ include_shared=include_shared,
182
+ )
183
+ seed_results = [
184
+ (fact_id, score)
185
+ for fact_id, score in combined.items()
186
+ if fact_id in allowed_seeds
187
+ ]
188
+ else:
189
+ seed_results = list(combined.items())
177
190
  if not seed_results:
178
191
  return []
179
192
 
@@ -186,13 +199,18 @@ class SpreadingActivation:
186
199
  )
187
200
  cached = self._get_cached_results(query_hash, profile_id)
188
201
  if cached:
189
- return filter_authorized_results(
190
- self._db,
191
- cached,
192
- profile_id,
193
- include_global=include_global,
194
- include_shared=include_shared,
195
- )[:top_k]
202
+ # v3.8.2 perf: cached activations were produced from this profile's
203
+ # own propagation; personal-scope hits need no re-authorization.
204
+ # Cross-scope hits still pass the fail-closed filter.
205
+ if include_global or include_shared:
206
+ return filter_authorized_results(
207
+ self._db,
208
+ cached,
209
+ profile_id,
210
+ include_global=include_global,
211
+ include_shared=include_shared,
212
+ )[:top_k]
213
+ return cached[:top_k]
196
214
 
197
215
  # Run 5-step spreading activation
198
216
  activations = self._propagate(
@@ -312,7 +330,15 @@ class SpreadingActivation:
312
330
  if activation < 0.001:
313
331
  continue
314
332
 
315
- # Get neighbors from BOTH tables (Rule 13) — cached per node
333
+ # Get neighbors from BOTH tables (Rule 13) — cached per node.
334
+ # v3.8.2 perf-fix: _get_unified_neighbors already filters edges by
335
+ # the scope predicate (graph_edges via _scope_where; association_edges
336
+ # by profile_id), so for the default personal scope the returned
337
+ # neighbors are inherently authorized. The per-node re-authorization
338
+ # (2 DB round-trips/node, ~30–60 per recall — the primary 3.8 latency
339
+ # regression) is only required when a cross-scope read can surface
340
+ # global/shared neighbors. filter_authorized_results() on the returned
341
+ # set remains the security net for every scope.
316
342
  if node_id not in neighbor_cache:
317
343
  raw_neighbors = self._get_unified_neighbors(
318
344
  node_id,
@@ -320,16 +346,20 @@ class SpreadingActivation:
320
346
  include_global=include_global,
321
347
  include_shared=include_shared,
322
348
  )
323
- allowed_neighbors = authorized_fact_ids(
324
- self._db,
325
- (neighbor_id for neighbor_id, _weight in raw_neighbors),
326
- profile_id,
327
- include_global=include_global,
328
- include_shared=include_shared,
329
- )
330
- neighbor_cache[node_id] = [
331
- item for item in raw_neighbors if item[0] in allowed_neighbors
332
- ]
349
+ if include_global or include_shared:
350
+ allowed_neighbors = authorized_fact_ids(
351
+ self._db,
352
+ (neighbor_id for neighbor_id, _weight in raw_neighbors),
353
+ profile_id,
354
+ include_global=include_global,
355
+ include_shared=include_shared,
356
+ )
357
+ neighbor_cache[node_id] = [
358
+ item for item in raw_neighbors
359
+ if item[0] in allowed_neighbors
360
+ ]
361
+ else:
362
+ neighbor_cache[node_id] = raw_neighbors
333
363
  neighbors = neighbor_cache[node_id]
334
364
 
335
365
  # Out-degree for fan effect normalization
@@ -473,8 +473,12 @@ def get_assertions(
473
473
  query += " ORDER BY confidence DESC LIMIT ?"
474
474
  params.append(limit)
475
475
 
476
- rows = conn.execute(query, tuple(params)).fetchall()
477
- conn.close()
476
+ # F8 fix: use try/finally so conn.close() is guaranteed even when
477
+ # conn.execute() raises (e.g. SQLITE_BUSY under dashboard burst load).
478
+ try:
479
+ rows = conn.execute(query, tuple(params)).fetchall()
480
+ finally:
481
+ conn.close()
478
482
 
479
483
  assertions = [dict(r) for r in rows]
480
484
  return {
@@ -36,6 +36,12 @@ router = APIRouter()
36
36
 
37
37
  LEARNING_DB = MEMORY_DIR / "learning.db"
38
38
 
39
+ # F10 fix: track which profiles have completed their initial cache-bypass check
40
+ # per daemon start. First call uses use_cache=False (tamper detection);
41
+ # subsequent calls use use_cache=True so tab navigation does not re-deserialize
42
+ # a LightGBM model on every dashboard request.
43
+ _ranker_initial_check_done: set[str] = set()
44
+
39
45
 
40
46
  def _require_write(request: Request) -> None:
41
47
  from superlocalmemory.access.rbac import Permission
@@ -137,10 +143,14 @@ def _compute_ranker_phase(
137
143
  )
138
144
 
139
145
  try:
140
- # Official train/promote/rollback paths invalidate this cache. Reuse
141
- # the verified object here so tab navigation does not deserialize a
146
+ # F10 fix: first call per daemon start per profile must bypass the cache
147
+ # so a tampered model file is detected before being served. Subsequent
148
+ # calls reuse the cache so tab navigation does not re-deserialize a
142
149
  # LightGBM model on every dashboard request.
143
- model = load_active(db, profile_id, use_cache=True)
150
+ bypass_cache = profile_id not in _ranker_initial_check_done
151
+ if bypass_cache:
152
+ _ranker_initial_check_done.add(profile_id)
153
+ model = load_active(db, profile_id, use_cache=not bypass_cache)
144
154
  except Exception as exc:
145
155
  logger.warning("load_active failed: %s", exc)
146
156
  model = None
@@ -507,9 +507,14 @@ async def search_memories(request: Request, body: SearchRequest):
507
507
  # a stalled connection and aborts with "signal is aborted without reason"
508
508
  # before the response arrives. Fix: run in a thread-pool executor so the
509
509
  # event loop stays alive to send keepalive frames.
510
- # v3.4.64 (regression fix): fast=True skips spreading_activation + agentic
511
- # LLM rounds (saves ~7s on cold graph traversal). fast=False is the SLOW
512
- # path that enables both; the earlier comment had this inverted.
510
+ # v3.8.2: fast=True the dashboard search BOX is a snappy retrieval
511
+ # list (all six local channels + reranker), never the internal agentic
512
+ # LLM round, which would reintroduce the multi-second hang this endpoint
513
+ # is regression-tested against (test_search_fast_param_and_profile_isolation).
514
+ # The human-facing LLM synthesis lives on separate paths that are NOT the
515
+ # search list: the "ask" memory-chat (/api/v3/chat/stream, Ollama Mode B)
516
+ # and the precomputed knowledge-cluster summaries (core.community_summary,
517
+ # Mode B/C). So search stays fast; synthesis is where the LLM adds value.
513
518
  import asyncio
514
519
  import time as _time
515
520
  engine = _get_engine(request)