superlocalmemory 3.8.0 → 3.8.1

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 (118) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  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 +1 -1
  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 +1 -1
  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 +1 -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 +1 -1
  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 +2 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +134 -7
  34. package/src/superlocalmemory/cli/daemon.py +7 -0
  35. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  36. package/src/superlocalmemory/cli/main.py +10 -4
  37. package/src/superlocalmemory/cli/version_banner.py +17 -3
  38. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  39. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  40. package/src/superlocalmemory/core/embeddings.py +119 -45
  41. package/src/superlocalmemory/core/engine.py +24 -21
  42. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  43. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  44. package/src/superlocalmemory/core/injection.py +12 -7
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  46. package/src/superlocalmemory/core/recall_pipeline.py +5 -3
  47. package/src/superlocalmemory/core/store_pipeline.py +153 -20
  48. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  49. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  50. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  51. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  52. package/src/superlocalmemory/learning/reward.py +50 -0
  53. package/src/superlocalmemory/learning/source_quality.py +523 -1
  54. package/src/superlocalmemory/loops/ledger.py +25 -5
  55. package/src/superlocalmemory/mcp/server.py +11 -30
  56. package/src/superlocalmemory/mcp/tools_core.py +4 -3
  57. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  58. package/src/superlocalmemory/retrieval/engine.py +53 -21
  59. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  60. package/src/superlocalmemory/server/config_file.py +90 -0
  61. package/src/superlocalmemory/server/origin.py +50 -0
  62. package/src/superlocalmemory/server/routes/backup.py +293 -70
  63. package/src/superlocalmemory/server/routes/behavioral.py +336 -59
  64. package/src/superlocalmemory/server/routes/brain.py +57 -16
  65. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  66. package/src/superlocalmemory/server/routes/entity.py +100 -23
  67. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  68. package/src/superlocalmemory/server/routes/learning.py +276 -105
  69. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  70. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  71. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  72. package/src/superlocalmemory/server/routes/stats.py +93 -155
  73. package/src/superlocalmemory/server/routes/token.py +3 -13
  74. package/src/superlocalmemory/server/routes/v3_api.py +64 -20
  75. package/src/superlocalmemory/server/unified_daemon.py +467 -40
  76. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  77. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  78. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  79. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  80. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  81. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  82. package/src/superlocalmemory/storage/schema.py +49 -1
  83. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  84. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  85. package/src/superlocalmemory/ui/index.html +3 -6
  86. package/src/superlocalmemory/ui/js/core.js +52 -9
  87. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  88. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  89. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  90. package/src/superlocalmemory/ui/js/od-entities.js +22 -22
  91. package/src/superlocalmemory/ui/js/od-graph.js +11 -4
  92. package/src/superlocalmemory/ui/js/od-memories.js +47 -5
  93. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  94. package/src/superlocalmemory/ui/js/od-settings.js +113 -59
  95. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  96. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  97. package/src/superlocalmemory/ui/js/settings.js +15 -1
  98. package/plugin-src/.mcp.json +0 -12
  99. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  100. package/plugin-src/agents/slm-loop-runner.md +0 -71
  101. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  102. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  103. package/plugin-src/commands/slm-loop.md +0 -31
  104. package/plugin-src/hooks/.gitkeep +0 -0
  105. package/plugin-src/hooks/hooks.json +0 -102
  106. package/plugin-src/manifest.json +0 -30
  107. package/plugin-src/requirements.txt +0 -1
  108. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  109. package/plugin-src/scripts/ensure-venv.bat +0 -122
  110. package/plugin-src/scripts/ensure-venv.sh +0 -105
  111. package/plugin-src/scripts/slm-launch +0 -62
  112. package/plugin-src/scripts/slm-launch.bat +0 -23
  113. package/plugin-src/settings.json +0 -25
  114. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  115. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  116. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  117. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  118. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -84,8 +84,9 @@ def _record_recall_hits(
84
84
  enqueue_shown_flip,
85
85
  )
86
86
 
87
- engine = get_engine()
88
- pid = profile_id or engine.profile_id
87
+ pid = profile_id
88
+ if not pid:
89
+ pid = get_engine().profile_id
89
90
  slm_dir = canonical_data_root()
