superlocalmemory 3.4.63 → 3.5.0

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 (32) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/package.json +1 -1
  3. package/pyproject.toml +5 -2
  4. package/src/superlocalmemory/__init__.py +1 -1
  5. package/src/superlocalmemory/cli/commands.py +80 -33
  6. package/src/superlocalmemory/cli/daemon.py +3 -1
  7. package/src/superlocalmemory/cli/main.py +1 -1
  8. package/src/superlocalmemory/core/backend_orchestrator.py +19 -13
  9. package/src/superlocalmemory/core/config.py +83 -0
  10. package/src/superlocalmemory/core/injection.py +351 -0
  11. package/src/superlocalmemory/core/recall_pipeline.py +29 -0
  12. package/src/superlocalmemory/core/store_pipeline.py +13 -0
  13. package/src/superlocalmemory/hooks/auto_recall_hook.py +50 -26
  14. package/src/superlocalmemory/hooks/before_web_hook.py +3 -2
  15. package/src/superlocalmemory/hooks/user_prompt_hook.py +5 -2
  16. package/src/superlocalmemory/mcp/tools_active.py +130 -9
  17. package/src/superlocalmemory/mcp/tools_context.py +18 -4
  18. package/src/superlocalmemory/retrieval/bm25_channel.py +50 -0
  19. package/src/superlocalmemory/retrieval/engine.py +43 -9
  20. package/src/superlocalmemory/retrieval/hopfield_channel.py +22 -9
  21. package/src/superlocalmemory/retrieval/temporal_channel.py +10 -1
  22. package/src/superlocalmemory/server/routes/memories.py +2 -2
  23. package/src/superlocalmemory/server/routes/v3_api.py +40 -25
  24. package/src/superlocalmemory/server/unified_daemon.py +80 -0
  25. package/src/superlocalmemory/storage/database.py +47 -0
  26. package/src/superlocalmemory/storage/migration_runner.py +4 -0
  27. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +40 -0
  28. package/src/superlocalmemory/storage/migrations/__init__.py +1 -0
  29. package/src/superlocalmemory/storage/models.py +3 -0
  30. package/src/superlocalmemory.egg-info/PKG-INFO +4 -2
  31. package/src/superlocalmemory.egg-info/SOURCES.txt +3 -0
  32. package/src/superlocalmemory.egg-info/requires.txt +4 -1
@@ -257,24 +257,88 @@ def register_active_tools(server, get_engine: Callable) -> None:
257
257
 
258
258
  # Build both return shapes from one recall. Calling recall twice
259
259
  # doubles session startup latency and can return duplicate snippets.
260
- context = ""
261
- if relevant:
260
+
261
+ # v3.4.65: use shared injection formatter for full-fidelity context.
262
+ from superlocalmemory.core.injection import (
263
+ InjectableMemory,
264
+ clamp_content,
265
+ is_low_quality,
266
+ render_context,
267
+ )
268
+
269
+ pid = engine.profile_id
270
+
271
+ # Merge pinned facts (Q3: Core Memory explicit pins).
272
+ # Pinned facts surface even if the query didn't retrieve them.
273
+ try:
274
+ pinned_facts = engine.db.get_pinned(pid)
275
+ except Exception:
276
+ pinned_facts = []
277
+ pinned_ids = {f.fact_id for f in pinned_facts}
278
+ pinned_seen = set()
279
+
280
+ cfg_inj = getattr(getattr(engine, "config", None), "injection", None)
281
+ # Defend against MagicMock / non-config objects in tests.
282
+ try:
283
+ from superlocalmemory.core.config import InjectionConfig
284
+ if not isinstance(cfg_inj, InjectionConfig):
285
+ cfg_inj = None
286
+ except Exception:
287
+ cfg_inj = None
288
+
289
+ inj_mems: list[InjectableMemory] = []
290
+ # Pinned facts first (they always head the core block).
291
+ for pf in pinned_facts[:20]: # safety cap
292
+ inj_mems.append(InjectableMemory(
293
+ content=pf.content,
294
+ score=0.0,
295
+ fact_id=pf.fact_id,
296
+ importance=getattr(pf, "importance", 0.0) or 0.0,
297
+ access_count=getattr(pf, "access_count", 0) or 0,
298
+ pinned=True,
299
+ ))
300
+ pinned_seen.add(pf.fact_id)
301
+
302
+ # Then recall results (skip duplicates of pinned).
303
+ for r in relevant[:max_results]:
304
+ if r.fact.fact_id in pinned_seen:
305
+ continue
306
+ inj_mems.append(InjectableMemory(
307
+ content=r.fact.content,
308
+ score=round(r.score, 3),
309
+ fact_id=r.fact.fact_id,
310
+ importance=getattr(r.fact, "importance", 0.0) or 0.0,
311
+ access_count=getattr(r.fact, "access_count", 0) or 0,
312
+ ))
313
+
314
+ mode_str = str(getattr(engine, "mode", "B")).upper()
315
+ try:
316
+ context = render_context(inj_mems, mode=mode_str, cfg=cfg_inj, wrap=False)
317
+ except Exception:
318
+ # Fall back to legacy content building on any formatter failure
262
319
  lines = ["# Relevant Memory Context", ""]
