superlocalmemory 3.4.51 → 3.4.53
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.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/ollama_embedder.py +14 -4
- package/src/superlocalmemory/mcp/_daemon_proxy.py +1 -1
- package/src/superlocalmemory/mcp/tools_active.py +101 -1
- package/src/superlocalmemory/retrieval/engine.py +56 -42
- package/src/superlocalmemory/retrieval/reranker.py +44 -27
- package/src/superlocalmemory/retrieval/spreading_activation.py +5 -2
- package/src/superlocalmemory/server/unified_daemon.py +94 -6
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.4.52] - 2026-05-28 — Warm Memory, No Cold Starts
|
|
9
|
+
|
|
10
|
+
**Production resilience for session_init.** No quality degradation as the primary path: full 6-channel recall (semantic + BM25 + entity + temporal + Hopfield + spreading-activation, Fisher-Rao fusion) is preserved. The cold-start problem is fixed at the infrastructure layer, not by downgrading retrieval.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Ollama embedding model unloads after 5 min idle** (`core/ollama_embedder.py`) — `_call_ollama_embed` and `_call_ollama_embed_batch` did not pass `keep_alive` to Ollama, so the embedder defaulted to 5-minute residency. After idle, next call required a 20-30s model reload from disk → DaemonPoolProxy's 30s HTTP timeout occasionally aborted → MCP clients (Hermes, CommandCode) saw `session init failed (connection error)`. Now both calls pass `keep_alive: -1`, pinning `nomic-embed-text` (~274 MB) in VRAM forever. Industry-standard pattern used by Hindsight, Zep, Supermemory.
|
|
14
|
+
- **DaemonPoolProxy HTTP timeout increased 30s → 60s** (`mcp/_daemon_proxy.py`) — Safety net for unexpected slowness during daemon restart windows. With keep_alive=-1 in place, this almost never matters, but it removes the cliff edge.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Emergency FTS5 BM25 fallback in `session_init`** (`mcp/tools_active.py`) — When the daemon is completely unreachable (truly dead, not just slow), `session_init` falls back to a direct SQLite query against the existing `atomic_facts_fts` virtual table with native BM25 ranking via `ORDER BY fts.rank`. Multi-process safe via WAL mode. Response includes explicit `degraded_mode: true` and `retrieval_mode: "emergency_fts5_bm25"` flags (Zep "Memory Unavailable" pattern) so agents can surface the degraded state to the user. This is the Mem0 / Letta industry-standard fallback — real BM25 math, not keyword LIKE.
|
|
18
|
+
- **`/health` reports `embedding_warm` flag** (`server/unified_daemon.py`) — MCP clients can poll the daemon's health endpoint to wait for the embedding model to finish loading before issuing recall calls. Set to `true` once the async pre-warm thread completes its first `embedder.embed("warmup")` call.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **`session_init` reverted to full 6-channel recall** (`mcp/tools_active.py`) — v3.4.51 had downgraded `session_init` to `fast=True` (BM25 only) as a timeout workaround. v3.4.52 restores full 6-channel recall as the primary path — quality is no longer compromised. Cold-start is prevented at the Ollama layer instead.
|
|
22
|
+
|
|
23
|
+
### Why this matters
|
|
24
|
+
A memory system's value is its retrieval quality. Degrading to BM25-only at session start would mean every agent session begins with degraded memory — exactly the opposite of what users expect. v3.4.52 fixes the actual root cause (Ollama cold-start) and reserves the BM25 fallback for true catastrophic failures (daemon completely dead). The agent is told explicitly via `degraded_mode` when this happens.
|
|
25
|
+
|
|
8
26
|
## [3.4.51] - 2026-05-28 — Recency Intelligence
|
|
9
27
|
|
|
10
28
|
**Session context is now time-aware.** Stale memories from completed projects and old debugging sessions no longer surface at session start. Frequently-recalled architectural decisions resist decay automatically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.53",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
|
@@ -204,12 +204,18 @@ class OllamaEmbedder:
|
|
|
204
204
|
return False
|
|
205
205
|
|
|
206
206
|
def _call_ollama_embed(self, text: str) -> list[float]:
|
|
207
|
-
"""Call Ollama embed endpoint for a single text.
|
|
207
|
+
"""Call Ollama embed endpoint for a single text.
|
|
208
|
+
|
|
209
|
+
v3.4.52: ``keep_alive: -1`` pins the embedding model in VRAM
|
|
210
|
+
forever so subsequent calls have no cold-start latency. Industry
|
|
211
|
+
pattern (Hindsight, Zep, Supermemory) — without this, Ollama
|
|
212
|
+
unloads after 5min idle and the next call takes 20-30s.
|
|
213
|
+
"""
|
|
208
214
|
import httpx
|
|
209
215
|
|
|
210
216
|
resp = httpx.post(
|
|
211
217
|
f"{self._base_url}/api/embed",
|
|
212
|
-
json={"model": self._model, "input": [text]},
|
|
218
|
+
json={"model": self._model, "input": [text], "keep_alive": -1},
|
|
213
219
|
timeout=httpx.Timeout(_RESPONSE_TIMEOUT, connect=_CONNECT_TIMEOUT),
|
|
214
220
|
)
|
|
215
221
|
resp.raise_for_status()
|
|
@@ -219,12 +225,16 @@ class OllamaEmbedder:
|
|
|
219
225
|
return self._normalize(vec)
|
|
220
226
|
|
|
221
227
|
def _call_ollama_embed_batch(self, texts: list[str]) -> list[list[float] | None]:
|
|
222
|
-
"""Call Ollama embed endpoint with batch input.
|
|
228
|
+
"""Call Ollama embed endpoint with batch input.
|
|
229
|
+
|
|
230
|
+
v3.4.52: ``keep_alive: -1`` pins the embedding model — see
|
|
231
|
+
``_call_ollama_embed`` docstring for rationale.
|
|
232
|
+
"""
|
|
223
233
|
import httpx
|
|
224
234
|
|
|
225
235
|
resp = httpx.post(
|
|
226
236
|
f"{self._base_url}/api/embed",
|
|
227
|
-
json={"model": self._model, "input": texts},
|
|
237
|
+
json={"model": self._model, "input": texts, "keep_alive": -1},
|
|
228
238
|
timeout=httpx.Timeout(_RESPONSE_TIMEOUT, connect=_CONNECT_TIMEOUT),
|
|
229
239
|
)
|
|
230
240
|
resp.raise_for_status()
|
|
@@ -36,7 +36,7 @@ class DaemonPoolProxy:
|
|
|
36
36
|
envelopes — the adapter is responsible for surfacing those.
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
|
-
def __init__(self, port: int, *, timeout_s: float =
|
|
39
|
+
def __init__(self, port: int, *, timeout_s: float = 60.0) -> None:
|
|
40
40
|
self._port = port
|
|
41
41
|
self._timeout = timeout_s
|
|
42
42
|
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
|
+
import sqlite3
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
from typing import Callable
|
|
23
24
|
|
|
@@ -27,6 +28,81 @@ MEMORY_DIR = Path.home() / ".superlocalmemory"
|
|
|
27
28
|
DB_PATH = MEMORY_DIR / "memory.db"
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _sqlite_emergency_recall(
|
|
32
|
+
query: str, limit: int, profile_id: str = "default",
|
|
33
|
+
max_age_days: int = 30,
|
|
34
|
+
) -> "PoolRecallResponse":
|
|
35
|
+
"""Emergency fallback: direct SQLite FTS5 BM25 when daemon is unreachable.
|
|
36
|
+
|
|
37
|
+
Uses the same ``atomic_facts_fts`` virtual table the daemon uses, with
|
|
38
|
+
native BM25 ranking via ``ORDER BY fts.rank``. This is the Mem0 / Letta
|
|
39
|
+
industry pattern — multi-process safe via SQLite WAL mode.
|
|
40
|
+
|
|
41
|
+
Quality degraded vs full 6-channel (no semantic, no entity graph, no
|
|
42
|
+
temporal/spreading-activation/Hopfield) but still provides real BM25
|
|
43
|
+
math + age gate. Returns ``degraded_mode=True`` via the caller's flag.
|
|
44
|
+
|
|
45
|
+
Used ONLY when Tier-1 (full daemon recall) fails completely. Normal
|
|
46
|
+
path is full 6-channel; this is the fire-alarm.
|
|
47
|
+
"""
|
|
48
|
+
from superlocalmemory.mcp._pool_adapter import PoolFact, PoolRecallItem, PoolRecallResponse
|
|
49
|
+
import re
|
|
50
|
+
try:
|
|
51
|
+
# FTS5 MATCH syntax: tokenize the query, drop special characters
|
|
52
|
+
# that confuse the parser (/, :, ., etc), and join with OR for
|
|
53
|
+
# broadest matching. Wrap each term in quotes to escape any
|
|
54
|
+
# remaining special-meaning chars.
|
|
55
|
+
tokens = re.findall(r"[A-Za-z0-9]+", query)
|
|
56
|
+
tokens = [t for t in tokens if len(t) >= 2]
|
|
57
|
+
if not tokens:
|
|
58
|
+
return PoolRecallResponse()
|
|
59
|
+
safe_query = " OR ".join(f'"{t}"' for t in tokens)
|
|
60
|
+
age_clause = (
|
|
61
|
+
f"AND f.created_at >= datetime('now', '-{int(max_age_days)} days') "
|
|
62
|
+
if max_age_days > 0 else ""
|
|
63
|
+
)
|
|
64
|
+
conn = sqlite3.connect(str(DB_PATH), timeout=5.0)
|
|
65
|
+
try:
|
|
66
|
+
rows = conn.execute(
|
|
67
|
+
f"""SELECT f.fact_id, f.content, f.memory_id, f.created_at,
|
|
68
|
+
fts.rank AS bm25_rank
|
|
69
|
+
FROM atomic_facts_fts AS fts
|
|
70
|
+
JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
|
|
71
|
+
WHERE fts.atomic_facts_fts MATCH ?
|
|
72
|
+
AND f.profile_id = ?
|
|
73
|
+
{age_clause}
|
|
74
|
+
ORDER BY fts.rank
|
|
75
|
+
LIMIT ?""",
|
|
76
|
+
(safe_query, profile_id, limit * 2),
|
|
77
|
+
).fetchall()
|
|
78
|
+
finally:
|
|
79
|
+
conn.close()
|
|
80
|
+
# FTS5 rank is negative (lower = better). Normalize to [0.3, 0.9].
|
|
81
|
+
if not rows:
|
|
82
|
+
return PoolRecallResponse()
|
|
83
|
+
ranks = [r[4] for r in rows]
|
|
84
|
+
rmin, rmax = min(ranks), max(ranks)
|
|
85
|
+
rng = (rmax - rmin) or 1.0
|
|
86
|
+
items = [
|
|
87
|
+
PoolRecallItem(
|
|
88
|
+
fact=PoolFact(
|
|
89
|
+
fact_id=r[0] or "", content=r[1] or "",
|
|
90
|
+
memory_id=r[2] or "", created_at=r[3] or "",
|
|
91
|
+
),
|
|
92
|
+
score=round(0.3 + 0.6 * (1.0 - (r[4] - rmin) / rng), 3),
|
|
93
|
+
)
|
|
94
|
+
for r in rows
|
|
95
|
+
]
|
|
96
|
+
logger.warning(
|
|
97
|
+
"session_init: EMERGENCY FTS5 fallback (%d results). "
|
|
98
|
+
"Daemon unreachable — semantic/graph channels disabled.", len(items),
|
|
99
|
+
)
|
|
100
|
+
return PoolRecallResponse(results=items[:limit])
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
logger.warning("Emergency FTS5 fallback failed: %s", exc)
|
|
103
|
+
return PoolRecallResponse()
|
|
104
|
+
|
|
105
|
+
|
|
30
106
|
def _get_agent_id(default: str = "mcp_client") -> str:
|
|
31
107
|
"""Resolve the calling agent's ID for attribution.
|
|
32
108
|
|
|
@@ -127,7 +203,29 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
127
203
|
else:
|
|
128
204
|
search_query = "recent important decisions"
|
|
129
205
|
|
|
130
|
-
|
|
206
|
+
# 2-tier recall (industry pattern: Hindsight / Zep / Supermemory):
|
|
207
|
+
# PRIMARY: full 6-channel via daemon (semantic + BM25 + entity + temporal
|
|
208
|
+
# + Hopfield + spreading-activation, Fisher-Rao fusion, FSRS decay).
|
|
209
|
+
# Fast because Ollama embed model is kept warm (keep_alive=-1
|
|
210
|
+
# + eager pre-warm at daemon boot).
|
|
211
|
+
# EMERGENCY: direct FTS5 BM25 (Mem0 / Letta pattern). Used ONLY when
|
|
212
|
+
# daemon is completely unreachable. Returns degraded_mode=True.
|
|
213
|
+
from superlocalmemory.mcp._pool_adapter import PoolError
|
|
214
|
+
degraded_mode = False
|
|
215
|
+
try:
|
|
216
|
+
response = pool_recall(search_query, limit=max_results, fast=False)
|
|
217
|
+
except (PoolError, Exception) as exc:
|
|
218
|
+
logger.warning(
|
|
219
|
+
"session_init: daemon recall failed (%s) — using FTS5 emergency fallback. "
|
|
220
|
+
"Memory system is in DEGRADED MODE: semantic/graph channels unavailable.",
|
|
221
|
+
exc,
|
|
222
|
+
)
|
|
223
|
+
response = _sqlite_emergency_recall(
|
|
224
|
+
search_query, max_results,
|
|
225
|
+
profile_id=engine.profile_id,
|
|
226
|
+
max_age_days=max_age_days,
|
|
227
|
+
)
|
|
228
|
+
degraded_mode = True
|
|
131
229
|
|
|
132
230
|
# Age gate: suppress stale memories at session start.
|
|
133
231
|
# Memories older than max_age_days are excluded unless their score
|
|
@@ -201,6 +299,8 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
201
299
|
"context": context,
|
|
202
300
|
"memories": memories[:max_results],
|
|
203
301
|
"memory_count": len(memories),
|
|
302
|
+
"degraded_mode": degraded_mode,
|
|
303
|
+
"retrieval_mode": "emergency_fts5_bm25" if degraded_mode else "full_6_channel",
|
|
204
304
|
"learning": {
|
|
205
305
|
"feedback_signals": feedback_count,
|
|
206
306
|
"phase": 1 if feedback_count < 50 else (2 if feedback_count < 200 else 3),
|
|
@@ -451,7 +451,17 @@ class RetrievalEngine:
|
|
|
451
451
|
def _run_channels(
|
|
452
452
|
self, query: str, profile_id: str, strat: QueryStrategy,
|
|
453
453
|
) -> dict[str, list[tuple[str, float]]]:
|
|
454
|
-
"""Run active retrieval channels.
|
|
454
|
+
"""Run active retrieval channels.
|
|
455
|
+
|
|
456
|
+
v3.4.53: channels run in PARALLEL via ThreadPoolExecutor. Industry
|
|
457
|
+
standard (EverMemOS, szl-recall, ContentPilot 2026): all channels
|
|
458
|
+
are independent after embedding; running them serially wastes time
|
|
459
|
+
equal to the sum of all channel latencies. Parallel execution brings
|
|
460
|
+
total channel time from sum(semantic+bm25+entity+temporal+hopfield+sa)
|
|
461
|
+
down to max(semantic,bm25,entity,temporal,hopfield,sa) — roughly a
|
|
462
|
+
3-5x speedup for the channel phase.
|
|
463
|
+
"""
|
|
464
|
+
import concurrent.futures
|
|
455
465
|
out: dict[str, list[tuple[str, float]]] = {}
|
|
456
466
|
# Skip channels listed in disabled_channels (ablation support)
|
|
457
467
|
# V3.4.40: union with per-recall extra_disabled set (e.g. --fast skip)
|
|
@@ -475,51 +485,55 @@ class RetrievalEngine:
|
|
|
475
485
|
except Exception as exc:
|
|
476
486
|
logger.warning("Query embedding failed: %s", exc)
|
|
477
487
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
out["semantic"] = r
|
|
483
|
-
except Exception as exc:
|
|
484
|
-
logger.warning("Semantic channel: %s", exc)
|
|
485
|
-
|
|
486
|
-
if self._bm25 is not None and "bm25" not in disabled:
|
|
487
|
-
try:
|
|
488
|
-
r = self._bm25.search(query, profile_id, self._config.bm25_top_k)
|
|
489
|
-
if r:
|
|
490
|
-
out["bm25"] = r
|
|
491
|
-
except Exception as exc:
|
|
492
|
-
logger.warning("BM25 channel: %s", exc)
|
|
493
|
-
|
|
494
|
-
# V3.4.12: entity_graph is now a signal enhancer (post-RRF boost),
|
|
495
|
-
# not an independent channel. Removed from channel execution to avoid
|
|
496
|
-
# running spreading activation twice. See score_candidates() in engine.recall().
|
|
497
|
-
|
|
498
|
-
if self._temporal is not None and "temporal" not in disabled:
|
|
499
|
-
try:
|
|
500
|
-
r = self._temporal.search(query, profile_id, top_k=self._config.bm25_top_k)
|
|
501
|
-
if r:
|
|
502
|
-
out["temporal"] = r
|
|
503
|
-
except Exception as exc:
|
|
504
|
-
logger.warning("Temporal channel: %s", exc)
|
|
488
|
+
# v3.4.53: collect channel callables and run in parallel.
|
|
489
|
+
# Each channel is a standalone search — no shared mutable state,
|
|
490
|
+
# no ordering dependencies. SQLite WAL mode permits concurrent reads.
|
|
491
|
+
futures: dict[str, concurrent.futures.Future] = {}
|
|
505
492
|
|
|
506
|
-
|
|
507
|
-
|
|
493
|
+
def _safe_channel(name: str, fn, *args):
|
|
494
|
+
"""Run a single channel, returning (name, result_or_None)."""
|
|
508
495
|
try:
|
|
509
|
-
|
|
510
|
-
if
|
|
511
|
-
out["hopfield"] = r
|
|
496
|
+
res = fn(*args)
|
|
497
|
+
return (name, res if res else None)
|
|
512
498
|
except Exception as exc:
|
|
513
|
-
logger.warning("
|
|
499
|
+
logger.warning("%s channel: %s", name, exc)
|
|
500
|
+
return (name, None)
|
|
501
|
+
|
|
502
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
|
503
|
+
if self._semantic is not None and q_emb is not None and "semantic" not in disabled:
|
|
504
|
+
futures["semantic"] = executor.submit(
|
|
505
|
+
_safe_channel, "semantic",
|
|
506
|
+
self._semantic.search, q_emb, profile_id, self._config.semantic_top_k,
|
|
507
|
+
)
|
|
508
|
+
if self._bm25 is not None and "bm25" not in disabled:
|
|
509
|
+
futures["bm25"] = executor.submit(
|
|
510
|
+
_safe_channel, "bm25",
|
|
511
|
+
self._bm25.search, query, profile_id, self._config.bm25_top_k,
|
|
512
|
+
)
|
|
513
|
+
if self._temporal is not None and "temporal" not in disabled:
|
|
514
|
+
futures["temporal"] = executor.submit(
|
|
515
|
+
_safe_channel, "temporal",
|
|
516
|
+
self._temporal.search, query, profile_id, self._config.bm25_top_k,
|
|
517
|
+
)
|
|
518
|
+
if self._hopfield is not None and q_emb is not None and "hopfield" not in disabled:
|
|
519
|
+
futures["hopfield"] = executor.submit(
|
|
520
|
+
_safe_channel, "hopfield",
|
|
521
|
+
self._hopfield.search, q_emb, profile_id, self._config.hopfield_top_k,
|
|
522
|
+
)
|
|
523
|
+
if self._spreading_activation is not None and q_emb is not None and "spreading_activation" not in disabled:
|
|
524
|
+
futures["spreading_activation"] = executor.submit(
|
|
525
|
+
_safe_channel, "spreading_activation",
|
|
526
|
+
self._spreading_activation.search, q_emb, profile_id, self._config.bm25_top_k,
|
|
527
|
+
)
|
|
514
528
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
529
|
+
# Collect results as channels complete
|
|
530
|
+
for name, fut in futures.items():
|
|
531
|
+
try:
|
|
532
|
+
ch_name, result = fut.result(timeout=30)
|
|
533
|
+
if result:
|
|
534
|
+
out[ch_name] = result
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
logger.warning("Channel %s timed out or failed: %s", name, exc)
|
|
523
537
|
|
|
524
538
|
# Apply registered post-retrieval filters (forgetting filter, etc.)
|
|
525
539
|
if hasattr(self, '_registry') and self._registry._filters:
|
|
@@ -56,7 +56,11 @@ _IDLE_TIMEOUT_SECONDS = 300 # V3.4.37: 5 min (was 30) — balance cold-start vs
|
|
|
56
56
|
# V3.4.19: Bumped from 120 → 1800 in lock-step with the embedding worker.
|
|
57
57
|
# Set ``SLM_RERANKER_IDLE_TIMEOUT=120`` + ``slm restart`` to revert.
|
|
58
58
|
_IDLE_TIMEOUT_SECONDS = int(os.environ.get("SLM_RERANKER_IDLE_TIMEOUT", _IDLE_TIMEOUT_SECONDS))
|
|
59
|
-
_SUBPROCESS_RESPONSE_TIMEOUT =
|
|
59
|
+
_SUBPROCESS_RESPONSE_TIMEOUT = 15 # v3.4.52: 15s (was 180s). Long timeout blocked the
|
|
60
|
+
# entire FastAPI event loop — a dead reranker subprocess held ALL
|
|
61
|
+
# endpoints hostage for 3 minutes. 15s is enough for ONNX inference
|
|
62
|
+
# cold start; if the worker can't respond, we fall back to fusion
|
|
63
|
+
# scores without reranking.
|
|
60
64
|
_WORKER_RECYCLE_AFTER = 500 # Recycle after N requests
|
|
61
65
|
|
|
62
66
|
|
|
@@ -231,16 +235,26 @@ class CrossEncoderReranker:
|
|
|
231
235
|
logger.warning("Failed to spawn reranker worker: %s", exc)
|
|
232
236
|
self._worker_proc = None
|
|
233
237
|
|
|
234
|
-
def _send_request(self, req: dict, timeout: float | None = None
|
|
238
|
+
def _send_request(self, req: dict, timeout: float | None = None,
|
|
239
|
+
block: bool = True) -> dict | None:
|
|
235
240
|
"""Send JSON request to worker, get response. Thread-safe.
|
|
236
241
|
|
|
237
242
|
Uses a short timeout (10s) for rerank requests since the model
|
|
238
243
|
should already be loaded by the background warmup. Uses the full
|
|
239
244
|
timeout only for explicit load/ping commands.
|
|
245
|
+
|
|
246
|
+
v3.4.52: when ``block=False``, uses ``try_lock`` instead of
|
|
247
|
+
``lock.acquire()``. If another thread is already using the
|
|
248
|
+
reranker subprocess, returns ``None`` immediately (the caller
|
|
249
|
+
falls back to fusion scores without reranking). This prevents
|
|
250
|
+
concurrent recall requests from serialising on the lock.
|
|
240
251
|
"""
|
|
241
252
|
effective_timeout = timeout or _SUBPROCESS_RESPONSE_TIMEOUT
|
|
242
253
|
|
|
243
|
-
|
|
254
|
+
acquired = self._lock.acquire(blocking=block)
|
|
255
|
+
if not acquired:
|
|
256
|
+
return None # another request is using the subprocess
|
|
257
|
+
try:
|
|
244
258
|
if self._request_count >= _WORKER_RECYCLE_AFTER and self._worker_proc is not None:
|
|
245
259
|
logger.info("Recycling reranker worker after %d requests", self._request_count)
|
|
246
260
|
self._kill_worker()
|
|
@@ -253,31 +267,32 @@ class CrossEncoderReranker:
|
|
|
253
267
|
if self._worker_proc is None:
|
|
254
268
|
return None
|
|
255
269
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
self._worker_proc.stdin.flush()
|
|
270
|
+
msg = json.dumps(req) + "\n"
|
|
271
|
+
self._worker_proc.stdin.write(msg)
|
|
272
|
+
self._worker_proc.stdin.flush()
|
|
260
273
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
self._kill_worker()
|
|
268
|
-
self._model_loaded = False
|
|
269
|
-
return None
|
|
270
|
-
|
|
271
|
-
resp = json.loads(resp_line)
|
|
272
|
-
self._reset_idle_timer()
|
|
273
|
-
self._request_count += 1
|
|
274
|
-
return resp
|
|
275
|
-
except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
|
|
276
|
-
logger.warning("Reranker worker communication failed: %s", exc)
|
|
274
|
+
resp_line = self._readline_with_timeout(
|
|
275
|
+
self._worker_proc.stdout,
|
|
276
|
+
effective_timeout,
|
|
277
|
+
)
|
|
278
|
+
if not resp_line:
|
|
279
|
+
logger.warning("Reranker worker timed out after %ds", effective_timeout)
|
|
277
280
|
self._kill_worker()
|
|
278
281
|
self._model_loaded = False
|
|
279
282
|
return None
|
|
280
283
|
|
|
284
|
+
resp = json.loads(resp_line)
|
|
285
|
+
self._reset_idle_timer()
|
|
286
|
+
self._request_count += 1
|
|
287
|
+
return resp
|
|
288
|
+
except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
|
|
289
|
+
logger.warning("Reranker worker communication failed: %s", exc)
|
|
290
|
+
self._kill_worker()
|
|
291
|
+
self._model_loaded = False
|
|
292
|
+
return None
|
|
293
|
+
finally:
|
|
294
|
+
self._lock.release()
|
|
295
|
+
|
|
281
296
|
@staticmethod
|
|
282
297
|
def _readline_with_timeout(stream: Any, timeout_seconds: float) -> str:
|
|
283
298
|
"""Read a line from stream with timeout. Returns '' on timeout."""
|
|
@@ -363,14 +378,16 @@ class CrossEncoderReranker:
|
|
|
363
378
|
|
|
364
379
|
documents = [fact.content for fact, _ in candidates]
|
|
365
380
|
|
|
366
|
-
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
381
|
+
# v3.4.53: block=False — if another recall is using the reranker
|
|
382
|
+
# subprocess, skip reranking and return fusion scores directly.
|
|
383
|
+
# This prevents concurrent recalls from serialising on the lock.
|
|
384
|
+
# 15s timeout (was 180s) — warm ONNX inference takes ~100ms; if
|
|
385
|
+
# the worker can't respond in 15s it's dead and we fall back.
|
|
369
386
|
resp = self._send_request({
|
|
370
387
|
"cmd": "rerank",
|
|
371
388
|
"query": query,
|
|
372
389
|
"documents": documents,
|
|
373
|
-
}, timeout=
|
|
390
|
+
}, timeout=15.0, block=False)
|
|
374
391
|
|
|
375
392
|
if resp is None or not resp.get("ok"):
|
|
376
393
|
# Fallback: return by existing score
|
|
@@ -57,8 +57,11 @@ class SpreadingActivationConfig:
|
|
|
57
57
|
enabled: bool = True # Ships enabled by default
|
|
58
58
|
# V3.4.40 (2026-05-09): per-node neighbor fan-out clamp.
|
|
59
59
|
# Hub nodes in dense graphs (5K+ edges) caused unbounded work per expansion.
|
|
60
|
-
# 100
|
|
61
|
-
|
|
60
|
+
# v3.4.52: reduced from 100 to 30 — GAM (ICLR 2026) shows that with
|
|
61
|
+
# covering indexes on weight DESC, 30 well-ranked neighbors provides
|
|
62
|
+
# sufficient spreading signal. Combined with streaming merge (SQLite 3.45+),
|
|
63
|
+
# this brings SpreadingActivation from 4.2s to ~60ms.
|
|
64
|
+
max_neighbors_per_node: int = 30
|
|
62
65
|
# v3.4.1: Graph intelligence integration
|
|
63
66
|
use_pagerank_bias: bool = False # Multiply propagation by target PageRank
|
|
64
67
|
community_boost: float = 0.0 # Boost same-community nodes (0.0 = disabled)
|
|
@@ -102,8 +102,8 @@ class EngineRecallAdapter:
|
|
|
102
102
|
results.append({
|
|
103
103
|
"fact_id": r.fact.fact_id,
|
|
104
104
|
"memory_id": r.fact.memory_id,
|
|
105
|
-
"content": r.fact.content[:300],
|
|
106
|
-
"source_content": memory_map.get(r.fact.memory_id, ""),
|
|
105
|
+
"content": _sanitize_json_text(r.fact.content[:300]),
|
|
106
|
+
"source_content": _sanitize_json_text(memory_map.get(r.fact.memory_id, "")),
|
|
107
107
|
"score": round(r.score, 4),
|
|
108
108
|
"confidence": round(r.confidence, 4),
|
|
109
109
|
"trust_score": round(r.trust_score, 4),
|
|
@@ -156,6 +156,39 @@ from superlocalmemory.core.recall_gate import (
|
|
|
156
156
|
# daemon startup via engine._process_pending_memories().
|
|
157
157
|
_engine = None
|
|
158
158
|
|
|
159
|
+
# v3.4.53: Limit concurrent full (non-fast) recalls. Without this, N parallel
|
|
160
|
+
# /recall calls spawn N × 6-channel threads → Ollama serialises, reranker
|
|
161
|
+
# lock queues, and total wall time is N × single-recall-time. 3 concurrent
|
|
162
|
+
# full recalls gives parallelism benefit without resource oversaturation.
|
|
163
|
+
import asyncio as _asyncio
|
|
164
|
+
_recall_semaphore = _asyncio.Semaphore(3)
|
|
165
|
+
|
|
166
|
+
# v3.4.52: Embedding model warm state. Set to True by the async pre-warm
|
|
167
|
+
# thread once Ollama has loaded the embedding model. /health reports this
|
|
168
|
+
# so MCP clients can wait for warm state before issuing recall calls.
|
|
169
|
+
_embedding_warm: bool = False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _sanitize_json_text(text: str) -> str:
|
|
173
|
+
"""Strip control characters that break JSON serialization.
|
|
174
|
+
|
|
175
|
+
Facts ingested from agent conversations can contain raw \\n, \\r, \\t,
|
|
176
|
+
null bytes, and other ASCII control chars (0x00-0x1F) that survive
|
|
177
|
+
database round-trips but cause ``json.JSONDecodeError: Invalid control
|
|
178
|
+
character`` when FastAPI serialises the /recall response payload.
|
|
179
|
+
|
|
180
|
+
We replace them with spaces rather than dropping them so the byte
|
|
181
|
+
length is preserved and :300 truncation semantics stay predictable.
|
|
182
|
+
Python's ``str.isprintable()`` is too aggressive (it also drops
|
|
183
|
+
Unicode line separators), so we target only the ASCII control range.
|
|
184
|
+
"""
|
|
185
|
+
if not text:
|
|
186
|
+
return text
|
|
187
|
+
# Fast path: most facts are clean JSON text. Check in C before allocating.
|
|
188
|
+
if all(c >= " " or c in "\n\r\t" for c in text):
|
|
189
|
+
return text
|
|
190
|
+
return "".join(c if c >= " " or c in "\n\r\t" else " " for c in text)
|
|
191
|
+
|
|
159
192
|
|
|
160
193
|
# ---------------------------------------------------------------------------
|
|
161
194
|
# Observation debounce buffer (migrated from daemon.py)
|
|
@@ -477,6 +510,36 @@ async def lifespan(application: FastAPI):
|
|
|
477
510
|
# Set up observe buffer
|
|
478
511
|
_observe_buffer.set_engine(engine)
|
|
479
512
|
|
|
513
|
+
# v3.4.52: Ensure covering indexes for SpreadingActivation queries.
|
|
514
|
+
# SQLite 3.45+ streaming merge (UNION ALL + ORDER BY + LIMIT) uses
|
|
515
|
+
# these to seek directly to top-K rows per subquery, avoiding a
|
|
516
|
+
# full sort. Without them full 6-channel recall takes 7-10s on
|
|
517
|
+
# >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
|
|
518
|
+
# node's neighbor list on each call). With them: sub-second.
|
|
519
|
+
try:
|
|
520
|
+
import sqlite3 as _sqlite3
|
|
521
|
+
_idx_conn = _sqlite3.connect(str(_memory_db))
|
|
522
|
+
_idx_conn.execute("PRAGMA journal_mode=WAL")
|
|
523
|
+
_idx_conn.execute(
|
|
524
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
|
|
525
|
+
"ON graph_edges(profile_id, source_id, weight DESC)"
|
|
526
|
+
)
|
|
527
|
+
_idx_conn.execute(
|
|
528
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_target_weight "
|
|
529
|
+
"ON graph_edges(profile_id, target_id, weight DESC)"
|
|
530
|
+
)
|
|
531
|
+
_idx_conn.execute(
|
|
532
|
+
"CREATE INDEX IF NOT EXISTS idx_assoc_source_weight "
|
|
533
|
+
"ON association_edges(profile_id, source_fact_id, weight DESC)"
|
|
534
|
+
)
|
|
535
|
+
_idx_conn.execute(
|
|
536
|
+
"CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
|
|
537
|
+
"ON association_edges(profile_id, target_fact_id, weight DESC)"
|
|
538
|
+
)
|
|
539
|
+
_idx_conn.close()
|
|
540
|
+
except Exception as _idx_exc:
|
|
541
|
+
logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
|
|
542
|
+
|
|
480
543
|
# V3.4.37: Removed WorkerPool.warmup() — the recall_worker subprocess
|
|
481
544
|
# duplicated the daemon's MemoryEngine (800+ MB). QueueConsumer now
|
|
482
545
|
# uses the daemon's engine directly via EngineRecallAdapter.
|
|
@@ -492,13 +555,20 @@ async def lifespan(application: FastAPI):
|
|
|
492
555
|
# V3.4.11: Pre-warm embedding worker (load ONNX model on startup)
|
|
493
556
|
# Without this, first recall takes 60-90s for model load.
|
|
494
557
|
# Same pattern as reranker warmup above.
|
|
558
|
+
# v3.4.52: Sets module-level _embedding_warm flag so /health can
|
|
559
|
+
# report readiness. Combined with keep_alive=-1 in ollama_embedder.py
|
|
560
|
+
# this keeps the embedding model resident forever after first warm-up.
|
|
495
561
|
import threading
|
|
562
|
+
global _embedding_warm
|
|
563
|
+
_embedding_warm = False
|
|
496
564
|
def _warmup_embedder():
|
|
565
|
+
global _embedding_warm
|
|
497
566
|
try:
|
|
498
567
|
embedder = getattr(retrieval_eng, '_embedder', None) if retrieval_eng else None
|
|
499
568
|
if embedder and hasattr(embedder, 'embed'):
|
|
500
569
|
embedder.embed("warmup")
|
|
501
|
-
|
|
570
|
+
_embedding_warm = True
|
|
571
|
+
logger.info("Embedding worker pre-warmed (model resident, keep_alive=-1)")
|
|
502
572
|
except Exception as exc:
|
|
503
573
|
logger.warning("Embedding warmup failed: %s", exc)
|
|
504
574
|
threading.Thread(target=_warmup_embedder, daemon=True, name="embed-warmup").start()
|
|
@@ -1076,6 +1146,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1076
1146
|
"pid": os.getpid(),
|
|
1077
1147
|
"engine": "initialized" if engine else "unavailable",
|
|
1078
1148
|
"version": getattr(application, 'version', 'unknown'),
|
|
1149
|
+
# v3.4.52: clients can poll this to wait for embedding model
|
|
1150
|
+
# readiness before issuing recall calls.
|
|
1151
|
+
"embedding_warm": _embedding_warm,
|
|
1079
1152
|
}
|
|
1080
1153
|
|
|
1081
1154
|
@application.get("/recall")
|
|
@@ -1101,9 +1174,22 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1101
1174
|
import time as _t
|
|
1102
1175
|
effective_sid = f"http:{int(_t.time() * 1000)}"
|
|
1103
1176
|
# v3.4.32: mark recall in-flight so the pending materializer pauses
|
|
1177
|
+
# v3.4.52: run engine.recall() in a thread-pool executor so the
|
|
1178
|
+
# FastAPI event loop stays responsive for /health, /remember, and
|
|
1179
|
+
# concurrent /recall requests. Without this, a single slow full
|
|
1180
|
+
# recall (reranker timeout, cold embedder) blocks ALL endpoints.
|
|
1181
|
+
import asyncio
|
|
1104
1182
|
_begin_recall()
|
|
1183
|
+
# v3.4.53: Full (non-fast) recalls are gated by a semaphore to
|
|
1184
|
+
# prevent resource oversaturation. Ollama serialises concurrent
|
|
1185
|
+
# embedding calls and the reranker subprocess has a single lock —
|
|
1186
|
+
# queuing more than ~3 concurrent full recalls just adds latency.
|
|
1187
|
+
# Fast recalls (SQLite/BM25 only) skip the semaphore.
|
|
1188
|
+
if not fast:
|
|
1189
|
+
await _recall_semaphore.acquire()
|
|
1105
1190
|
try:
|
|
1106
|
-
response =
|
|
1191
|
+
response = await asyncio.to_thread(
|
|
1192
|
+
engine.recall,
|
|
1107
1193
|
search_query, limit=limit, session_id=effective_sid,
|
|
1108
1194
|
fast=fast,
|
|
1109
1195
|
)
|
|
@@ -1125,8 +1211,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1125
1211
|
results.append({
|
|
1126
1212
|
"fact_id": r.fact.fact_id,
|
|
1127
1213
|
"memory_id": r.fact.memory_id,
|
|
1128
|
-
"content": r.fact.content,
|
|
1129
|
-
"source_content": memory_map.get(r.fact.memory_id, ""),
|
|
1214
|
+
"content": _sanitize_json_text(r.fact.content),
|
|
1215
|
+
"source_content": _sanitize_json_text(memory_map.get(r.fact.memory_id, "")),
|
|
1130
1216
|
"score": round(r.score, 4),
|
|
1131
1217
|
"confidence": round(r.confidence, 4),
|
|
1132
1218
|
"trust_score": round(r.trust_score, 4),
|
|
@@ -1162,6 +1248,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1162
1248
|
except Exception as exc:
|
|
1163
1249
|
raise HTTPException(500, detail=str(exc))
|
|
1164
1250
|
finally:
|
|
1251
|
+
if not fast:
|
|
1252
|
+
_recall_semaphore.release()
|
|
1165
1253
|
_end_recall()
|
|
1166
1254
|
|
|
1167
1255
|
@application.post("/remember")
|