superlocalmemory 3.8.1 → 3.8.3
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 +67 -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 +80 -26
- package/src/superlocalmemory/server/routes/v3_api.py +120 -0
- package/src/superlocalmemory/server/unified_daemon.py +349 -10
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/ui/index.html +3 -2
- package/src/superlocalmemory/ui/js/core.js +6 -1
- 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
|
@@ -19,6 +19,25 @@ from .helpers import (
|
|
|
19
19
|
logger = logging.getLogger("superlocalmemory.routes.memories")
|
|
20
20
|
router = APIRouter()
|
|
21
21
|
|
|
22
|
+
# v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
|
|
23
|
+
# under heavy multi-agent load, so semantic recall is given ample time to
|
|
24
|
+
# finish — the keyword fallback is a LAST-RESORT safety net for a genuine hang
|
|
25
|
+
# (e.g. a wedged embedder), NOT an aggressive speed cutoff. Only if recall
|
|
26
|
+
# exceeds this budget do we serve the fast keyword search so the caller ALWAYS
|
|
27
|
+
# gets a result instead of hanging forever. Tune with SLM_SEARCH_RECALL_TIMEOUT_S.
|
|
28
|
+
# The dashboard's fetch timeout is set ABOVE this so the browser waits for the
|
|
29
|
+
# quality result rather than aborting early.
|
|
30
|
+
_DEFAULT_RECALL_BUDGET_S = 25.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _search_recall_timeout_s() -> float:
|
|
34
|
+
import os
|
|
35
|
+
try:
|
|
36
|
+
v = float(os.environ.get("SLM_SEARCH_RECALL_TIMEOUT_S", ""))
|
|
37
|
+
return v if v > 0 else _DEFAULT_RECALL_BUDGET_S
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return _DEFAULT_RECALL_BUDGET_S
|
|
40
|
+
|
|
22
41
|
|
|
23
42
|
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
24
43
|
"""SEC-H-02: log the full traceback server-side; return a generic message.
|
|
@@ -507,9 +526,14 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
507
526
|
# a stalled connection and aborts with "signal is aborted without reason"
|
|
508
527
|
# before the response arrives. Fix: run in a thread-pool executor so the
|
|
509
528
|
# event loop stays alive to send keepalive frames.
|
|
510
|
-
# v3.
|
|
511
|
-
#
|
|
512
|
-
#
|
|
529
|
+
# v3.8.2: fast=True — the dashboard search BOX is a snappy retrieval
|
|
530
|
+
# list (all six local channels + reranker), never the internal agentic
|
|
531
|
+
# LLM round, which would reintroduce the multi-second hang this endpoint
|
|
532
|
+
# is regression-tested against (test_search_fast_param_and_profile_isolation).
|
|
533
|
+
# The human-facing LLM synthesis lives on separate paths that are NOT the
|
|
534
|
+
# search list: the "ask" memory-chat (/api/v3/chat/stream, Ollama Mode B)
|
|
535
|
+
# and the precomputed knowledge-cluster summaries (core.community_summary,
|
|
536
|
+
# Mode B/C). So search stays fast; synthesis is where the LLM adds value.
|
|
513
537
|
import asyncio
|
|
514
538
|
import time as _time
|
|
515
539
|
engine = _get_engine(request)
|
|
@@ -521,35 +545,65 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
521
545
|
_window = getattr(body, "window", None) or ""
|
|
522
546
|
if not _window and getattr(body, "date_from", None) and getattr(body, "date_to", None):
|
|
523
547
|
_window = f"{body.date_from}..{body.date_to}"
|
|
524
|
-
|
|
548
|
+
# v3.8.3: bound the synchronous recall. Under a concurrent
|
|
549
|
+
# maintenance pass or a busy embedder it can run tens of seconds
|
|
550
|
+
# and the browser aborts the fetch. If it exceeds the budget we
|
|
551
|
+
# fall through to the fast keyword search below, so the dashboard
|
|
552
|
+
# ALWAYS returns instead of failing with an abort.
|
|
553
|
+
_recall_future = loop.run_in_executor(
|
|
525
554
|
None,
|
|
526
555
|
lambda: engine.recall(
|
|
527
556
|
body.query, limit=body.limit, fast=True,
|
|
528
557
|
window=_window or None,
|
|
529
558
|
),
|
|
530
559
|
)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
"
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
560
|
+
# A run_in_executor thread cannot be cancelled, and wait_for() on it
|
|
561
|
+
# blocks until the thread finishes (defeating the timeout). So poll
|
|
562
|
+
# the future without blocking the event loop and give up at the
|
|
563
|
+
# deadline — the orphaned recall completes in the background and its
|
|
564
|
+
# result is discarded. This is what bounds dashboard-search latency.
|
|
565
|
+
_budget = _search_recall_timeout_s()
|
|
566
|
+
_deadline = loop.time() + _budget
|
|
567
|
+
while not _recall_future.done() and loop.time() < _deadline:
|
|
568
|
+
await asyncio.sleep(0.05)
|
|
569
|
+
if _recall_future.done():
|
|
570
|
+
response = _recall_future.result()
|
|
571
|
+
else:
|
|
572
|
+
# Ensure the orphaned future's eventual result/exception is
|
|
573
|
+
# retrieved so asyncio doesn't log "never retrieved".
|
|
574
|
+
_recall_future.add_done_callback(
|
|
575
|
+
lambda f: (f.cancelled() or f.exception())
|
|
576
|
+
)
|
|
577
|
+
logger.warning(
|
|
578
|
+
"search_memories: semantic recall exceeded %.0fs budget for "
|
|
579
|
+
"%r — serving keyword fallback",
|
|
580
|
+
_budget, (body.query or "")[:80],
|
|
581
|
+
)
|
|
582
|
+
response = None
|
|
583
|
+
if response is not None:
|
|
584
|
+
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
585
|
+
from superlocalmemory.server.recall_serializer import (
|
|
586
|
+
recall_response_metadata,
|
|
587
|
+
serialize_recall_response,
|
|
588
|
+
)
|
|
589
|
+
results, no_confident_match = serialize_recall_response(
|
|
590
|
+
response,
|
|
591
|
+
limit=body.limit,
|
|
592
|
+
per_fact_max=300,
|
|
593
|
+
total_max=max(300, body.limit * 300),
|
|
594
|
+
)
|
|
595
|
+
return {
|
|
596
|
+
"query": body.query,
|
|
597
|
+
"results": results,
|
|
598
|
+
"total": len(results),
|
|
599
|
+
"query_type": getattr(response, "query_type", "semantic"),
|
|
600
|
+
"retrieval_time_ms": elapsed_ms,
|
|
601
|
+
"no_confident_match": no_confident_match,
|
|
602
|
+
**recall_response_metadata(response),
|
|
603
|
+
}
|
|
604
|
+
# recall timed out — fall through to the fast keyword search.
|
|
605
|
+
|
|
606
|
+
# Fallback: direct DB text search (engine not ready OR recall over budget)
|
|
553
607
|
conn = get_db_connection()
|
|
554
608
|
conn.row_factory = dict_factory
|
|
555
609
|
cursor = conn.cursor()
|
|
@@ -1060,6 +1060,126 @@ async def set_auto_recall_config(request: Request):
|
|
|
1060
1060
|
return _internal_error()
|
|
1061
1061
|
|
|
1062
1062
|
|
|
1063
|
+
# ── Runtime behaviour config (v3.8.2 UX-1) ──────────────────
|
|
1064
|
+
# User-facing settings that take effect LIVE (no restart) via the
|
|
1065
|
+
# reconfigure_daemon_engine hot-swap path AND persist across restarts via
|
|
1066
|
+
# SLMConfig.save(). Scoped deliberately to fields that save() round-trips:
|
|
1067
|
+
# retrieval (asdict) + injection (explicit). Excludes setup-time/internal
|
|
1068
|
+
# knobs and any field save() doesn't persist (which would silently revert).
|
|
1069
|
+
_RUNTIME_CONFIG_FIELDS = (
|
|
1070
|
+
# (section, field, kind, min, max)
|
|
1071
|
+
("retrieval", "top_k", "int", 1, 200),
|
|
1072
|
+
("retrieval", "use_cross_encoder", "bool", None, None),
|
|
1073
|
+
("injection", "enabled", "bool", None, None),
|
|
1074
|
+
("injection", "core_block_enabled", "bool", None, None),
|
|
1075
|
+
("injection", "core_block_max_facts", "int", 0, 50),
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _runtime_config_snapshot(config) -> dict:
|
|
1080
|
+
"""Current values of the exposed runtime fields, grouped by section."""
|
|
1081
|
+
out: dict = {}
|
|
1082
|
+
for section, field, *_ in _RUNTIME_CONFIG_FIELDS:
|
|
1083
|
+
sec = getattr(config, section, None)
|
|
1084
|
+
out.setdefault(section, {})[field] = (
|
|
1085
|
+
getattr(sec, field, None) if sec is not None else None
|
|
1086
|
+
)
|
|
1087
|
+
return out
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@router.get("/runtime/config")
|
|
1091
|
+
async def get_runtime_config(request: Request):
|
|
1092
|
+
"""User-facing runtime behaviour settings.
|
|
1093
|
+
|
|
1094
|
+
Recall depth (top_k), reranker on/off (use_cross_encoder), and memory
|
|
1095
|
+
injection (master + core-block). All apply live via the daemon hot-swap —
|
|
1096
|
+
no restart — and persist across restarts.
|
|
1097
|
+
"""
|
|
1098
|
+
try:
|
|
1099
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1100
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
1101
|
+
return {"success": True, "config": _runtime_config_snapshot(config)}
|
|
1102
|
+
except Exception:
|
|
1103
|
+
return _internal_error()
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
@router.put("/runtime/config")
|
|
1107
|
+
async def set_runtime_config(request: Request):
|
|
1108
|
+
"""Validate, persist, and hot-apply runtime behaviour settings.
|
|
1109
|
+
|
|
1110
|
+
Body: ``{"retrieval": {"top_k": 30}, "injection": {"enabled": false}}``.
|
|
1111
|
+
Only known fields are accepted; a bad type/range is rejected 400 and
|
|
1112
|
+
nothing is applied (fail-fast — never let a bad value wedge the engine).
|
|
1113
|
+
"""
|
|
1114
|
+
_require_manage(request)
|
|
1115
|
+
try:
|
|
1116
|
+
import dataclasses as _dc
|
|
1117
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1118
|
+
|
|
1119
|
+
body = await request.json()
|
|
1120
|
+
if not isinstance(body, dict):
|
|
1121
|
+
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
|
1122
|
+
|
|
1123
|
+
# Validate everything BEFORE mutating anything.
|
|
1124
|
+
updates: dict[str, dict] = {}
|
|
1125
|
+
for section, field, kind, lo, hi in _RUNTIME_CONFIG_FIELDS:
|
|
1126
|
+
sec_in = body.get(section)
|
|
1127
|
+
if not isinstance(sec_in, dict) or field not in sec_in:
|
|
1128
|
+
continue
|
|
1129
|
+
val = sec_in[field]
|
|
1130
|
+
if kind == "bool":
|
|
1131
|
+
if not isinstance(val, bool):
|
|
1132
|
+
return JSONResponse(
|
|
1133
|
+
{"error": f"{section}.{field} must be true or false"},
|
|
1134
|
+
status_code=400,
|
|
1135
|
+
)
|
|
1136
|
+
elif kind == "int":
|
|
1137
|
+
# bool is an int subclass — reject it explicitly.
|
|
1138
|
+
if isinstance(val, bool) or not isinstance(val, int):
|
|
1139
|
+
return JSONResponse(
|
|
1140
|
+
{"error": f"{section}.{field} must be an integer"},
|
|
1141
|
+
status_code=400,
|
|
1142
|
+
)
|
|
1143
|
+
if (lo is not None and val < lo) or (hi is not None and val > hi):
|
|
1144
|
+
return JSONResponse(
|
|
1145
|
+
{"error": f"{section}.{field} must be between {lo} and {hi}"},
|
|
1146
|
+
status_code=400,
|
|
1147
|
+
)
|
|
1148
|
+
updates.setdefault(section, {})[field] = val
|
|
1149
|
+
|
|
1150
|
+
if not updates:
|
|
1151
|
+
return JSONResponse(
|
|
1152
|
+
{"error": "no known runtime settings in request body"},
|
|
1153
|
+
status_code=400,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
# These are plain behaviour flags read per-recall — NOT model/mode
|
|
1157
|
+
# swaps. So we apply them the light way: swap the sub-config objects on
|
|
1158
|
+
# the LIVE config the daemon+engine already hold, then persist to disk.
|
|
1159
|
+
# This avoids the heavyweight engine drain+rebuild (which reloads
|
|
1160
|
+
# models and can time out draining in-flight recalls) — the running
|
|
1161
|
+
# engine simply reads the new values on its next recall/injection.
|
|
1162
|
+
live = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
1163
|
+
targets = [live]
|
|
1164
|
+
engine = getattr(request.app.state, "engine", None)
|
|
1165
|
+
eng_cfg = getattr(engine, "_config", None) if engine is not None else None
|
|
1166
|
+
if eng_cfg is not None and eng_cfg is not live:
|
|
1167
|
+
targets.append(eng_cfg)
|
|
1168
|
+
for cfg in targets:
|
|
1169
|
+
for section, changes in updates.items():
|
|
1170
|
+
sec = getattr(cfg, section, None)
|
|
1171
|
+
if sec is None:
|
|
1172
|
+
continue
|
|
1173
|
+
# replace() yields a NEW sub-config (works frozen or mutable);
|
|
1174
|
+
# assigning the attribute is an atomic reference swap.
|
|
1175
|
+
setattr(cfg, section, _dc.replace(sec, **changes))
|
|
1176
|
+
request.app.state.config = live
|
|
1177
|
+
live.save(mode_change=False) # durable across restart
|
|
1178
|
+
return {"success": True, "config": _runtime_config_snapshot(live)}
|
|
1179
|
+
except Exception:
|
|
1180
|
+
return _internal_error()
|
|
1181
|
+
|
|
1182
|
+
|
|
1063
1183
|
# ── IDE Status ───────────────────────────────────────────────
|
|
1064
1184
|
|
|
1065
1185
|
@router.get("/ide/status")
|