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.
- package/CHANGELOG.md +52 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +2 -2
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +3 -5
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +2 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +360 -2
- package/src/superlocalmemory/cli/main.py +62 -3
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embeddings.py +14 -1
- package/src/superlocalmemory/core/engine.py +9 -5
- package/src/superlocalmemory/core/ingestion_command.py +36 -16
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
- package/src/superlocalmemory/core/recall_pipeline.py +39 -3
- package/src/superlocalmemory/core/store_pipeline.py +42 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +17 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/routes/behavioral.py +6 -2
- package/src/superlocalmemory/server/routes/learning.py +13 -3
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/v3_api.py +120 -0
- package/src/superlocalmemory/server/unified_daemon.py +266 -2
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/ui/index.html +3 -2
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +43 -0
- package/src/superlocalmemory/ui/js/od-graph.js +35 -0
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +37 -0
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- 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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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=
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
477
|
-
conn.
|
|
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
|
-
#
|
|
141
|
-
#
|
|
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
|
-
|
|
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.
|
|
511
|
-
#
|
|
512
|
-
#
|
|
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)
|