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.
- package/CHANGELOG.md +124 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +80 -33
- package/src/superlocalmemory/cli/daemon.py +3 -1
- package/src/superlocalmemory/cli/main.py +1 -1
- package/src/superlocalmemory/core/backend_orchestrator.py +19 -13
- package/src/superlocalmemory/core/config.py +83 -0
- package/src/superlocalmemory/core/injection.py +351 -0
- package/src/superlocalmemory/core/recall_pipeline.py +29 -0
- package/src/superlocalmemory/core/store_pipeline.py +13 -0
- package/src/superlocalmemory/hooks/auto_recall_hook.py +50 -26
- package/src/superlocalmemory/hooks/before_web_hook.py +3 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +5 -2
- package/src/superlocalmemory/mcp/tools_active.py +130 -9
- package/src/superlocalmemory/mcp/tools_context.py +18 -4
- package/src/superlocalmemory/retrieval/bm25_channel.py +50 -0
- package/src/superlocalmemory/retrieval/engine.py +43 -9
- package/src/superlocalmemory/retrieval/hopfield_channel.py +22 -9
- package/src/superlocalmemory/retrieval/temporal_channel.py +10 -1
- package/src/superlocalmemory/server/routes/memories.py +2 -2
- package/src/superlocalmemory/server/routes/v3_api.py +40 -25
- package/src/superlocalmemory/server/unified_daemon.py +80 -0
- package/src/superlocalmemory/storage/database.py +47 -0
- package/src/superlocalmemory/storage/migration_runner.py +4 -0
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +40 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -0
- package/src/superlocalmemory/storage/models.py +3 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +4 -2
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -0
- 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
|
-
|
|
261
|
-
|
|
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
|
|
264
|
-
lines.append(f"- {
|
|
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":
|
|
270
|
-
"content":
|
|
271
|
-
"score":
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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) >
|
|
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
|
|
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
|
|
189
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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:
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if
|
|
528
|
-
return JSONResponse(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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,
|
|
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":
|
|
552
|
-
"result_count":
|
|
553
|
-
"retrieval_time_ms":
|
|
554
|
-
"results":
|
|
555
|
-
"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)
|