90
91
 
91
92
  shown_ids = [r.get("fact_id", "") for r in results[:10]
@@ -121,7 +122,7 @@ def _record_recall_hits(
121
122
  def register_core_tools(server, get_engine: Callable) -> None:
122
123
  """Register the 13 core MCP tools on *server*."""
123
124
 
124
- @server.tool(annotations=ToolAnnotations(idempotentHint=True))
125
+ @server.tool()
125
126
  async def remember(
126
127
  content: str, tags: str = "", project: str = "",
127
128
  importance: int = 5, session_id: str = "",
@@ -143,7 +143,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
143
143
  logger.debug("get_assertions failed: %s", exc)
144
144
  return {"assertions": [], "count": 0, "error": str(exc)}
145
145
 
146
- @server.tool(annotations=ToolAnnotations(idempotentHint=True))
146
+ @server.tool()
147
147
  async def reinforce_assertion(assertion_id: str) -> dict:
148
148
  """Reinforce a behavioral assertion (increase confidence).
149
149
 
@@ -171,7 +171,7 @@ def register_learning_tools(server, get_engine: Callable) -> None:
171
171
  except Exception as exc:
172
172
  return {"success": False, "error": str(exc)}
173
173
 
174
- @server.tool(annotations=ToolAnnotations(idempotentHint=True))
174
+ @server.tool()
175
175
  async def contradict_assertion(assertion_id: str) -> dict:
176
176
  """Contradict a behavioral assertion (decrease confidence).
177
177
 
@@ -679,32 +679,47 @@ class RetrievalEngine:
679
679
  fused: list,
680
680
  ch_results: dict[str, list[tuple[str, float]]],
681
681
  effective_limit: int,
682
- min_per_channel: int = 2,
683
682
  ) -> list:
684
- """Ensure structure channels (entity_graph) get representation.
685
-
686
- V3.4.11: entity_graph finds valid results but RRF scores them low
687
- because they don't overlap with semantic/bm25 results. This interleaves
688
- top entity_graph facts into positions 3-4 of the final output instead
689
- of appending at the end where they'd never be seen.
683
+ """Keep strong lexical and structure evidence visible in the result cap.
684
+
685
+ A semantic channel with a larger weight can fill a small result limit
686
+ even when BM25 has an exact, high-signal hit. That broke the
687
+ ``queryable now`` ingestion contract: a freshly inserted FTS row could
688
+ exist durably but remain invisible to immediate recall. Reserve one
689
+ capped slot for a strong BM25 hit and two for a structure channel when
690
+ such candidates exist, without returning more than ``effective_limit``.
690
691
  """
691
- structure_channels = ["entity_graph"]
692
+ channel_minimums = (
693
+ ("bm25", 1, 0.0),
694
+ ("entity_graph", 2, 0.0),
695
+ )
692
696
  top_ids = {fr.fact_id for fr in top}
693
697
 
694
698
  promoted = []
695
- for ch_name in structure_channels:
699
+ for ch_name, minimum, score_floor in channel_minimums:
696
700
  ch_items = ch_results.get(ch_name, [])
697
701
  if not ch_items:
698
702
  continue
699
703
 
700
- present = sum(1 for fid, _ in ch_items if fid in top_ids)
701
- if present >= min_per_channel:
704
+ eligible_ids = {
705
+ fid
706
+ for fid, score in ch_items
707
+ if (
708
+ float(score) > score_floor
709
+ if ch_name == "bm25"
710
+ else float(score) >= score_floor
711
+ )
712
+ }
713
+ if not eligible_ids:
714
+ continue
715
+
716
+ present = sum(1 for fid in eligible_ids if fid in top_ids)
717
+ if present >= minimum:
702
718
  continue
703
719
 
704
- needed = min_per_channel - present
705
- ch_fids = {fid for fid, _ in ch_items}
720
+ needed = minimum - present
706
721
  for fr in fused:
707
- if fr.fact_id in ch_fids and fr.fact_id not in top_ids:
722
+ if fr.fact_id in eligible_ids and fr.fact_id not in top_ids:
708
723
  promoted.append(fr)
709
724
  top_ids.add(fr.fact_id)
710
725
  needed -= 1
@@ -714,10 +729,15 @@ class RetrievalEngine:
714
729
  if not promoted:
715
730
  return top
716
731
 
717
- # Append as safety net — with proper RRF weights (strategy.py),
718
- # entity_graph facts should already rank naturally in the top-k.
719
- # This only fires when they're still missing despite weight boost.
720
- return list(top) + promoted
732
+ selected = promoted[:effective_limit]
733
+ result = list(top[:effective_limit])
734
+ free_slots = max(0, effective_limit - len(result))
735
+ result.extend(selected[:free_slots])
736
+ remaining = selected[free_slots:]
737
+ if remaining:
738
+ keep = max(0, effective_limit - len(remaining))
739
+ result = result[:keep] + remaining
740
+ return result[:effective_limit]
721
741
 
722
742
  # -- Channel execution --------------------------------------------------
723
743
 
@@ -853,14 +873,26 @@ class RetrievalEngine:
853
873
  q_emb, profile_id, self._config.bm25_top_k,
854
874
  )
855
875
 
856
- # Collect results as channels complete.
876
+ # Each local channel gets a strict latency budget. A slow graph walk
877
+ # must not make an interactive recall wait 30 seconds; completed
878
+ # channels still participate in fusion and the timeout is observable.
879
+ channel_timeout_seconds = 1.0
880
+ # One shared deadline keeps parallel dispatch genuinely bounded. A
881
+ # per-future timeout here would serialise the wait and turn five slow
882
+ # channels into five seconds of UI latency.
883
+ done, pending = concurrent.futures.wait(
884
+ futures.values(), timeout=channel_timeout_seconds,
885
+ )
857
886
  for name, fut in futures.items():
887
+ if fut in pending:
888
+ logger.warning("Channel %s exceeded %.1fs latency budget", name, channel_timeout_seconds)
889
+ continue
858
890
  try:
859
- ch_name, result = fut.result(timeout=30)
891
+ ch_name, result = fut.result()
860
892
  if result:
861
893
  out[ch_name] = result
862
894
  except Exception as exc:
863
- logger.warning("Channel %s timed out or failed: %s", name, exc)
895
+ logger.warning("Channel %s failed: %s", name, exc)
864
896
 
865
897
  # Apply registered post-retrieval filters (forgetting filter, etc.)
866
898
  if hasattr(self, '_registry') and self._registry._filters:
@@ -25,9 +25,8 @@ import sys
25
25
  import threading
26
26
  import time
27
27
  import weakref
28
- from typing import Any
29
-
30
28
  from pathlib import Path
29
+ from typing import Any
31
30
 
32
31
  from superlocalmemory.infra.data_root import state_path
33
32
  from superlocalmemory.storage.models import AtomicFact
@@ -57,9 +56,9 @@ _live_rerankers: set[weakref.ref] = set()
57
56
 
58
57
  logger = logging.getLogger(__name__)
59
58
 
60
- _IDLE_TIMEOUT_SECONDS = 300 # V3.4.37: 5 min (was 30) — balance cold-start vs RAM.
59
+ _IDLE_TIMEOUT_SECONDS = 1800 # V3.8.1: keep interactive sessions warm.
61
60
  # V3.3.12: Configurable via SLM_RERANKER_IDLE_TIMEOUT env var.
62
- # V3.4.19: Bumped from 120 1800 in lock-step with the embedding worker.
61
+ # Low-RAM installations can retain aggressive recycling through the override.
63
62
  # Set ``SLM_RERANKER_IDLE_TIMEOUT=120`` + ``slm restart`` to revert.
64
63
  _IDLE_TIMEOUT_SECONDS = int(os.environ.get("SLM_RERANKER_IDLE_TIMEOUT", _IDLE_TIMEOUT_SECONDS))
65
64
  _SUBPROCESS_RESPONSE_TIMEOUT = 15 # v3.4.52: 15s (was 180s). Long timeout blocked the
@@ -0,0 +1,90 @@
1
+ """Process-safe, durable read/modify/write access to ``config.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import threading
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Callable, Iterator
12
+
13
+ _PROCESS_LOCK = threading.RLock()
14
+
15
+
16
+ @contextmanager
17
+ def _file_lock(path: Path) -> Iterator[None]:
18
+ """Serialize config access across daemon, CLI, and worker processes."""
19
+ lock_path = path.with_name(f".{path.name}.lock")
20
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
21
+ with _PROCESS_LOCK, lock_path.open("a+b") as handle:
22
+ if os.name == "nt":
23
+ import msvcrt
24
+
25
+ handle.seek(0)
26
+ handle.write(b"\0")
27
+ handle.flush()
28
+ handle.seek(0)
29
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
30
+ else:
31
+ import fcntl
32
+
33
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
34
+ try:
35
+ yield
36
+ finally:
37
+ if os.name == "nt":
38
+ handle.seek(0)
39
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
40
+ else:
41
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
42
+
43
+
44
+ def _read_unlocked(path: Path) -> dict:
45
+ if not path.exists():
46
+ return {}
47
+ value = json.loads(path.read_text(encoding="utf-8"))
48
+ if not isinstance(value, dict):
49
+ raise ValueError("config root must be a JSON object")
50
+ return value
51
+
52
+
53
+ def read_config(path: Path) -> dict:
54
+ """Return one coherent config snapshot under the interprocess lock."""
55
+ with _file_lock(path):
56
+ return _read_unlocked(path)
57
+
58
+
59
+ def update_config(path: Path, update: Callable[[dict], None]) -> dict:
60
+ """Atomically update and durably replace a config JSON object."""
61
+ with _file_lock(path):
62
+ data = _read_unlocked(path)
63
+ update(data)
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ descriptor, temp_name = tempfile.mkstemp(
66
+ prefix=f".{path.name}.",
67
+ suffix=".tmp",
68
+ dir=path.parent,
69
+ )
70
+ temp_path = Path(temp_name)
71
+ try:
72
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
73
+ json.dump(data, stream, indent=2)
74
+ stream.write("\n")
75
+ stream.flush()
76
+ os.fsync(stream.fileno())
77
+ os.chmod(temp_path, 0o600)
78
+ os.replace(temp_path, path)
79
+ if hasattr(os, "O_DIRECTORY"):
80
+ directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
81
+ try:
82
+ os.fsync(directory_fd)
83
+ finally:
84
+ os.close(directory_fd)
85
+ finally:
86
+ temp_path.unlink(missing_ok=True)
87
+ return data
88
+
89
+
90
+ __all__ = ("read_config", "update_config")
@@ -0,0 +1,50 @@
1
+ """Exact browser-origin validation for the local dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlsplit
6
+
7
+
8
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
9
+
10
+
11
+ def origin_is_loopback(origin: str) -> bool:
12
+ """Return whether an Origin is absent or an exact HTTP(S) loopback URL."""
13
+ if not origin:
14
+ return True
15
+ try:
16
+ parsed = urlsplit(origin)
17
+ # Accessing port validates malformed/non-numeric port values.
18
+ _ = parsed.port
19
+ except (TypeError, ValueError):
20
+ return False
21
+ return (
22
+ parsed.scheme in {"http", "https"}
23
+ and parsed.hostname is not None
24
+ and parsed.hostname.lower() in _LOOPBACK_HOSTS
25
+ and parsed.username is None
26
+ and parsed.password is None
27
+ and parsed.path in {"", "/"}
28
+ and not parsed.query
29
+ and not parsed.fragment
30
+ )
31
+
32
+
33
+ def origin_is_daemon(origin: str, *, port: int) -> bool:
34
+ """Return whether ``origin`` is one of this daemon's loopback aliases.
35
+
36
+ A loopback host alone is not a browser trust boundary: another local web
37
+ server can run on a different port. Credentialless dashboard writes are
38
+ therefore limited to the port owned by this daemon. Authenticated local
39
+ integrations are handled separately by the write-identity boundary.
40
+ """
41
+ if not origin_is_loopback(origin):
42
+ return False
43
+ try:
44
+ parsed = urlsplit(origin)
45
+ return parsed.port == port
46
+ except (TypeError, ValueError):
47
+ return False
48
+
49
+
50
+ __all__ = ("origin_is_daemon", "origin_is_loopback")