superlocalmemory 3.4.53 → 3.4.55
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/install.sh +57 -5
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +7 -31
- package/src/superlocalmemory/core/config.py +183 -0
- package/src/superlocalmemory/server/unified_daemon.py +189 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.55",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -239,11 +239,63 @@ if [ -f "${REPO_DIR}/mcp_server.py" ]; then
|
|
|
239
239
|
echo "✓ MCP server copied"
|
|
240
240
|
fi
|
|
241
241
|
|
|
242
|
-
#
|
|
243
|
-
if [ ! -f "${INSTALL_DIR}/
|
|
244
|
-
echo "
|
|
245
|
-
|
|
246
|
-
echo "
|
|
242
|
+
# Interactive mode selection (v3.4.55)
|
|
243
|
+
if [ ! -f "${INSTALL_DIR}/current_mode" ] && [ "${NON_INTERACTIVE}" != "true" ]; then
|
|
244
|
+
echo ""
|
|
245
|
+
echo "┌─────────────────────────────────────────────┐"
|
|
246
|
+
echo "│ SuperLocalMemory V3 — Setup │"
|
|
247
|
+
echo "└─────────────────────────────────────────────┘"
|
|
248
|
+
echo ""
|
|
249
|
+
echo " Choose operating mode:"
|
|
250
|
+
echo ""
|
|
251
|
+
echo " [A] Zero-Cloud — Pure local, no API keys needed"
|
|
252
|
+
echo " • Embedding: sentence-transformers (local)"
|
|
253
|
+
echo " • LLM: none"
|
|
254
|
+
echo " • Best for: privacy, air-gapped, EU AI Act"
|
|
255
|
+
echo ""
|
|
256
|
+
echo " [B] Local AI — Ollama-powered (recommended)"
|
|
257
|
+
echo " • Embedding: ollama / nomic-embed-text"
|
|
258
|
+
echo " • LLM: ollama / llama3.2"
|
|
259
|
+
echo " • Best for: full offline AI, zero cost"
|
|
260
|
+
echo ""
|
|
261
|
+
echo " [C] Cloud Power — OpenRouter / OpenAI API"
|
|
262
|
+
echo " • Embedding: text-embedding-3-large"
|
|
263
|
+
echo " • LLM: claude-sonnet-4 / gpt-4.1-mini"
|
|
264
|
+
echo " • Best for: max quality, API required"
|
|
265
|
+
echo ""
|
|
266
|
+
read -r -p " Enter mode [A/B/C] (default: B): " MODE_CHOICE
|
|
267
|
+
MODE_CHOICE=${MODE_CHOICE:-b}
|
|
268
|
+
case "${MODE_CHOICE,,}" in
|
|
269
|
+
a) SELECTED_MODE="a" ;;
|
|
270
|
+
c) SELECTED_MODE="c" ;;
|
|
271
|
+
*) SELECTED_MODE="b" ;;
|
|
272
|
+
esac
|
|
273
|
+
echo ""
|
|
274
|
+
echo " → Selected Mode ${SELECTED_MODE^^}"
|
|
275
|
+
echo ""
|
|
276
|
+
# Generate initial 3-mode config via Python
|
|
277
|
+
python3 -c "
|
|
278
|
+
from superlocalmemory.core.config import SLMConfig
|
|
279
|
+
SLMConfig.migrate_to_3mode()
|
|
280
|
+
SLMConfig.switch_mode('${SELECTED_MODE}')
|
|
281
|
+
print('✓ Mode ${SELECTED_MODE^^} configured')
|
|
282
|
+
print(' Config files created: mode_a.json, mode_b.json, mode_c.json')
|
|
283
|
+
print(' Run slm mode a/b/c to switch modes later')
|
|
284
|
+
"
|
|
285
|
+
elif [ ! -f "${INSTALL_DIR}/config.json" ] && [ "${NON_INTERACTIVE}" = "true" ]; then
|
|
286
|
+
echo "Creating default config (non-interactive, Mode B)..."
|
|
287
|
+
python3 -c "
|
|
288
|
+
from superlocalmemory.core.config import SLMConfig
|
|
289
|
+
SLMConfig.migrate_to_3mode()
|
|
290
|
+
print('✓ Default config created (Mode B)')
|
|
291
|
+
"
|
|
292
|
+
elif [ -f "${INSTALL_DIR}/config.json" ] && [ ! -f "${INSTALL_DIR}/current_mode" ]; then
|
|
293
|
+
echo "Migrating existing config to 3-mode system..."
|
|
294
|
+
python3 -c "
|
|
295
|
+
from superlocalmemory.core.config import SLMConfig
|
|
296
|
+
SLMConfig.migrate_to_3mode()
|
|
297
|
+
print('✓ Config migrated — your settings preserved')
|
|
298
|
+
"
|
|
247
299
|
else
|
|
248
300
|
echo "○ Config exists (keeping existing)"
|
|
249
301
|
fi
|
|
@@ -652,30 +652,11 @@ def cmd_mode(args: Namespace) -> None:
|
|
|
652
652
|
|
|
653
653
|
config = SLMConfig.load()
|
|
654
654
|
|
|
655
|
-
def _apply_mode_change(new_value: str) -> tuple[SLMConfig, bool]:
|
|
656
|
-
"""Mutate-in-place mode switch. Returns (updated_config, llm_was_set).
|
|
657
|
-
|
|
658
|
-
Only changes ``config.mode``. If the user has no LLM provider
|
|
659
|
-
configured AND is moving to Mode B or C, populates the mode's
|
|
660
|
-
default LLM block so the daemon has something to talk to.
|
|
661
|
-
Everything else (embedding, retrieval, evolution, forgetting,
|
|
662
|
-
math, profile) is preserved byte-for-byte.
|
|
663
|
-
"""
|
|
664
|
-
new_mode = Mode(new_value)
|
|
665
|
-
llm_was_set = False
|
|
666
|
-
if new_mode != Mode.A and not config.llm.provider:
|
|
667
|
-
defaults = SLMConfig.for_mode(new_mode)
|
|
668
|
-
config.llm = defaults.llm
|
|
669
|
-
llm_was_set = True
|
|
670
|
-
config.mode = new_mode
|
|
671
|
-
config.save(mode_change=True)
|
|
672
|
-
return config, llm_was_set
|
|
673
|
-
|
|
674
655
|
if getattr(args, 'json', False):
|
|
675
656
|
from superlocalmemory.cli.json_output import json_print
|
|
676
657
|
if args.value:
|
|
677
658
|
old_mode = config.mode.value.upper()
|
|
678
|
-
updated
|
|
659
|
+
updated = SLMConfig.switch_mode(args.value)
|
|
679
660
|
json_print("mode", data={
|
|
680
661
|
"previous_mode": old_mode, "current_mode": args.value.upper(),
|
|
681
662
|
}, next_actions=[
|
|
@@ -690,22 +671,17 @@ def cmd_mode(args: Namespace) -> None:
|
|
|
690
671
|
return
|
|
691
672
|
|
|
692
673
|
if args.value:
|
|
693
|
-
updated
|
|
674
|
+
updated = SLMConfig.switch_mode(args.value)
|
|
694
675
|
print(f"Mode set to: {args.value.upper()}")
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
# symptom of the bug. The warning is retained ONLY as an
|
|
700
|
-
# informational note when LLM defaults were freshly populated.
|
|
701
|
-
if llm_was_set:
|
|
702
|
-
print(f" ℹ LLM provider populated from mode defaults: "
|
|
703
|
-
f"{updated.llm.provider}/{updated.llm.model}. "
|
|
704
|
-
f"Run `slm provider set` to customize.")
|
|
676
|
+
print(f" Embedding: {updated.embedding.provider}/{updated.embedding.model_name}")
|
|
677
|
+
if args.value.lower() != "a":
|
|
678
|
+
print(f" LLM: {updated.llm.provider}/{updated.llm.model}")
|
|
679
|
+
print(f" Reranker: ONNX cross-encoder (enabled)")
|
|
705
680
|
|
|
706
681
|
# V3.3.4: Warn if Mode C lacks cloud API key
|
|
707
682
|
if args.value == "c" and not updated.llm.api_key:
|
|
708
683
|
print(" ⚠ Mode C requires a cloud API key. Run: slm provider set")
|
|
684
|
+
print(" ℹ Run `slm restart` to apply the new mode.")
|
|
709
685
|
else:
|
|
710
686
|
print(f"Current mode: {config.mode.value.upper()}")
|
|
711
687
|
|
|
@@ -12,9 +12,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import logging
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
18
21
|
from superlocalmemory.storage.models import Mode
|
|
19
22
|
|
|
20
23
|
|
|
@@ -25,6 +28,9 @@ from superlocalmemory.storage.models import Mode
|
|
|
25
28
|
DEFAULT_BASE_DIR = Path.home() / ".superlocalmemory"
|
|
26
29
|
DEFAULT_DB_NAME = "memory.db"
|
|
27
30
|
DEFAULT_PROFILES_FILE = "profiles.json"
|
|
31
|
+
CURRENT_MODE_FILE = "current_mode"
|
|
32
|
+
# Populated lazily in _get_mode_config_path() to avoid circular imports
|
|
33
|
+
_MODE_CONFIG_NAMES: dict | None = None
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
# ---------------------------------------------------------------------------
|
|
@@ -950,3 +956,180 @@ class SLMConfig:
|
|
|
950
956
|
sheaf_contradiction_threshold=0.65, # Higher for 3072d embeddings
|
|
951
957
|
),
|
|
952
958
|
)
|
|
959
|
+
|
|
960
|
+
# ------------------------------------------------------------------
|
|
961
|
+
# 3-Mode config system (v3.4.54)
|
|
962
|
+
# ------------------------------------------------------------------
|
|
963
|
+
|
|
964
|
+
@staticmethod
|
|
965
|
+
def _mode_config_path(base_dir: Path, mode: "Mode") -> Path:
|
|
966
|
+
"""Return the per-mode config file path."""
|
|
967
|
+
from superlocalmemory.storage.models import Mode as _M
|
|
968
|
+
_names = {
|
|
969
|
+
_M.A: "mode_a.json",
|
|
970
|
+
_M.B: "mode_b.json",
|
|
971
|
+
_M.C: "mode_c.json",
|
|
972
|
+
}
|
|
973
|
+
return base_dir / _names.get(mode, "mode_a.json")
|
|
974
|
+
|
|
975
|
+
@staticmethod
|
|
976
|
+
def read_current_mode(base_dir: Path | None = None) -> str:
|
|
977
|
+
"""Read the current mode from the ``current_mode`` file.
|
|
978
|
+
|
|
979
|
+
Returns ``\"b\"`` (the default) if the file doesn't exist.
|
|
980
|
+
"""
|
|
981
|
+
_base = base_dir or DEFAULT_BASE_DIR
|
|
982
|
+
_f = _base / CURRENT_MODE_FILE
|
|
983
|
+
try:
|
|
984
|
+
return _f.read_text(encoding="utf-8").strip().lower() or "b"
|
|
985
|
+
except (OSError, FileNotFoundError):
|
|
986
|
+
return "b"
|
|
987
|
+
|
|
988
|
+
@staticmethod
|
|
989
|
+
def write_current_mode(mode: str, base_dir: Path | None = None) -> None:
|
|
990
|
+
"""Write the current mode letter to ``current_mode``."""
|
|
991
|
+
_base = base_dir or DEFAULT_BASE_DIR
|
|
992
|
+
_base.mkdir(parents=True, exist_ok=True)
|
|
993
|
+
(_base / CURRENT_MODE_FILE).write_text(
|
|
994
|
+
mode.lower().strip(), encoding="utf-8",
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
@classmethod
|
|
998
|
+
def switch_mode(
|
|
999
|
+
cls,
|
|
1000
|
+
new_mode: str,
|
|
1001
|
+
base_dir: Path | None = None,
|
|
1002
|
+
) -> "SLMConfig":
|
|
1003
|
+
"""Switch to a different mode, preserving the old mode's config.
|
|
1004
|
+
|
|
1005
|
+
v3.4.54: Saves the current config as ``mode_{old}.json``, then
|
|
1006
|
+
loads ``mode_{new}.json`` (or creates it from defaults if it
|
|
1007
|
+
doesn't exist). Writes ``current_mode`` and persists the new
|
|
1008
|
+
active config as ``config.json`` for backward compatibility
|
|
1009
|
+
with tooling that reads it directly.
|
|
1010
|
+
|
|
1011
|
+
Returns the new active config.
|
|
1012
|
+
"""
|
|
1013
|
+
from superlocalmemory.storage.models import Mode as _M
|
|
1014
|
+
_base = base_dir or DEFAULT_BASE_DIR
|
|
1015
|
+
_base.mkdir(parents=True, exist_ok=True)
|
|
1016
|
+
|
|
1017
|
+
old_config = cls.load(_base / "config.json")
|
|
1018
|
+
old_mode = old_config.mode.value.lower()
|
|
1019
|
+
|
|
1020
|
+
new_mode_val = _M(new_mode.lower())
|
|
1021
|
+
|
|
1022
|
+
# 1. Save current config to its per-mode file (unless already
|
|
1023
|
+
# matched — prevents overwriting user customizations)
|
|
1024
|
+
if old_mode != new_mode.lower():
|
|
1025
|
+
old_path = cls._mode_config_path(_base, old_config.mode)
|
|
1026
|
+
old_config.save(old_path)
|
|
1027
|
+
|
|
1028
|
+
# 2. Try to load the target mode's saved config.
|
|
1029
|
+
new_path = cls._mode_config_path(_base, new_mode_val)
|
|
1030
|
+
need_migration = False
|
|
1031
|
+
if new_path.exists():
|
|
1032
|
+
try:
|
|
1033
|
+
new_config = cls.load(new_path)
|
|
1034
|
+
# Ensure the loaded config actually has the right mode
|
|
1035
|
+
if new_config.mode != new_mode_val:
|
|
1036
|
+
new_config = cls.for_mode(new_mode_val, base_dir=_base)
|
|
1037
|
+
except Exception:
|
|
1038
|
+
new_config = cls.for_mode(new_mode_val, base_dir=_base)
|
|
1039
|
+
else:
|
|
1040
|
+
# First time switching to this mode: try migrating from
|
|
1041
|
+
# legacy config.json if it was already in this mode
|
|
1042
|
+
legacy = _base / "config.json"
|
|
1043
|
+
if legacy.exists():
|
|
1044
|
+
try:
|
|
1045
|
+
import json
|
|
1046
|
+
_data = json.loads(legacy.read_text(encoding="utf-8"))
|
|
1047
|
+
_legacy_mode = _data.get("mode", "").lower()
|
|
1048
|
+
if _legacy_mode == new_mode.lower():
|
|
1049
|
+
# config.json IS this mode — use it directly
|
|
1050
|
+
new_config = cls.load(legacy)
|
|
1051
|
+
need_migration = True
|
|
1052
|
+
else:
|
|
1053
|
+
new_config = cls.for_mode(new_mode_val, base_dir=_base)
|
|
1054
|
+
except Exception:
|
|
1055
|
+
new_config = cls.for_mode(new_mode_val, base_dir=_base)
|
|
1056
|
+
else:
|
|
1057
|
+
new_config = cls.for_mode(new_mode_val, base_dir=_base)
|
|
1058
|
+
|
|
1059
|
+
# 3. Save as active config.json (backward compat)
|
|
1060
|
+
new_config.save(_base / "config.json", mode_change=True)
|
|
1061
|
+
|
|
1062
|
+
# 4. Write current_mode
|
|
1063
|
+
cls.write_current_mode(new_mode, _base)
|
|
1064
|
+
|
|
1065
|
+
# 5. On first migration, also save mode_a.json and mode_c.json
|
|
1066
|
+
# so users have a complete set
|
|
1067
|
+
if need_migration:
|
|
1068
|
+
for _m in (_M.A, _M.B, _M.C):
|
|
1069
|
+
_mp = cls._mode_config_path(_base, _m)
|
|
1070
|
+
if not _mp.exists():
|
|
1071
|
+
try:
|
|
1072
|
+
_mc = cls.for_mode(_m, base_dir=_base)
|
|
1073
|
+
_mc.save(_mp)
|
|
1074
|
+
except Exception:
|
|
1075
|
+
pass
|
|
1076
|
+
|
|
1077
|
+
return new_config
|
|
1078
|
+
|
|
1079
|
+
@classmethod
|
|
1080
|
+
def migrate_to_3mode(cls, base_dir: Path | None = None) -> bool:
|
|
1081
|
+
"""One-time migration: config.json → 3-mode system.
|
|
1082
|
+
|
|
1083
|
+
Called on daemon boot. Idempotent — if ``current_mode`` already
|
|
1084
|
+
exists, this is a no-op. Returns True if migration was performed.
|
|
1085
|
+
"""
|
|
1086
|
+
_base = base_dir or DEFAULT_BASE_DIR
|
|
1087
|
+
_current = _base / CURRENT_MODE_FILE
|
|
1088
|
+
if _current.exists():
|
|
1089
|
+
return False # already migrated
|
|
1090
|
+
|
|
1091
|
+
legacy = _base / "config.json"
|
|
1092
|
+
if not legacy.exists():
|
|
1093
|
+
# No config at all — write defaults and current_mode
|
|
1094
|
+
from superlocalmemory.storage.models import Mode as _M
|
|
1095
|
+
_def = cls.for_mode(_M.B, base_dir=_base)
|
|
1096
|
+
_def.save(legacy)
|
|
1097
|
+
cls.write_current_mode("b", _base)
|
|
1098
|
+
for _m in (_M.A, _M.B, _M.C):
|
|
1099
|
+
_mp = cls._mode_config_path(_base, _m)
|
|
1100
|
+
if not _mp.exists():
|
|
1101
|
+
try:
|
|
1102
|
+
_mc = cls.for_mode(_m, base_dir=_base)
|
|
1103
|
+
_mc.save(_mp)
|
|
1104
|
+
except Exception:
|
|
1105
|
+
pass
|
|
1106
|
+
return True
|
|
1107
|
+
|
|
1108
|
+
# Migrate existing config.json → mode_{current}.json
|
|
1109
|
+
try:
|
|
1110
|
+
config = cls.load(legacy)
|
|
1111
|
+
current = config.mode.value.lower()
|
|
1112
|
+
cls.write_current_mode(current, _base)
|
|
1113
|
+
|
|
1114
|
+
# Save current config to its mode file
|
|
1115
|
+
mode_path = cls._mode_config_path(_base, config.mode)
|
|
1116
|
+
config.save(mode_path)
|
|
1117
|
+
|
|
1118
|
+
# Generate other mode files from defaults
|
|
1119
|
+
from superlocalmemory.storage.models import Mode as _M
|
|
1120
|
+
for _m in (_M.A, _M.B, _M.C):
|
|
1121
|
+
_mp = cls._mode_config_path(_base, _m)
|
|
1122
|
+
if not _mp.exists():
|
|
1123
|
+
try:
|
|
1124
|
+
_mc = cls.for_mode(_m, base_dir=_base)
|
|
1125
|
+
_mc.save(_mp)
|
|
1126
|
+
except Exception:
|
|
1127
|
+
pass
|
|
1128
|
+
|
|
1129
|
+
logger.info(
|
|
1130
|
+
"3-mode config system migrated: config.json → mode_%s.json. "
|
|
1131
|
+
"All three mode configs generated.", current,
|
|
1132
|
+
)
|
|
1133
|
+
return True
|
|
1134
|
+
except Exception:
|
|
1135
|
+
return False
|
|
@@ -446,6 +446,9 @@ async def lifespan(application: FastAPI):
|
|
|
446
446
|
from superlocalmemory.core.config import SLMConfig
|
|
447
447
|
from superlocalmemory.core.engine import MemoryEngine
|
|
448
448
|
|
|
449
|
+
# v3.4.54: one-time migration config.json → 3-mode system
|
|
450
|
+
SLMConfig.migrate_to_3mode()
|
|
451
|
+
|
|
449
452
|
config = SLMConfig.load()
|
|
450
453
|
engine = MemoryEngine(config)
|
|
451
454
|
engine.initialize()
|
|
@@ -1095,6 +1098,192 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1095
1098
|
async def api_version():
|
|
1096
1099
|
return JSONResponse({"version": _SLM_VERSION})
|
|
1097
1100
|
|
|
1101
|
+
# v3.4.55: Mode switching & config API for the dashboard UI.
|
|
1102
|
+
# The auto-settings.js expects /api/v3/* endpoints. These routes
|
|
1103
|
+
# bridge the 3-mode config system to the existing settings page.
|
|
1104
|
+
|
|
1105
|
+
@application.get("/api/v3/auto")
|
|
1106
|
+
async def v3_auto_detect():
|
|
1107
|
+
"""Auto-detect available providers from environment."""
|
|
1108
|
+
import os as _os
|
|
1109
|
+
providers = []
|
|
1110
|
+
if _os.environ.get("OPENROUTER_API_KEY"):
|
|
1111
|
+
providers.append({"id": "openrouter", "name": "OpenRouter", "has_key": True})
|
|
1112
|
+
if _os.environ.get("OPENAI_API_KEY"):
|
|
1113
|
+
providers.append({"id": "openai", "name": "OpenAI", "has_key": True})
|
|
1114
|
+
if _os.environ.get("ANTHROPIC_API_KEY"):
|
|
1115
|
+
providers.append({"id": "anthropic", "name": "Anthropic", "has_key": True})
|
|
1116
|
+
# Ollama is always available as a local option if the server is reachable
|
|
1117
|
+
try:
|
|
1118
|
+
import httpx as _hx
|
|
1119
|
+
_r = _hx.get("http://localhost:11434/api/tags", timeout=2.0)
|
|
1120
|
+
ollama_models = []
|
|
1121
|
+
if _r.status_code == 200:
|
|
1122
|
+
ollama_models = [m["name"] for m in _r.json().get("models", [])]
|
|
1123
|
+
providers.append({
|
|
1124
|
+
"id": "ollama", "name": "Ollama (local)",
|
|
1125
|
+
"has_key": False, "running": True,
|
|
1126
|
+
"models": ollama_models,
|
|
1127
|
+
})
|
|
1128
|
+
except Exception:
|
|
1129
|
+
providers.append({
|
|
1130
|
+
"id": "ollama", "name": "Ollama (local)",
|
|
1131
|
+
"has_key": False, "running": False, "models": [],
|
|
1132
|
+
})
|
|
1133
|
+
return {"providers": providers}
|
|
1134
|
+
|
|
1135
|
+
@application.get("/api/v3/mode")
|
|
1136
|
+
async def v3_get_mode():
|
|
1137
|
+
"""Get current mode and available modes."""
|
|
1138
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1139
|
+
from superlocalmemory.storage.models import Mode as _M
|
|
1140
|
+
_base = Path.home() / ".superlocalmemory"
|
|
1141
|
+
current = SLMConfig.read_current_mode(_base)
|
|
1142
|
+
modes = {}
|
|
1143
|
+
for _m in (_M.A, _M.B, _M.C):
|
|
1144
|
+
_name = _m.value.lower()
|
|
1145
|
+
_path = SLMConfig._mode_config_path(_base, _m)
|
|
1146
|
+
_cfg = None
|
|
1147
|
+
if _path.exists():
|
|
1148
|
+
try:
|
|
1149
|
+
_cfg = SLMConfig.load(_path)
|
|
1150
|
+
except Exception:
|
|
1151
|
+
pass
|
|
1152
|
+
modes[_name] = {
|
|
1153
|
+
"label": {"a": "Zero-Cloud", "b": "Local AI", "c": "Cloud Power"}[_name],
|
|
1154
|
+
"config_exists": _path.exists(),
|
|
1155
|
+
"embedding_provider": getattr(_cfg.embedding, "provider", "") if _cfg else "",
|
|
1156
|
+
"embedding_model": getattr(_cfg.embedding, "model_name", "") if _cfg else "",
|
|
1157
|
+
"llm_provider": getattr(_cfg.llm, "provider", "") if _cfg else "",
|
|
1158
|
+
"llm_model": getattr(_cfg.llm, "model", "") if _cfg else "",
|
|
1159
|
+
"reranker": _cfg.retrieval.use_cross_encoder if _cfg else True,
|
|
1160
|
+
}
|
|
1161
|
+
return {"current_mode": current, "modes": modes}
|
|
1162
|
+
|
|
1163
|
+
@application.post("/api/v3/mode/set")
|
|
1164
|
+
async def v3_set_mode(request: Request):
|
|
1165
|
+
"""Switch mode and optionally update provider/model. Body matches
|
|
1166
|
+
the auto-settings.js saveSettings() payload."""
|
|
1167
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1168
|
+
try:
|
|
1169
|
+
body = await request.json()
|
|
1170
|
+
new_mode = (body.get("mode") or body.get("settings_mode") or "").lower().strip()
|
|
1171
|
+
if new_mode not in ("a", "b", "c"):
|
|
1172
|
+
return JSONResponse(
|
|
1173
|
+
{"ok": False, "error": "mode must be a, b, or c"},
|
|
1174
|
+
status_code=400,
|
|
1175
|
+
)
|
|
1176
|
+
config = SLMConfig.switch_mode(new_mode)
|
|
1177
|
+
|
|
1178
|
+
# If provider/model were sent, update the saved config
|
|
1179
|
+
provider = body.get("provider", "").strip()
|
|
1180
|
+
if provider and new_mode != "a":
|
|
1181
|
+
_base = Path.home() / ".superlocalmemory"
|
|
1182
|
+
from superlocalmemory.core.config import LLMConfig, EmbeddingConfig
|
|
1183
|
+
# Update LLM
|
|
1184
|
+
model = body.get("model", "").strip()
|
|
1185
|
+
api_key = body.get("api_key", "").strip()
|
|
1186
|
+
endpoint = body.get("endpoint", "").strip()
|
|
1187
|
+
if provider or model:
|
|
1188
|
+
config.llm = LLMConfig(
|
|
1189
|
+
provider=provider or config.llm.provider,
|
|
1190
|
+
model=model or config.llm.model,
|
|
1191
|
+
api_key=api_key or config.llm.api_key,
|
|
1192
|
+
api_base=endpoint or config.llm.api_base,
|
|
1193
|
+
)
|
|
1194
|
+
# Update embedding
|
|
1195
|
+
emb_provider = body.get("embedding_provider", "").strip()
|
|
1196
|
+
emb_model = body.get("embedding_model", "").strip()
|
|
1197
|
+
emb_key = body.get("embedding_key", "").strip()
|
|
1198
|
+
if emb_provider or emb_model:
|
|
1199
|
+
config.embedding = EmbeddingConfig(
|
|
1200
|
+
provider=emb_provider or config.embedding.provider,
|
|
1201
|
+
model_name=emb_model or config.embedding.model_name,
|
|
1202
|
+
dimension=config.embedding.dimension,
|
|
1203
|
+
api_key=emb_key or config.embedding.api_key,
|
|
1204
|
+
)
|
|
1205
|
+
config.save(mode_change=True)
|
|
1206
|
+
|
|
1207
|
+
return {
|
|
1208
|
+
"ok": True, "mode": new_mode,
|
|
1209
|
+
"embedding": f"{config.embedding.provider}/{config.embedding.model_name}",
|
|
1210
|
+
"llm": f"{config.llm.provider}/{config.llm.model}",
|
|
1211
|
+
"message": f"Switched to Mode {new_mode.upper()}. Run slm restart to apply.",
|
|
1212
|
+
}
|
|
1213
|
+
except Exception as exc:
|
|
1214
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1215
|
+
|
|
1216
|
+
@application.get("/api/v3/ollama/status")
|
|
1217
|
+
async def v3_ollama_status():
|
|
1218
|
+
"""Check if Ollama is running and list available models."""
|
|
1219
|
+
try:
|
|
1220
|
+
import httpx as _hx
|
|
1221
|
+
_r = _hx.get("http://localhost:11434/api/tags", timeout=3.0)
|
|
1222
|
+
if _r.status_code == 200:
|
|
1223
|
+
_data = _r.json()
|
|
1224
|
+
return {
|
|
1225
|
+
"running": True,
|
|
1226
|
+
"models": [{"name": m["name"], "size": m.get("size", 0)}
|
|
1227
|
+
for m in _data.get("models", [])],
|
|
1228
|
+
}
|
|
1229
|
+
except Exception:
|
|
1230
|
+
pass
|
|
1231
|
+
return {"running": False, "models": []}
|
|
1232
|
+
|
|
1233
|
+
@application.post("/api/v3/provider/test")
|
|
1234
|
+
async def v3_provider_test(request: Request):
|
|
1235
|
+
"""Test a provider connection. Body: {provider, api_key, endpoint}."""
|
|
1236
|
+
try:
|
|
1237
|
+
body = await request.json()
|
|
1238
|
+
provider = body.get("provider", "")
|
|
1239
|
+
api_key = body.get("api_key", "")
|
|
1240
|
+
endpoint = body.get("endpoint", "")
|
|
1241
|
+
if provider == "ollama":
|
|
1242
|
+
import httpx as _hx
|
|
1243
|
+
_r = _hx.get(f"{endpoint or 'http://localhost:11434'}/api/tags", timeout=3.0)
|
|
1244
|
+
return {"ok": _r.status_code == 200, "message": "Ollama reachable" if _r.status_code == 200 else f"HTTP {_r.status_code}"}
|
|
1245
|
+
if provider in ("openai", "openrouter"):
|
|
1246
|
+
import httpx as _hx
|
|
1247
|
+
_url = f"{endpoint or 'https://api.openai.com/v1'}/models"
|
|
1248
|
+
_headers = {"Authorization": f"Bearer {api_key}"}
|
|
1249
|
+
_r = _hx.get(_url, headers=_headers, timeout=5.0)
|
|
1250
|
+
return {"ok": _r.status_code == 200, "message": "API key valid" if _r.status_code == 200 else f"HTTP {_r.status_code}: {_r.text[:200]}"}
|
|
1251
|
+
return {"ok": False, "message": f"Unknown provider: {provider}"}
|
|
1252
|
+
except Exception as exc:
|
|
1253
|
+
return {"ok": False, "message": str(exc)}
|
|
1254
|
+
|
|
1255
|
+
@application.get("/api/v3/embedding/config")
|
|
1256
|
+
async def v3_get_embedding_config():
|
|
1257
|
+
"""Get current embedding configuration."""
|
|
1258
|
+
engine = getattr(application.state, "engine", None)
|
|
1259
|
+
if engine is None:
|
|
1260
|
+
return JSONResponse({"ok": False, "error": "engine not initialized"}, status_code=503)
|
|
1261
|
+
config = getattr(engine, "_config", None)
|
|
1262
|
+
if config is None:
|
|
1263
|
+
return JSONResponse({"ok": False, "error": "no config loaded"}, status_code=503)
|
|
1264
|
+
return {
|
|
1265
|
+
"provider": getattr(config.embedding, "provider", ""),
|
|
1266
|
+
"model_name": getattr(config.embedding, "model_name", ""),
|
|
1267
|
+
"dimension": getattr(config.embedding, "dimension", 0),
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
@application.post("/api/v3/embedding/test")
|
|
1271
|
+
async def v3_embedding_test(request: Request):
|
|
1272
|
+
"""Test embedding with current config. Body: {text: \"test\"}."""
|
|
1273
|
+
try:
|
|
1274
|
+
body = await request.json()
|
|
1275
|
+
text = body.get("text", "test embedding")
|
|
1276
|
+
engine = getattr(application.state, "engine", None)
|
|
1277
|
+
if engine is None:
|
|
1278
|
+
return {"ok": False, "error": "engine not initialized"}
|
|
1279
|
+
embedder = getattr(engine, "_embedder", None)
|
|
1280
|
+
if embedder is None:
|
|
1281
|
+
return {"ok": False, "error": "embedder not available"}
|
|
1282
|
+
vec = embedder.embed(text)
|
|
1283
|
+
return {"ok": True, "dimensions": len(vec) if vec else 0}
|
|
1284
|
+
except Exception as exc:
|
|
1285
|
+
return {"ok": False, "error": str(exc)}
|
|
1286
|
+
|
|
1098
1287
|
@application.get("/", response_class=HTMLResponse)
|
|
1099
1288
|
async def root():
|
|
1100
1289
|
index_path = UI_DIR / "index.html"
|