263
- for r in relevant[:max_results]:
264
- lines.append(f"- {r.fact.content[:200]}")
320
+ for m in inj_mems[:max_results]:
321
+ lines.append(f"- {m.content[:200]}")
265
322
  context = "\n".join(lines)
266
323
 
324
+ # GAP-FIX (v3.4.65 delivery-lead): the memories[] array is part of
325
+ # the MCP response Claude Code ingests — it MUST be bounded too, not
326
+ # just the rendered `context` string. Previously full unclamped
327
+ # content shipped here (one fact was 131K chars → ~124K-token
328
+ # response, defeating the whole token budget). Clamp each content
329
+ # to per_memory_max_tokens, drop junk, and honour max_results.
267
330
  memories = [
268
331
  {
269
- "fact_id": r.fact.fact_id,
270
- "content": r.fact.content[:300],
271
- "score": round(r.score, 3),
332
+ "fact_id": m.fact_id,
333
+ "content": clamp_content(m.content, cfg_inj),
334
+ "score": m.score,
335
+ "is_core": m.is_core,
272
336
  }
273
- for r in relevant[:max_results]
337
+ for m in inj_mems[:max_results]
338
+ if not is_low_quality(m.content)
274
339
  ]
275
340
 
276
341
  # Get learning status
277
- pid = engine.profile_id
278
342
  feedback_count = 0
279
343
  try:
280
344
  feedback_count = engine._adaptive_learner.get_feedback_count(pid)
@@ -299,6 +363,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
299
363
  "context": context,
300
364
  "memories": memories[:max_results],
301
365
  "memory_count": len(memories),
366
+ "core_memory": [m["content"] for m in memories if m.get("is_core")],
302
367
  "degraded_mode": degraded_mode,
303
368
  "retrieval_mode": "emergency_fts5_bm25" if degraded_mode else "full_6_channel",
