superlocalmemory 3.5.8 → 3.6.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.
- package/ATTRIBUTION.md +24 -0
- package/CHANGELOG.md +86 -0
- package/README.md +142 -35
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/cache_cmd.py +198 -0
- package/src/superlocalmemory/cli/commands.py +80 -2
- package/src/superlocalmemory/cli/compress_cmd.py +179 -0
- package/src/superlocalmemory/cli/help_cmd.py +197 -0
- package/src/superlocalmemory/cli/main.py +122 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +178 -0
- package/src/superlocalmemory/cli/optimize_constants.py +31 -0
- package/src/superlocalmemory/cli/proxy_cmd.py +104 -0
- package/src/superlocalmemory/core/config.py +5 -0
- package/src/superlocalmemory/core/engine.py +23 -0
- package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
- package/src/superlocalmemory/llm/backbone.py +10 -4
- package/src/superlocalmemory/mcp/server.py +34 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -2
- package/src/superlocalmemory/optimize/NOTICE +11 -0
- package/src/superlocalmemory/optimize/__init__.py +0 -0
- package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
- package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
- package/src/superlocalmemory/optimize/adapters/wrap.py +218 -0
- package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
- package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
- package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
- package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
- package/src/superlocalmemory/optimize/cache/exact.py +85 -0
- package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
- package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
- package/src/superlocalmemory/optimize/cache/manager.py +452 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
- package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
- package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
- package/src/superlocalmemory/optimize/compress/align.py +153 -0
- package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
- package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
- package/src/superlocalmemory/optimize/compress/router.py +548 -0
- package/src/superlocalmemory/optimize/config/__init__.py +35 -0
- package/src/superlocalmemory/optimize/config/defaults.py +48 -0
- package/src/superlocalmemory/optimize/config/schema.py +255 -0
- package/src/superlocalmemory/optimize/config/store.py +209 -0
- package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
- package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
- package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
- package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
- package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
- package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
- package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
- package/src/superlocalmemory/optimize/proxy/server.py +151 -0
- package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
- package/src/superlocalmemory/optimize/storage/db.py +1016 -0
- package/src/superlocalmemory/optimize/storage/schema.py +184 -0
- package/src/superlocalmemory/server/routes/optimize.py +167 -0
- package/src/superlocalmemory/server/routes/v3_api.py +63 -1
- package/src/superlocalmemory/server/unified_daemon.py +105 -0
- package/src/superlocalmemory/ui/index.html +98 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +5 -1
- package/src/superlocalmemory/ui/js/optimize.js +173 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +144 -36
- package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Handlers for ``slm optimize status|on|off|savings``."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from argparse import Namespace
|
|
13
|
+
|
|
14
|
+
from superlocalmemory.cli.optimize_constants import (
|
|
15
|
+
DEFAULT_COST_PER_MILLION_INPUT_TOKENS,
|
|
16
|
+
OPTIMIZE_DEFAULT_PORT,
|
|
17
|
+
_PRICING_DATE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_store():
|
|
22
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
23
|
+
return ConfigStore()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_cache_db():
|
|
27
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
28
|
+
return CacheDB()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_config(**fields) -> None:
|
|
32
|
+
"""5-step immutable config-write. Call from any handler."""
|
|
33
|
+
store = _get_store()
|
|
34
|
+
cfg = store.get()
|
|
35
|
+
try:
|
|
36
|
+
cfg = dataclasses.replace(cfg, **fields)
|
|
37
|
+
store.save(cfg)
|
|
38
|
+
except ValueError as e:
|
|
39
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
except OSError as e:
|
|
42
|
+
print(f"Error writing config: {e}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_optimize(args: Namespace) -> None:
|
|
47
|
+
"""Top-level dispatcher for ``slm optimize <subcommand>``."""
|
|
48
|
+
sub = getattr(args, "opt_command", None)
|
|
49
|
+
_dispatch = {
|
|
50
|
+
"status": cmd_optimize_status,
|
|
51
|
+
"on": cmd_optimize_on,
|
|
52
|
+
"off": cmd_optimize_off,
|
|
53
|
+
"savings": cmd_optimize_savings,
|
|
54
|
+
}
|
|
55
|
+
handler = _dispatch.get(sub or "")
|
|
56
|
+
if handler:
|
|
57
|
+
handler(args)
|
|
58
|
+
else:
|
|
59
|
+
print("Usage: slm optimize status|on|off|savings [--json] [--since N]")
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_optimize_status(args: Namespace) -> None:
|
|
64
|
+
"""Print current Optimize module status."""
|
|
65
|
+
cfg = _get_store().get()
|
|
66
|
+
use_json = getattr(args, "json", False)
|
|
67
|
+
|
|
68
|
+
proxy_running = False
|
|
69
|
+
try:
|
|
70
|
+
import urllib.request
|
|
71
|
+
_url = f"http://127.0.0.1:{OPTIMIZE_DEFAULT_PORT}/health"
|
|
72
|
+
_req = urllib.request.Request(_url, method="GET")
|
|
73
|
+
with urllib.request.urlopen(_req, timeout=1) as _resp:
|
|
74
|
+
proxy_running = _resp.status == 200
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if use_json:
|
|
79
|
+
data = {
|
|
80
|
+
"status": "ok",
|
|
81
|
+
"optimize_enabled": cfg.enabled,
|
|
82
|
+
"cache_enabled": cfg.cache_enabled,
|
|
83
|
+
"semantic_enabled": cfg.semantic_enabled,
|
|
84
|
+
"compress_enabled": cfg.compress_enabled,
|
|
85
|
+
"compress_mode": cfg.compress_mode,
|
|
86
|
+
"proxy_running": proxy_running,
|
|
87
|
+
"proxy_port": OPTIMIZE_DEFAULT_PORT,
|
|
88
|
+
"config_version": cfg.config_version,
|
|
89
|
+
}
|
|
90
|
+
print(json.dumps(data, indent=2))
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
state = "ON" if cfg.enabled else "OFF"
|
|
94
|
+
print(f"Optimize: {state}")
|
|
95
|
+
print(f" Cache: {'enabled' if cfg.cache_enabled else 'disabled'}"
|
|
96
|
+
f" (exact: {cfg.ttl.exact_seconds}s TTL,"
|
|
97
|
+
f" semantic: {'OFF' if not cfg.semantic_enabled else f'{cfg.ttl.semantic_seconds}s'})")
|
|
98
|
+
print(f" Compress: {'enabled' if cfg.compress_enabled else 'disabled'}"
|
|
99
|
+
f" (mode: {cfg.compress_mode},"
|
|
100
|
+
f" code: {'ON' if cfg.compress_code else 'OFF'},"
|
|
101
|
+
f" prose: {'ON' if cfg.compress_prose else 'OFF'},"
|
|
102
|
+
f" CCR: {'ON' if cfg.compress_ccr else 'OFF'})")
|
|
103
|
+
proxy_status = f"running on :{OPTIMIZE_DEFAULT_PORT}" if proxy_running else "not running"
|
|
104
|
+
print(f" Proxy: {proxy_status}")
|
|
105
|
+
print(f" Config: ~/.superlocalmemory/optimize.json (version {cfg.config_version})")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_optimize_on(args: Namespace) -> None:
|
|
109
|
+
"""Enable all Optimize features (cache + compress)."""
|
|
110
|
+
_write_config(enabled=True, cache_enabled=True, compress_enabled=True)
|
|
111
|
+
use_json = getattr(args, "json", False)
|
|
112
|
+
if use_json:
|
|
113
|
+
print(json.dumps({"status": "ok", "optimize_enabled": True}))
|
|
114
|
+
else:
|
|
115
|
+
print("Optimize enabled. Run 'slm proxy' to start the proxy."
|
|
116
|
+
" Daemon hot-reload: active within 2s.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_optimize_off(args: Namespace) -> None:
|
|
120
|
+
"""Disable all Optimize features. Does NOT stop proxy."""
|
|
121
|
+
_write_config(
|
|
122
|
+
enabled=False,
|
|
123
|
+
cache_enabled=False,
|
|
124
|
+
semantic_enabled=False,
|
|
125
|
+
compress_enabled=False,
|
|
126
|
+
)
|
|
127
|
+
use_json = getattr(args, "json", False)
|
|
128
|
+
if use_json:
|
|
129
|
+
print(json.dumps({"status": "ok", "optimize_enabled": False}))
|
|
130
|
+
else:
|
|
131
|
+
print("Optimize disabled. Proxy (if running) will pass through calls unchanged.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_optimize_savings(args: Namespace) -> None:
|
|
135
|
+
"""Print token/cost savings from CacheDB.metrics_load()."""
|
|
136
|
+
since = getattr(args, "since", 7)
|
|
137
|
+
provider = getattr(args, "provider", None)
|
|
138
|
+
use_json = getattr(args, "json", False)
|
|
139
|
+
|
|
140
|
+
if since <= 0:
|
|
141
|
+
print("Error: --since must be a positive integer.", file=sys.stderr)
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
|
|
144
|
+
snap = _get_cache_db().metrics_load()
|
|
145
|
+
cfg = _get_store().get()
|
|
146
|
+
|
|
147
|
+
tokens_saved = snap.tokens_saved_input + snap.tokens_saved_output + snap.tokens_saved_compress
|
|
148
|
+
provider_key = provider or "default"
|
|
149
|
+
rate = DEFAULT_COST_PER_MILLION_INPUT_TOKENS.get(
|
|
150
|
+
provider_key,
|
|
151
|
+
DEFAULT_COST_PER_MILLION_INPUT_TOKENS["default"],
|
|
152
|
+
)
|
|
153
|
+
# Allow config overrides
|
|
154
|
+
if cfg.pricing_overrides and provider_key in cfg.pricing_overrides:
|
|
155
|
+
rate = cfg.pricing_overrides[provider_key].get("input_per_1m_usd", rate)
|
|
156
|
+
|
|
157
|
+
estimated_savings_usd = tokens_saved / 1_000_000 * rate
|
|
158
|
+
|
|
159
|
+
if use_json:
|
|
160
|
+
data = {
|
|
161
|
+
"exact_hits": snap.hits,
|
|
162
|
+
"semantic_hits": snap.misses,
|
|
163
|
+
"tokens_saved": tokens_saved,
|
|
164
|
+
"estimated_savings_usd": round(estimated_savings_usd, 6),
|
|
165
|
+
"pricing_date": _PRICING_DATE,
|
|
166
|
+
"tokens_saved_input": snap.tokens_saved_input,
|
|
167
|
+
"tokens_saved_output": snap.tokens_saved_output,
|
|
168
|
+
"tokens_saved_compress": snap.tokens_saved_compress,
|
|
169
|
+
}
|
|
170
|
+
print(json.dumps(data, indent=2))
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
print(f"Savings (last {since} days):")
|
|
174
|
+
print(f" Exact cache hits: {snap.hits:>5} ({snap.tokens_saved_input:,} input tokens saved)")
|
|
175
|
+
print(f" Semantic cache hits: {snap.misses:>5}")
|
|
176
|
+
print(f" Tokens saved (total): {tokens_saved:,}")
|
|
177
|
+
print(f" Estimated savings: ~${estimated_savings_usd:.4f} (at ${rate:.2f}/M tokens)")
|
|
178
|
+
print(f" Pricing date: {_PRICING_DATE}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Constants shared by Optimize CLI handlers.
|
|
6
|
+
|
|
7
|
+
No imports from optimize.* at module load time (lazy import in functions only).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
OPTIMIZE_DEFAULT_PORT: Final[int] = 8765
|
|
15
|
+
|
|
16
|
+
# Cache skip saves BOTH input+output tokens (whole call avoided).
|
|
17
|
+
# For CLI display we show the conservative input-price lower bound.
|
|
18
|
+
DEFAULT_COST_PER_MILLION_INPUT_TOKENS: Final[dict[str, float]] = {
|
|
19
|
+
"anthropic": 3.00,
|
|
20
|
+
"openai": 2.50,
|
|
21
|
+
"gemini": 1.25,
|
|
22
|
+
"default": 3.00,
|
|
23
|
+
}
|
|
24
|
+
_PRICING_DATE: Final[str] = "2026-06-07"
|
|
25
|
+
|
|
26
|
+
AGGRESSIVE_MODE_WARNING: Final[str] = (
|
|
27
|
+
"WARNING: Aggressive mode may reduce output fidelity.\n"
|
|
28
|
+
" Do NOT use for: code generation, legal text, exact-output tasks, math.\n"
|
|
29
|
+
" Safe for: summarization, brainstorming, open-ended chat.\n"
|
|
30
|
+
" To revert: slm compress mode safe"
|
|
31
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Handler for ``slm proxy``."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from argparse import Namespace
|
|
13
|
+
|
|
14
|
+
_DEFAULT_PORT: int = 8765
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_store():
|
|
18
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
19
|
+
return ConfigStore()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_running(port: int) -> bool:
|
|
23
|
+
"""Liveness probe: return True if the SLM daemon is responding at *port*.
|
|
24
|
+
|
|
25
|
+
Calls GET /health with a 2-second timeout. The SLM daemon (which also acts
|
|
26
|
+
as the optimize proxy) is the process that answers on :8765; if it responds
|
|
27
|
+
the proxy layer is alive. We do NOT call lifecycle.ensure_proxy_running()
|
|
28
|
+
here because that function reads config via the daemon-internal store
|
|
29
|
+
(get_optimize_config/_store) which is None in a CLI subprocess context.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
import urllib.request
|
|
33
|
+
url = f"http://127.0.0.1:{port}/health"
|
|
34
|
+
req = urllib.request.Request(url, method="GET")
|
|
35
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
36
|
+
return resp.status == 200
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_proxy(args: Namespace) -> None:
|
|
42
|
+
"""Start the SLM optimization proxy (or report if already running)."""
|
|
43
|
+
use_json = getattr(args, "json", False)
|
|
44
|
+
port = getattr(args, "port", _DEFAULT_PORT)
|
|
45
|
+
provider = getattr(args, "provider", "anthropic")
|
|
46
|
+
no_compress = getattr(args, "no_compress", False)
|
|
47
|
+
semantic = getattr(args, "semantic", False)
|
|
48
|
+
|
|
49
|
+
# CRIT-3 FIX: validate port before any config write
|
|
50
|
+
if not (1024 <= port <= 65535):
|
|
51
|
+
print(f"Error: --port must be 1024–65535, got {port}.", file=sys.stderr)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
store = _get_store()
|
|
55
|
+
cfg = store.get()
|
|
56
|
+
|
|
57
|
+
# Build config updates
|
|
58
|
+
providers = dict(cfg.providers) if cfg.providers else {}
|
|
59
|
+
from superlocalmemory.optimize.config.schema import ProviderConfig
|
|
60
|
+
existing = providers.get(provider, ProviderConfig())
|
|
61
|
+
providers[provider] = ProviderConfig(
|
|
62
|
+
enabled=existing.enabled,
|
|
63
|
+
base_url=f"http://localhost:{port}",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
fields: dict = {"providers": providers, "proxy_enabled": True}
|
|
67
|
+
if no_compress:
|
|
68
|
+
fields["compress_enabled"] = False
|
|
69
|
+
if semantic:
|
|
70
|
+
fields["semantic_enabled"] = True
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
cfg = dataclasses.replace(cfg, **fields)
|
|
74
|
+
store.save(cfg)
|
|
75
|
+
except (ValueError, OSError) as e:
|
|
76
|
+
print(f"Error writing config: {e}", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
running = _ensure_running(port)
|
|
80
|
+
|
|
81
|
+
if use_json:
|
|
82
|
+
data = {
|
|
83
|
+
"status": "running" if running else "failed",
|
|
84
|
+
"port": port,
|
|
85
|
+
"anthropic_url": f"http://localhost:{port}",
|
|
86
|
+
"openai_url": f"http://localhost:{port}/v1",
|
|
87
|
+
"provider": provider,
|
|
88
|
+
"compress": not no_compress,
|
|
89
|
+
"semantic": semantic,
|
|
90
|
+
}
|
|
91
|
+
print(json.dumps(data, indent=2))
|
|
92
|
+
else:
|
|
93
|
+
if running:
|
|
94
|
+
print(f"SLM proxy starting on :{port}...")
|
|
95
|
+
print(f" Anthropic surface: http://localhost:{port}")
|
|
96
|
+
print(f" OpenAI surface: http://localhost:{port}/v1")
|
|
97
|
+
print()
|
|
98
|
+
print(f"Set ANTHROPIC_BASE_URL=http://localhost:{port} before running Claude Code.")
|
|
99
|
+
print("Or run: slm wrap claude")
|
|
100
|
+
print()
|
|
101
|
+
print("Proxy ready.")
|
|
102
|
+
else:
|
|
103
|
+
print("Error: proxy failed to start. Check logs.", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
@@ -691,6 +691,9 @@ class SLMConfig:
|
|
|
691
691
|
mode = Mode(data.get("mode", "a"))
|
|
692
692
|
llm_data = data.get("llm", {})
|
|
693
693
|
emb_data = data.get("embedding", {})
|
|
694
|
+
# V3.5.9: read base_dir before constructing config so for_mode() builds
|
|
695
|
+
# db_path from the user's directory, not DEFAULT_BASE_DIR.
|
|
696
|
+
raw_base_dir = Path(data.get("base_dir", str(DEFAULT_BASE_DIR)))
|
|
694
697
|
config = cls.for_mode(
|
|
695
698
|
mode,
|
|
696
699
|
llm_provider=llm_data.get("provider", ""),
|
|
@@ -703,6 +706,7 @@ class SLMConfig:
|
|
|
703
706
|
embedding_deployment=emb_data.get("deployment_name", ""),
|
|
704
707
|
embedding_model_name=emb_data.get("model_name", ""),
|
|
705
708
|
embedding_dimension=int(emb_data.get("dimension", 0) or 0),
|
|
709
|
+
base_dir=raw_base_dir,
|
|
706
710
|
)
|
|
707
711
|
config.active_profile = data.get("active_profile", "default")
|
|
708
712
|
|
|
@@ -813,6 +817,7 @@ class SLMConfig:
|
|
|
813
817
|
data = {
|
|
814
818
|
"mode": effective_mode,
|
|
815
819
|
"active_profile": self.active_profile,
|
|
820
|
+
"base_dir": str(self.base_dir), # V3.5.9: persist so load() can restore custom paths
|
|
816
821
|
"llm": {
|
|
817
822
|
"provider": self.llm.provider,
|
|
818
823
|
"model": self.llm.model,
|
|
@@ -126,6 +126,10 @@ class MemoryEngine:
|
|
|
126
126
|
|
|
127
127
|
if self._capabilities is Capabilities.FULL:
|
|
128
128
|
self._init_heavy_layer()
|
|
129
|
+
else:
|
|
130
|
+
# V3.5.9: LIGHT mode — try to get embedder from running daemon so that
|
|
131
|
+
# memories stored via MCP have real embeddings (fixes PR #30 NULL embedder).
|
|
132
|
+
self._try_init_proxy()
|
|
129
133
|
|
|
130
134
|
self._initialized = True
|
|
131
135
|
logger.info(
|
|
@@ -187,6 +191,25 @@ class MemoryEngine:
|
|
|
187
191
|
from superlocalmemory.learning.adaptive import AdaptiveLearner
|
|
188
192
|
self._adaptive_learner = AdaptiveLearner(self._db)
|
|
189
193
|
|
|
194
|
+
def _try_init_proxy(self) -> None:
|
|
195
|
+
"""V3.5.9: Attach McpEmbedderProxy when running in LIGHT mode.
|
|
196
|
+
|
|
197
|
+
If the daemon is reachable, the proxy delegates embed calls over HTTP
|
|
198
|
+
so that MCP-stored facts have real embeddings (fixes PR #30). Silently
|
|
199
|
+
skips if the daemon is not running — keyword recall still works.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
from superlocalmemory.core.mcp_embedder_proxy import McpEmbedderProxy
|
|
203
|
+
port = getattr(self._config, "daemon_port", 8765)
|
|
204
|
+
proxy = McpEmbedderProxy(port=port)
|
|
205
|
+
if proxy.is_available():
|
|
206
|
+
self._embedder = proxy
|
|
207
|
+
logger.info("MCP embedder proxy attached (daemon port %d)", port)
|
|
208
|
+
else:
|
|
209
|
+
logger.debug("Daemon not reachable — MCP will run without embedder")
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
logger.debug("McpEmbedderProxy init skipped: %s", exc)
|
|
212
|
+
|
|
190
213
|
def _init_heavy_layer(self) -> None:
|
|
191
214
|
from superlocalmemory.llm.backbone import LLMBackbone
|
|
192
215
|
from superlocalmemory.core.engine_wiring import (
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""V3.5.9 — McpEmbedderProxy: lightweight embedder for the MCP (LIGHT) process.
|
|
6
|
+
|
|
7
|
+
Problem: MemoryEngine(Capabilities.LIGHT) skips _init_heavy_layer(), leaving
|
|
8
|
+
_embedder=None permanently. Any memory stored via MCP tools has NULL embeddings
|
|
9
|
+
→ semantic search silently broken; health() lies, reporting unavailable.
|
|
10
|
+
|
|
11
|
+
Solution: a thin proxy that delegates embed_batch() to the running daemon via
|
|
12
|
+
its POST /api/embed endpoint over localhost HTTP. The daemon runs a FULL engine
|
|
13
|
+
with one real ONNX/Ollama worker. No second ONNX process is spawned.
|
|
14
|
+
|
|
15
|
+
Usage (engine.py LIGHT branch):
|
|
16
|
+
proxy = McpEmbedderProxy(port=8765)
|
|
17
|
+
if proxy.is_available():
|
|
18
|
+
engine._embedder = proxy
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_DEFAULT_TIMEOUT = 5.0 # seconds — fast enough for inline store() calls
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class McpEmbedderProxy:
|
|
31
|
+
"""Proxy embedder: MCP process → daemon's /api/embed over localhost HTTP."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, port: int = 8765, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
34
|
+
self._base_url = f"http://127.0.0.1:{port}"
|
|
35
|
+
self._timeout = timeout
|
|
36
|
+
self._available: bool | None = None # cached after first is_available() call
|
|
37
|
+
|
|
38
|
+
def is_available(self) -> bool:
|
|
39
|
+
"""Ping daemon's embed endpoint. Cached after first success."""
|
|
40
|
+
if self._available is True:
|
|
41
|
+
return True
|
|
42
|
+
try:
|
|
43
|
+
import httpx
|
|
44
|
+
resp = httpx.get(f"{self._base_url}/api/v3/embed/ping", timeout=2.0)
|
|
45
|
+
self._available = resp.status_code == 200
|
|
46
|
+
except Exception:
|
|
47
|
+
self._available = False
|
|
48
|
+
return bool(self._available)
|
|
49
|
+
|
|
50
|
+
# -- Embedder interface (matches embeddings.py / ollama_embedder.py) ------
|
|
51
|
+
|
|
52
|
+
def embed(self, text: str) -> list[float] | None:
|
|
53
|
+
"""Embed a single text via daemon. Returns None on any error."""
|
|
54
|
+
results = self.embed_batch([text])
|
|
55
|
+
return results[0] if results else None
|
|
56
|
+
|
|
57
|
+
def embed_batch(self, texts: list[str]) -> list[list[float] | None]:
|
|
58
|
+
"""Embed a batch of texts via daemon's /api/embed endpoint."""
|
|
59
|
+
if not texts:
|
|
60
|
+
return []
|
|
61
|
+
try:
|
|
62
|
+
import httpx
|
|
63
|
+
resp = httpx.post(
|
|
64
|
+
f"{self._base_url}/api/v3/embed",
|
|
65
|
+
json={"texts": texts},
|
|
66
|
+
timeout=self._timeout,
|
|
67
|
+
)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
data = resp.json()
|
|
70
|
+
embeddings = data.get("embeddings", [])
|
|
71
|
+
# Pad with None if daemon returned fewer results than requested
|
|
72
|
+
while len(embeddings) < len(texts):
|
|
73
|
+
embeddings.append(None)
|
|
74
|
+
return embeddings
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
logger.debug("McpEmbedderProxy.embed_batch failed: %s", exc)
|
|
77
|
+
return [None] * len(texts)
|
|
78
|
+
|
|
79
|
+
def compute_fisher_params(
|
|
80
|
+
self, embedding: list[float]
|
|
81
|
+
) -> tuple[list[float] | None, list[float] | None]:
|
|
82
|
+
"""Fisher-Rao params stay in daemon's FULL engine; proxy returns None.
|
|
83
|
+
|
|
84
|
+
The MCP LIGHT process stores facts with embedding=<vector> but
|
|
85
|
+
fisher_mean=None, fisher_variance=None. The daemon's consolidation
|
|
86
|
+
pass fills these in asynchronously — same behaviour as the
|
|
87
|
+
write-through remember path.
|
|
88
|
+
"""
|
|
89
|
+
return None, None
|
|
@@ -233,17 +233,23 @@ class LLMBackbone:
|
|
|
233
233
|
self, prompt: str, system: str, max_tokens: int, temperature: float,
|
|
234
234
|
) -> tuple[str, dict[str, str], dict]:
|
|
235
235
|
messages = self._make_messages(system, prompt)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
# V3.5.9: Skip Authorization header when api_key is empty so unauthenticated
|
|
237
|
+
# local endpoints (llama.cpp, LM Studio, etc.) don't reject with HTTP 401.
|
|
238
|
+
headers = {"Content-Type": "application/json"}
|
|
239
|
+
if self._api_key:
|
|
240
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
240
241
|
payload = {
|
|
241
242
|
"model": self._model,
|
|
242
243
|
"messages": messages,
|
|
243
244
|
"max_tokens": max_tokens,
|
|
244
245
|
"temperature": temperature,
|
|
245
246
|
}
|
|
247
|
+
# V3.5.9: If base_url is a bare base (e.g. "http://host:port/v1") without
|
|
248
|
+
# the /chat/completions path, append it — matching OpenAI SDK behaviour and
|
|
249
|
+
# fixing the /v1/v1 duplication reported in issue #29.
|
|
246
250
|
url = self._base_url or _OPENAI_URL
|
|
251
|
+
if self._base_url and not self._base_url.rstrip("/").endswith("chat/completions"):
|
|
252
|
+
url = f"{self._base_url.rstrip('/')}/chat/completions"
|
|
247
253
|
return url, headers, payload
|
|
248
254
|
|
|
249
255
|
def _build_ollama(
|
|
@@ -278,5 +278,39 @@ _watchdog_thread = threading.Thread(target=_parent_watchdog, daemon=True, name="
|
|
|
278
278
|
_watchdog_thread.start()
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
# V3.5.9: Stdin EOF monitor — complements the parent watchdog for the case where
|
|
282
|
+
# the IDE starts a new MCP session WITHOUT quitting the app. The old stdio pipe
|
|
283
|
+
# is abandoned (write-end closed) but the parent process stays alive, so the
|
|
284
|
+
# watchdog never fires. Without this, each IDE reconnect adds a zombie process
|
|
285
|
+
# (22 seen in production causing 12 GB swap on the M5 Pro).
|
|
286
|
+
#
|
|
287
|
+
# Uses kqueue(2) KQ_EV_EOF on macOS — fires when the write-end of the stdin pipe
|
|
288
|
+
# closes WITHOUT consuming any bytes, so it cannot race with FastMCP's asyncio
|
|
289
|
+
# stdin reader. On Linux (no kqueue), the watchdog alone provides coverage.
|
|
290
|
+
def _stdin_eof_monitor() -> None:
|
|
291
|
+
"""Exit when the IDE closes our stdin pipe (kqueue — macOS only)."""
|
|
292
|
+
import select as _sel, os as _os_eof
|
|
293
|
+
_mlog = logging.getLogger(__name__ + ".stdin_monitor")
|
|
294
|
+
if not hasattr(_sel, "kqueue"):
|
|
295
|
+
return # Linux / non-macOS: watchdog covers process death
|
|
296
|
+
try:
|
|
297
|
+
fd = sys.stdin.fileno()
|
|
298
|
+
kq = _sel.kqueue()
|
|
299
|
+
ke = _sel.kevent(fd, filter=_sel.KQ_FILTER_READ, flags=_sel.KQ_EV_ADD | _sel.KQ_EV_EOF)
|
|
300
|
+
kq.control([ke], 0) # register without waiting
|
|
301
|
+
while True:
|
|
302
|
+
evs = kq.control(None, 4, 30.0) # 30 s poll — low cost
|
|
303
|
+
for ev in evs:
|
|
304
|
+
if ev.flags & _sel.KQ_EV_EOF:
|
|
305
|
+
_mlog.info("stdin write-end closed (kqueue EOF), self-terminating")
|
|
306
|
+
_os_eof._exit(0)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
_mlog.debug("stdin EOF monitor error: %s — watchdog will cover", exc)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_stdin_monitor_thread = threading.Thread(target=_stdin_eof_monitor, daemon=True, name="stdin-eof-monitor")
|
|
312
|
+
_stdin_monitor_thread.start()
|
|
313
|
+
|
|
314
|
+
|
|
281
315
|
if __name__ == "__main__":
|
|
282
316
|
server.run(transport="stdio")
|
|
@@ -161,9 +161,13 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
161
161
|
"fact_count": fact_count,
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
# Embedding service
|
|
164
|
+
# Embedding service — distinguish real embedder from V3.5.9 daemon proxy
|
|
165
|
+
_emb = engine._embedder
|
|
166
|
+
from superlocalmemory.core.mcp_embedder_proxy import McpEmbedderProxy
|
|
167
|
+
_is_proxy = isinstance(_emb, McpEmbedderProxy)
|
|
165
168
|
status["components"]["embedder"] = {
|
|
166
|
-
"status": "ok" if
|
|
169
|
+
"status": "ok" if _emb else "unavailable",
|
|
170
|
+
"source": "daemon_proxy" if _is_proxy else "local",
|
|
167
171
|
"model": engine._config.embedding.model_name,
|
|
168
172
|
}
|
|
169
173
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Third-party components used by SLM v3.6 Optimize module:
|
|
2
|
+
|
|
3
|
+
1. Headroom (Apache-2.0)
|
|
4
|
+
Copyright (c) 2024 Headroom contributors
|
|
5
|
+
Source: https://github.com/headroom/headroom
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
|
|
8
|
+
2. LLMLingua-2 (MIT)
|
|
9
|
+
Copyright (c) 2024 Microsoft Corporation
|
|
10
|
+
Source: https://github.com/microsoft/LLMLingua
|
|
11
|
+
License: MIT
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""optimize/adapters — SDK adapters for OpenAI / Anthropic / LangChain.
|
|
2
|
+
|
|
3
|
+
SECURITY: No pickle anywhere. JSON+pydantic only (SEC-C-04, CWE-502).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("superlocalmemory.optimize.adapters")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def withSLM(client: Any, *, tenant_id: str = "default") -> Any:
|
|
15
|
+
"""Wrap any supported SDK client with SLM cache hooks.
|
|
16
|
+
|
|
17
|
+
FAIL-OPEN GUARANTEE:
|
|
18
|
+
- If Optimize is disabled (config.enabled == False) → return client unchanged.
|
|
19
|
+
- If cache is unavailable → return client unchanged, log WARNING.
|
|
20
|
+
- If client type unrecognized → return client unchanged, log WARNING.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
from superlocalmemory.optimize.config import get_optimize_config
|
|
24
|
+
from superlocalmemory.optimize.cache.manager import CacheManager
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
logger.warning("SLM Optimize not available (%s) — pass-through active", exc)
|
|
27
|
+
return client
|
|
28
|
+
|
|
29
|
+
config = get_optimize_config()
|
|
30
|
+
if not config.enabled:
|
|
31
|
+
return client # DEFAULT STATE — zero behavioral change
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
cache_manager = CacheManager.get_instance()
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
logger.warning("SLM cache unavailable (%s) — pass-through active", exc)
|
|
37
|
+
return client
|
|
38
|
+
|
|
39
|
+
# Anthropic (.messages.create)
|
|
40
|
+
if hasattr(client, "messages") and hasattr(client.messages, "create"):
|
|
41
|
+
try:
|
|
42
|
+
from superlocalmemory.optimize.adapters.anthropic_adapter import (
|
|
43
|
+
SLMAnthropicAdapter,
|
|
44
|
+
)
|
|
45
|
+
return SLMAnthropicAdapter(client, cache_manager, config, tenant_id)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
logger.warning("SLM Anthropic adapter init failed: %s", exc)
|
|
48
|
+
return client
|
|
49
|
+
|
|
50
|
+
# OpenAI (.chat.completions)
|
|
51
|
+
if hasattr(client, "chat") and hasattr(client.chat, "completions"):
|
|
52
|
+
try:
|
|
53
|
+
from superlocalmemory.optimize.adapters.openai_adapter import (
|
|
54
|
+
SLMOpenAIAdapter,
|
|
55
|
+
)
|
|
56
|
+
return SLMOpenAIAdapter(client, cache_manager, config, tenant_id)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.warning("SLM OpenAI adapter init failed: %s", exc)
|
|
59
|
+
return client
|
|
60
|
+
|
|
61
|
+
logger.warning(
|
|
62
|
+
"withSLM: unrecognized client type %s — pass-through active",
|
|
63
|
+
type(client).__name__,
|
|
64
|
+
)
|
|
65
|
+
return client
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["withSLM"]
|