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.
Files changed (67) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +360 -2
  35. package/src/superlocalmemory/cli/main.py +62 -3
  36. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  37. package/src/superlocalmemory/core/component_healer.py +144 -0
  38. package/src/superlocalmemory/core/component_registry.py +487 -0
  39. package/src/superlocalmemory/core/config.py +21 -0
  40. package/src/superlocalmemory/core/embeddings.py +14 -1
  41. package/src/superlocalmemory/core/engine.py +9 -5
  42. package/src/superlocalmemory/core/ingestion_command.py +36 -16
  43. package/src/superlocalmemory/core/maintenance.py +43 -0
  44. package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
  45. package/src/superlocalmemory/core/recall_pipeline.py +39 -3
  46. package/src/superlocalmemory/core/store_pipeline.py +42 -0
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  48. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  49. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  50. package/src/superlocalmemory/mcp/tools_core.py +17 -2
  51. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  52. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  53. package/src/superlocalmemory/server/routes/behavioral.py +6 -2
  54. package/src/superlocalmemory/server/routes/learning.py +13 -3
  55. package/src/superlocalmemory/server/routes/memories.py +80 -26
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +349 -10
  58. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  59. package/src/superlocalmemory/ui/index.html +3 -2
  60. package/src/superlocalmemory/ui/js/core.js +6 -1
  61. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  62. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  63. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  64. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  65. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  66. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  67. 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.4.64 (regression fix): fast=True skips spreading_activation + agentic
511
- # LLM rounds (saves ~7s on cold graph traversal). fast=False is the SLOW
512
- # path that enables both; the earlier comment had this inverted.
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
- response = await loop.run_in_executor(
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
- elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
532
- from superlocalmemory.server.recall_serializer import (
533
- recall_response_metadata,
534
- serialize_recall_response,
535
- )
536
- results, no_confident_match = serialize_recall_response(
537
- response,
538
- limit=body.limit,
539
- per_fact_max=300,
540
- total_max=max(300, body.limit * 300),
541
- )
542
- return {
543
- "query": body.query,
544
- "results": results,
545
- "total": len(results),
546
- "query_type": getattr(response, "query_type", "semantic"),
547
- "retrieval_time_ms": elapsed_ms,
548
- "no_confident_match": no_confident_match,
549
- **recall_response_metadata(response),
550
- }
551
-
552
- # Fallback: direct DB text search (engine not yet initialised)
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")