304
369
  "learning": {
@@ -471,3 +536,59 @@ def register_active_tools(server, get_engine: Callable) -> None:
471
536
  except Exception as exc:
472
537
  logger.exception("close_session failed")
473
538
  return {"success": False, "error": str(exc)}
539
+
540
+ # ------------------------------------------------------------------
541
+ # core_memory — v3.4.65: explicit Core Memory pin management
542
+ # ------------------------------------------------------------------
543
+
544
+ @server.tool()
545
+ async def core_memory(
546
+ action: str,
547
+ fact_id: str = "",
548
+ profile_id: str = "default",
549
+ ) -> dict:
550
+ """Manage the explicit Core Memory pin set (v3.4.65).
551
+
552
+ - pin: mark a fact as always-injected
553
+ - unpin: clear the pin
554
+ - list: return currently pinned facts
555
+ """
556
+ try:
557
+ engine = get_engine()
558
+ db = engine.db
559
+ pid = profile_id or engine.profile_id
560
+
561
+ if action == "pin":
562
+ if not fact_id:
563
+ return {"success": False, "error": "fact_id required for pin"}
564
+ db.set_pinned(fact_id, True)
565
+ return {"success": True, "action": "pin", "fact_id": fact_id}
566
+
567
+ if action == "unpin":
568
+ if not fact_id:
569
+ return {"success": False, "error": "fact_id required for unpin"}
570
+ db.set_pinned(fact_id, False)
571
+ return {"success": True, "action": "unpin", "fact_id": fact_id}
572
+
573
+ if action == "list":
574
+ pinned = db.get_pinned(pid)
575
+ cfg_inj = getattr(getattr(engine, "config", None), "injection", None)
576
+ max_tok = getattr(cfg_inj, "per_memory_max_tokens", 600) if cfg_inj else 600
577
+ return {
578
+ "success": True,
579
+ "pinned": [
580
+ {
581
+ "fact_id": f.fact_id,
582
+ "content": f.content[: max_tok * 4],
583
+ "importance": getattr(f, "importance", 0.0),
584
+ }
585
+ for f in pinned
586
+ ],
587
+ "count": len(pinned),
588
+ }
589
+
590
+ return {"success": False, "error": f"unknown action: {action}"}
591
+
592
+ except Exception as exc:
593
+ logger.exception("core_memory failed")
594
+ return {"success": False, "error": str(exc)}
@@ -26,7 +26,7 @@ from superlocalmemory.core.security_primitives import redact_secrets
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
28
  MAX_CALLS_PER_MINUTE = 30
29
- MAX_RESPONSE_BYTES = 16 * 1024 # 16 KB
29
+ MAX_RESPONSE_BYTES = 64 * 1024 # v3.4.65: raised from 16 KB, configurable via InjectionConfig
30
30
  WINDOW_SECONDS = 60.0
31
31
 
32
32
 
@@ -72,7 +72,11 @@ def _iso_now() -> str:
72
72
 
73
73
 
74
74
  def _cap_memory(memory: dict, *, max_text_bytes: int = 2048) -> dict:
75
- """Ensure each memory is bounded and redacted."""
75
+ """Ensure each memory is bounded and redacted.
76
+
77
+ v3.4.65: max_text_bytes default kept small for backward compat;
78
+ callers should pass cfg.per_memory_max_tokens * 4 for full fidelity.
79
+ """
76
80
  text = memory.get("text", "")
77
81
  if not isinstance(text, str):
78
82
  text = str(text)
@@ -135,7 +139,17 @@ def prestage_context(
135
139
  "truncated_count": 0,
136
140
  }
137
141
 
138
- capped = [_cap_memory(m) for m in raw if isinstance(m, dict)]
142
+ # v3.4.65: use InjectionConfig for per-memory and response caps.
143
+ try:
144
+ from superlocalmemory.core.config import SLMConfig
145
+ cfg_inj = SLMConfig.load().injection
146
+ per_mem_bytes = cfg_inj.per_memory_max_tokens * 4
147
+ resp_bytes = cfg_inj.prestage_max_response_bytes
148
+ except Exception:
149
+ per_mem_bytes = 2400 # 600 tokens * 4
150
+ resp_bytes = 64 * 1024
151
+
152
+ capped = [_cap_memory(m, max_text_bytes=per_mem_bytes) for m in raw if isinstance(m, dict)]
139
153
  capped = capped[:limit]
140
154
 
141
155
  # Enforce total response size cap (A11/16 KB).
@@ -147,7 +161,7 @@ def prestage_context(
147
161
  }
148
162
  encoded = json.dumps(response).encode("utf-8")
149
163
  truncated = 0
150
- while len(encoded) > MAX_RESPONSE_BYTES and response["memories"]:
164
+ while len(encoded) > resp_bytes and response["memories"]:
151
165
  response["memories"].pop()
152
166
  truncated += 1
153
167
  response["truncated_count"] = truncated
@@ -146,6 +146,43 @@ class BM25Channel:
146
146
  # Persist for cold start
147
147
  self._db.store_bm25_tokens(fact_id, profile_id, tokens)
148
148
 
149
+ def _fts5_search(
150
+ self, query: str, profile_id: str, top_k: int = 30,
151
+ ) -> list[tuple[str, float]]:
152
+ """v3.5.0: SQLite FTS5 keyword search (C-level indexed, scales to millions).
153
+
154
+ Uses the ``atomic_facts_fts`` external-content FTS5 table (kept in sync
155
+ by INSERT/DELETE/UPDATE triggers). Joins ``atomic_facts`` for profile
156
+ scoping (FTS table has no profile_id). ``bm25()`` returns a negative
157
+ score (lower = better match); we negate it to the channel's
158
+ "higher = better" convention. Returns [] on no matches.
159
+
160
+ Raises (OperationalError) if the FTS5 table is absent — the caller
161
+ then falls back to the legacy in-memory rank_bm25 path.
162
+ """
163
+ tokens = tokenize(query)
164
+ if not tokens:
165
+ return []
166
+ # Quote each token so query punctuation can't break FTS5 MATCH syntax;
167
+ # OR-join for high recall (any token may match).
168
+ match_expr = " OR ".join('"' + t.replace('"', "") + '"' for t in tokens)
169
+ sql = (
170
+ "SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
171
+ "FROM atomic_facts_fts "
172
+ "JOIN atomic_facts af ON af.rowid = atomic_facts_fts.rowid "
173
+ "WHERE atomic_facts_fts MATCH ? AND af.profile_id = ? "
174
+ "ORDER BY rank LIMIT ?"
175
+ )
176
+ rows = self._db.execute(sql, (match_expr, profile_id, int(top_k)))
177
+ out: list[tuple[str, float]] = []
178
+ for r in rows:
179
+ d = dict(r)
180
+ fid = d.get("fact_id")
181
+ if not fid:
182
+ continue
183
+ out.append((fid, -float(d.get("rank", 0.0))))
184
+ return out
185
+
149
186
  def search(
150
187
  self,
151
188
  query: str,
@@ -164,6 +201,19 @@ class BM25Channel:
164
201
  Returns:
165
202
  List of (fact_id, bm25_score) sorted by score descending.
166
203
  """
204
+ # v3.5.0: FTS5 fast path — C-level indexed, ~ms, scales to millions.
205
+ # The legacy in-memory rank_bm25 path rebuilt the whole index over the
206
+ # entire corpus on every corpus change (11s+ at 17.5k facts, does not
207
+ # scale). FTS5 (atomic_facts_fts, kept in sync by triggers) replaces it.
208
+ # Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
209
+ # unavailable (raises) — e.g. a pre-FTS legacy DB.
210
+ try:
211
+ return self._fts5_search(query, profile_id, top_k)
212
+ except Exception as exc: # pragma: no cover — legacy/missing FTS table
213
+ logger.debug(
214
+ "BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
215
+ )
216
+
167
217
  self.ensure_loaded(profile_id)
168
218
 
169
219
  if not self._corpus:
@@ -127,8 +127,20 @@ class RetrievalEngine:
127
127
  t0 = time.monotonic()
128
128
  self._extra_disabled = set(extra_disabled_channels or ())
129
129
 
130
+ # v3.5.0 diagnostic: stage timing inside retrieval (SLM_RECALL_TIMING=1).
131
+ import os as _os_e
132
+ import time as _time_e
133
+ _et = bool(_os_e.environ.get("SLM_RECALL_TIMING"))
134
+ _e0 = _time_e.monotonic()
135
+
136
+ def _em(_l: str) -> None:
137
+ if _et:
138
+ logger.warning("[RECALL-TIMING] engine.%-16s %.0f ms",
139
+ _l, (_time_e.monotonic() - _e0) * 1000.0)
140
+
130
141
  # 1. Classify query, get adaptive weights
131
142
  strat = self._strategy.classify(query, self._base_weights)
143
+ _em("classify")
132
144
 
133
145
  # Profile shortcut (runs before channel search)
134
146
  if self._profile_channel is not None:
@@ -149,12 +161,14 @@ class RetrievalEngine:
149
161
 
150
162
  # 3. Run 4 channels
151
163
  ch_results = self._run_channels(query, profile_id, strat)
164
+ _em("run_channels")
152
165
  if profile_hits:
153
166
  ch_results["profile"] = profile_hits
154
167
  total = sum(len(v) for v in ch_results.values())
155
168
 
156
169
  # 3. Single-pass RRF fusion
157
170
  fused = weighted_rrf(ch_results, strat.weights, k=self._config.rrf_k)
171
+ _em("rrf_fusion")
158
172
 
159
173
  # V3.3.21: Cross-channel intersection boost for multi-hop/temporal queries.
160
174
  # Problem: channels work in ISOLATION. "When did Caroline go to X?" needs
@@ -181,18 +195,23 @@ class RetrievalEngine:
181
195
  except Exception as exc:
182
196
  logger.warning("Bridge discovery: %s", exc)
183
197
 
184
- # Scene expansion
185
- if fused:
198
+ # Scene expansion (v3.5.0: batch + time-budgeted).
199
+ # Skip if channels already exceeded the per-recall time budget;
200
+ # the scene signal is nice-to-have, never worth delaying response.
201
+ if fused and (_time_e.monotonic() - _e0) < 0.8:
186
202
  try:
203
+ top_ids = [fr.fact_id for fr in fused[:20]]
204
+ scenes_map = self._db.get_scenes_for_facts_batch(top_ids, profile_id)
187
205
  expanded_ids: set[str] = set()
188
- for fr in fused[:20]:
189
- scenes = self._db.get_scenes_for_fact(fr.fact_id, profile_id)
190
- for scene in scenes[:2]:
206
+ for fid in top_ids:
207
+ for scene in scenes_map.get(fid, [])[:2]:
191
208
  for sfid in scene.fact_ids:
192
209
  if not any(f.fact_id == sfid for f in fused) and sfid not in expanded_ids:
193
210
  expanded_ids.add(sfid)
194
211
  fused.append(FusionResult(
195
- fact_id=sfid, fused_score=fr.fused_score * 0.8,
212
+ fact_id=sfid, fused_score=(
213
+ next((f.fused_score for f in fused if f.fact_id == fid), 0.5) * 0.8
214
+ ),
196
215
  channel_ranks={}, channel_scores={},
197
216
  ))
198
217
  except Exception as exc:
@@ -204,7 +223,8 @@ class RetrievalEngine:
204
223
  # Research: Microsoft GraphRAG DRIFT, Pistis-RAG cascaded architecture.
205
224
  if (self._entity is not None
206
225
  and "entity_graph" not in set(self._config.disabled_channels)
207
- and fused):
226
+ and fused
227
+ and (_time_e.monotonic() - _e0) < 0.9):
208
228
  try:
209
229
  candidate_ids = [fr.fact_id for fr in fused[:100]]
210
230
  eg_scores = self._entity.score_candidates(
@@ -229,10 +249,12 @@ class RetrievalEngine:
229
249
  except Exception as exc:
230
250
  logger.warning("Entity graph signal enhancement: %s", exc)
231
251
 
252
+ _em("expand+entity_enh")
232
253
  # 4. Load facts for rerank pool
233
254
  pool = min(len(fused), max(effective_limit * 3, 30))
234
255
  top = fused[:pool]
235
256
  facts = self._load_facts(top, profile_id)
257
+ _em("load_facts")
236
258
 
237
259
  # V3.3.21: Session diversity for aggregation queries.
238
260
  if strat.query_type == "aggregation" and facts:
@@ -250,6 +272,7 @@ class RetrievalEngine:
250
272
  if reranker_ready and facts:
251
273
  ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
252
274
  top = self._apply_reranker(query, top, facts, alpha=ce_alpha)
275
+ _em(f"rerank(ready={reranker_ready})")
253
276
 
254
277
  # V3.4.11: Channel diversity — guarantee entity_graph results appear in
255
278
  # the final output. Applied AFTER reranker so results can't be pushed out.
@@ -462,6 +485,9 @@ class RetrievalEngine:
462
485
  3-5x speedup for the channel phase.
463
486
  """
464
487
  import concurrent.futures
488
+ import os as _os_e
489
+ import time as _time_e
490
+ _et = bool(_os_e.environ.get("SLM_RECALL_TIMING"))
465
491
  out: dict[str, list[tuple[str, float]]] = {}
466
492
  # Skip channels listed in disabled_channels (ablation support)
467
493
  # V3.4.40: union with per-recall extra_disabled set (e.g. --fast skip)
@@ -492,8 +518,12 @@ class RetrievalEngine:
492
518
 
493
519
  def _safe_channel(name: str, fn, *args):
494
520
  """Run a single channel, returning (name, result_or_None)."""
521
+ _cs = _time_e.monotonic() if _et else 0.0
495
522
  try:
496
523
  res = fn(*args)
524
+ if _et:
525
+ logger.warning("[RECALL-TIMING] channel.%-16s %.0f ms",
526
+ name, (_time_e.monotonic() - _cs) * 1000.0)
497
527
  return (name, res if res else None)
498
528
  except Exception as exc:
499
529
  logger.warning("%s channel: %s", name, exc)
@@ -735,9 +765,13 @@ class RetrievalEngine:
735
765
  trust_weight, raw_trust = self._get_trust_weight(fact, profile_id)
736
766
 
737
767
  boosted_score = fr.fused_score * recency_boost * quality * trust_weight
738
- confidence = min(1.0, boosted_score * 10.0) * fact.confidence
768
+ # v3.5.0 (M2): soft-normalize to [0,1]. RRF weights + scene/entity
769
+ # boosts push raw scores well above 1 (observed: 27.97). A sigmoid
770
+ # preserves rank (monotonic) while giving users a readable 0-1 range.
771
+ normalized_score = 1.0 / (1.0 + math.exp(-boosted_score * 0.5))
772
+ confidence = min(1.0, normalized_score * 10.0) * fact.confidence
739
773
  results.append(RetrievalResult(
740
- fact=fact, score=boosted_score,
774
+ fact=fact, score=round(normalized_score, 4),
741
775
  channel_scores=fr.channel_scores,
742
776
  confidence=confidence, evidence_chain=evidence,
743
777
  trust_score=raw_trust,
@@ -145,18 +145,31 @@ class HopfieldChannel:
145
145
  )
146
146
  return []
147
147
 
148
- # Step 4: Get memory matrix
148
+ # Step 4 (v3.5.0 FIX): route by the cheap total_count BEFORE building
149
+ # the full memory matrix. Previously _get_memory_matrix() loaded and
150
+ # normalized ALL embeddings (~6s at 17.5k facts) even when the prefilter
151
+ # path was taken — and that path builds its own ANN sub-matrix and never
152
+ # uses the full one. So the entire 6s build was wasted on large stores.
153
+ # Now the full matrix is built ONLY for small stores (<= prefilter_threshold).
154
+ vs_ok = bool(
155
+ self._vector_store
156
+ and getattr(self._vector_store, "available", False)
157
+ )
158
+ # v3.5.0 FIX: prefer the bounded ANN-prefilter path whenever a vector
159
+ # store is available and the corpus is larger than the candidate set.
160
+ # The full-matrix path loads & L2-normalizes EVERY embedding from SQLite
161
+ # (~6s at 17.5k facts), so it's only worth it for tiny stores or when no
162
+ # VS exists. Routing on prefilter_candidates (not prefilter_threshold)
163
+ # ensures the matrix is always bounded to ~prefilter_candidates rows.
164
+ if vs_ok and total_count > self._config.prefilter_candidates:
165
+ return self._search_with_prefilter(q_vec, profile_id, [], top_k)
166
+
167
+ # Tiny store (or no VS): build (cached) full matrix.
149
168
  memory_matrix, fact_ids = self._get_memory_matrix(profile_id)
150
-
151
- # Step 5: Empty check
152
169
  if memory_matrix is None or len(fact_ids) == 0:
153
170
  return []
154
-
155
- # Step 6/7: Route by size
156
- if len(fact_ids) > self._config.prefilter_threshold:
157
- return self._search_with_prefilter(
158
- q_vec, profile_id, fact_ids, top_k,
159
- )
171
+ if vs_ok and len(fact_ids) > self._config.prefilter_candidates:
172
+ return self._search_with_prefilter(q_vec, profile_id, fact_ids, top_k)
160
173
  return self._search_full_matrix(
161
174
  q_vec, memory_matrix, fact_ids, top_k,
162
175
  )
@@ -32,9 +32,18 @@ _MAX_PROXIMITY_DAYS: float = 365.0
32
32
  def _parse_iso(s: str | None) -> datetime | None:
33
33
  if not s:
34
34
  return None
35
+ # v3.5.0 perf: stored dates are ISO-8601, so try the C-level
36
+ # datetime.fromisoformat (~1µs) FIRST. dateutil.parser.parse is ~100x
37
+ # slower (~100µs) and the temporal channel parses up to 4 dates per event
38
+ # across thousands of events — that was ~2.6s of the recall. dateutil is
39
+ # now only the fallback for non-ISO strings.
40
+ try:
41
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
42
+ except (ValueError, TypeError):
43
+ pass
35
44
  try:
36
45
  return dateutil_parse(s)
37
- except (ParserError, ValueError, OverflowError):
46
+ except (ParserError, ValueError, OverflowError, TypeError):
38
47
  return None
39
48
 
40
49
 
@@ -417,7 +417,7 @@ async def search_memories(request: Request, body: SearchRequest):
417
417
  # directly in an async route blocks the ASGI event loop — Chrome detects
418
418
  # a stalled connection and aborts with "signal is aborted without reason"
419
419
  # before the response arrives. Fix: run in a thread-pool executor so the
420
- # event loop stays alive to send keepalive frames. Also fast=True skips
420
+ # event loop stays alive to send keepalive frames. Also fast=False skips
421
421
  # spreading_activation + Hopfield (saves ~7s on cold graph traversal).
422
422
  import asyncio
423
423
  import time as _time
@@ -427,7 +427,7 @@ async def search_memories(request: Request, body: SearchRequest):
427
427
  t0 = _time.monotonic()
428
428
  response = await loop.run_in_executor(
429
429
  None,
430
- lambda: engine.recall(body.query, limit=body.limit, fast=True),
430
+ lambda: engine.recall(body.query, limit=body.limit, fast=False),
431
431
  )
432
432
  elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
433
433
  results = []
@@ -514,45 +514,60 @@ async def set_provider(request: Request):
514
514
 
515
515
  @router.post("/recall/trace")
516
516
  async def recall_trace(request: Request):
517
- """Recall with per-channel score breakdown."""
517
+ """Recall with per-channel score breakdown.
518
+
519
+ v3.4.64: Replaced WorkerPool.shared() (subprocess, blocks event loop,
520
+ worker crashes after ~15s) with daemon engine via run_in_executor.
521
+ Same fix as POST /api/search in v3.4.63.
522
+ """
523
+ import asyncio
524
+ import time as _time
518
525
  try:
519
526
  body = await request.json()
520
527
  query = body.get("query", "")
521
528
  limit = body.get("limit", 10)
522
529
 
523
- from superlocalmemory.core.worker_pool import WorkerPool
524
- pool = WorkerPool.shared()
525
- result = pool.recall(query, limit=limit)
526
-
527
- if not result.get("ok"):
528
- return JSONResponse(
529
- {"error": result.get("error", "Recall failed")},
530
- status_code=503,
531
- )
532
-
533
- # Optional: synthesize answer from results (Mode B/C only)
534
- synthesis = ""
535
- if body.get("synthesize") and result.get("results"):
536
- try:
537
- syn_result = pool.synthesize(query, result["results"][:5])
538
- synthesis = syn_result.get("synthesis", "") if syn_result.get("ok") else ""
539
- except Exception:
540
- pass
530
+ # Use daemon engine — already loaded, shares warm page cache.
531
+ # run_in_executor keeps event loop alive so browser doesn't abort.
532
+ from .helpers import get_engine_lazy
533
+ engine = get_engine_lazy(request.app.state)
534
+ if engine is None:
535
+ return JSONResponse({"error": "Engine not initialised"}, status_code=503)
536
+
537
+ loop = asyncio.get_event_loop()
538
+ t0 = _time.monotonic()
539
+ response = await loop.run_in_executor(
540
+ None,
541
+ lambda: engine.recall(query, limit=limit, fast=False),
542
+ )
543
+ elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
544
+
545
+ results = []
546
+ for r in response.results[:limit]:
547
+ results.append({
548
+ "fact_id": r.fact.fact_id,
549
+ "memory_id": getattr(r.fact, "memory_id", ""),
550
+ "content": r.fact.content[:300],
551
+ "score": round(r.score, 4),
552
+ "confidence": round(getattr(r, "confidence", 0.0), 4),
553
+ "channel_scores": getattr(r, "channel_scores", {}),
554
+ "created_at": getattr(r.fact, "created_at", ""),
555
+ })
541
556
 
542
557
  # Record learning signals (non-blocking, non-critical)
543
558
  try:
544
- _record_learning_signals(query, result.get("results", []))
559
+ _record_learning_signals(query, results)
545
560
  except Exception as _sig_exc:
546
561
  import logging as _log
547
562
  _log.getLogger(__name__).warning("Learning signal error: %s", _sig_exc)
548
563
 
549
564
  return {
550
565
  "query": query,
551
- "query_type": result.get("query_type", "unknown"),
552
- "result_count": result.get("result_count", 0),
553
- "retrieval_time_ms": result.get("retrieval_time_ms", 0),
554
- "results": result.get("results", []),
555
- "synthesis": synthesis,
566
+ "query_type": getattr(response, "query_type", "semantic"),
567
+ "result_count": len(results),
568
+ "retrieval_time_ms": elapsed_ms,
569
+ "results": results,
570
+ "synthesis": "",
556
571
  }
557
572
  except Exception as e:
558
573
  return JSONResponse({"error": str(e)}, status_code=500)