superlocalmemory 3.7.6 → 3.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/pyproject.toml +6 -7
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +171 -11
- package/src/superlocalmemory/cli/setup_wizard.py +18 -1
- package/src/superlocalmemory/infra/auth_middleware.py +33 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_core.py +216 -20
- package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
- package/src/superlocalmemory/optimize/cache/manager.py +7 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
- package/src/superlocalmemory/server/api.py +17 -0
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +12 -6
- package/src/superlocalmemory/server/routes/chat.py +63 -12
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/memories.py +58 -11
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +128 -52
- package/src/superlocalmemory/server/ui.py +10 -0
- package/src/superlocalmemory/server/unified_daemon.py +290 -74
- package/src/superlocalmemory/storage/migration_runner.py +17 -3
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/storage/schema_v32.py +0 -9
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.7.8] - 2026-07-20 — Profile-isolation leak fix, loopback auth opt-in, hardening
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Cross-profile recall leak (critical).** After a profile switch, the dashboard Chat, memory-facts, and cluster-summary routes read from a long-lived `WorkerPool` subprocess that cached the previous profile for up to 120 seconds, so those views could return the prior profile's memories while the UI reported the new one. Those routes now read the daemon's resident, lease-protected engine — which `commit_daemon_profile_switch` rebinds synchronously on every switch, exactly as the `/recall` and `/remember` endpoints already do — so recall always reflects the current profile. Added full-daemon isolation regression tests that store under one profile, switch, and assert the other profile's data is never returned.
|
|
13
|
+
- Removed a dead consolidation-trigger fast path (`WorkerPool.send_command`, a method that never existed and silently fell through on every call) and placed the remaining direct-consolidation path under the profile-runtime lease so a concurrent switch cannot commit mid-consolidation.
|
|
14
|
+
- The MCP `switch_profile` tool now synchronizes local engine state only to the profile the daemon actually acknowledged, and re-validates that the profile exists locally before adopting it, instead of trusting the response body.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `SLM_REQUIRE_API_KEY_LOOPBACK` opt-in. When set together with a configured `api_key` file, uncredentialed loopback writes must also present a matching `X-SLM-API-Key`, restoring the strict posture for shared-host operators. Default behavior is unchanged (local-first): the flag is a no-op unless explicitly enabled. This is a single, explicit control rather than overloading "an api_key file exists" with two meanings — the overload that caused the 3.7.6 write-auth regressions.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Removed the dead `V32_VEC0_DDL` schema constant and retired the now-inert unconditional `check_api_key` write gate (repurposed as the sole enforcement point for the loopback opt-in). The legacy `api.py`/`ui.py` app factories are documented as not served by the daemon.
|
|
23
|
+
|
|
24
|
+
## [3.7.7] - 2026-07-20 — Profile isolation and runtime integrity
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Fixes daemon-aware profile switching and profile-isolated CLI reads/writes. Profile transitions now drain admitted operations, atomically rebind the resident engine, persist only after a successful transition, and return a generation-stamped acknowledgement to CLI, dashboard/API, and MCP callers without restarting the daemon.
|
|
29
|
+
- Dashboard mode, provider, embedding, and memory-visibility changes now take effect through the same daemon-owned runtime transition boundary. Existing custom embedding settings survive setup-mode changes, and Optimize cache vectors follow the configured embedding dimension instead of assuming 768 dimensions.
|
|
30
|
+
- Migration reconciliation now accepts only explicitly allowlisted historical hashes and verifies the complete required schema before updating migration metadata; unknown or structurally incomplete drift fails closed.
|
|
31
|
+
- Restored cross-platform UI test discovery and added dependency and high-severity static security gates to CI.
|
|
32
|
+
- Upgraded the audited web, MCP, cryptography, and Transformers dependency stack to patched releases. Three narrowly scoped NLTK, setuptools, and PyTorch advisories are tracked as exact, dated exceptions; PyTorch remains on the proven 2.11 runtime because the combined native/ML upgrade produced a full-suite process crash and could not be attributed safely to one package.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Dashboard controls and API/CLI configuration support for the default write scope and explicit shared/global recall opt-ins. Personal-only recall remains the default.
|
|
37
|
+
|
|
8
38
|
## [3.7.6] - 2026-07-19 — Auth, upgrade, and embedding-dimension fixes
|
|
9
39
|
|
|
10
40
|
### Fixed
|
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
<h1 align="center">SuperLocalMemory V3.7.
|
|
8
|
+
<h1 align="center">SuperLocalMemory V3.7.8</h1>
|
|
9
9
|
<p align="center"><strong>Cache. Compress. Remember. Three surfaces — proxy, MCP tools, or skill. Every setup covered.</strong><br/>
|
|
10
10
|
<em>Local-first agent memory with explicit operating modes, auditable retrieval, and optional Optimize tools.</em></p>
|
|
11
|
-
<p align="center"><code>v3.7.
|
|
11
|
+
<p align="center"><code>v3.7.8</code> — <strong>Profile-isolation leak fix, opt-in loopback write auth, and fresh-install acceptance verification.</strong><br/>
|
|
12
12
|
Proxy: <code>slm wrap claude</code> · MCP: add <code>slm_compress</code> to your config · Skill: zero-config</p>
|
|
13
13
|
<p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.8",
|
|
4
4
|
"description": "Local-first agent memory with MCP and an agent-native CLI. Documented clients include Claude Code, Cursor, and Windsurf.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"prepack": "node scripts/build-plugin.mjs && node scripts/prepack.js",
|
|
52
52
|
"postinstall": "node scripts/postinstall.js",
|
|
53
53
|
"preuninstall": "node scripts/preuninstall.js",
|
|
54
|
-
"test": "node
|
|
54
|
+
"test": "node scripts/run-ui-tests.mjs"
|
|
55
55
|
},
|
|
56
56
|
"engines": {
|
|
57
57
|
"node": ">=18.0.0",
|
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.7.
|
|
1
|
+
superlocalmemory==3.7.8
|
package/plugin-src/manifest.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.7.
|
|
1
|
+
superlocalmemory==3.7.8
|
package/pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "superlocalmemory"
|
|
3
|
-
version = "3.7.
|
|
3
|
+
version = "3.7.8"
|
|
4
4
|
description = "Local-first agent memory with auditable hybrid retrieval"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "AGPL-3.0-or-later"
|
|
@@ -37,12 +37,12 @@ dependencies = [
|
|
|
37
37
|
"numpy==2.4.4",
|
|
38
38
|
"scipy==1.17.1",
|
|
39
39
|
"networkx==3.6.1",
|
|
40
|
-
"mcp==1.
|
|
40
|
+
"mcp==1.28.1",
|
|
41
41
|
"python-dateutil==2.9.0.post0",
|
|
42
42
|
"rank-bm25==0.2.2",
|
|
43
43
|
"vadersentiment==3.3.2",
|
|
44
44
|
"einops==0.8.2",
|
|
45
|
-
"fastapi[all]==0.
|
|
45
|
+
"fastapi[all]==0.139.2",
|
|
46
46
|
"uvicorn==0.46.0",
|
|
47
47
|
"websockets==16.0",
|
|
48
48
|
"zeroconf>=0.140",
|
|
@@ -59,7 +59,7 @@ dependencies = [
|
|
|
59
59
|
"psutil==7.2.2",
|
|
60
60
|
"structlog==25.5.0",
|
|
61
61
|
"portalocker==3.2.0",
|
|
62
|
-
"cryptography==
|
|
62
|
+
"cryptography==48.0.1",
|
|
63
63
|
# Semantic search + cross-encoder reranker. Do NOT use
|
|
64
64
|
# sentence-transformers[onnx] — its extras pull optimum which
|
|
65
65
|
# overrides the sentence-transformers pin via transitive deps.
|
|
@@ -67,8 +67,8 @@ dependencies = [
|
|
|
67
67
|
"sentence-transformers==5.3.0",
|
|
68
68
|
"optimum==2.1.0",
|
|
69
69
|
"onnxruntime==1.24.4",
|
|
70
|
-
"transformers==
|
|
71
|
-
"huggingface_hub==
|
|
70
|
+
"transformers==5.5.4",
|
|
71
|
+
"huggingface_hub==1.5.0",
|
|
72
72
|
"torch==2.11.0",
|
|
73
73
|
"scikit-learn==1.8.0",
|
|
74
74
|
# Vector KNN extension for the semantic channel.
|
|
@@ -97,7 +97,6 @@ search = [
|
|
|
97
97
|
"einops==0.8.2",
|
|
98
98
|
"torch==2.11.0",
|
|
99
99
|
"scikit-learn==1.8.0",
|
|
100
|
-
"geoopt>=0.5.0",
|
|
101
100
|
"onnxruntime==1.24.4",
|
|
102
101
|
]
|
|
103
102
|
ui = [
|
|
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
|
|
|
32
32
|
os.environ["OMP_NUM_THREADS"] = "2"
|
|
33
33
|
# ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
__version__ = "3.7.
|
|
35
|
+
__version__ = "3.7.8"
|
|
36
36
|
|
|
37
37
|
_REQUIRED_VERSIONS = {
|
|
38
38
|
"sentence_transformers": "5.3.0",
|
|
@@ -612,6 +612,8 @@ def cmd_config(args: Namespace) -> None:
|
|
|
612
612
|
"evolution.enabled", "evolution.backend", "evolution.max_evolutions_per_cycle",
|
|
613
613
|
"mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
|
|
614
614
|
"graph_backend", "vector_backend", "scale_engine_state",
|
|
615
|
+
"scope.default_scope", "scope.recall_include_global",
|
|
616
|
+
"scope.recall_include_shared",
|
|
615
617
|
}
|
|
616
618
|
if key not in _ALLOWED_CONFIG_KEYS:
|
|
617
619
|
if use_json:
|
|
@@ -650,6 +652,60 @@ def cmd_config(args: Namespace) -> None:
|
|
|
650
652
|
except ValueError:
|
|
651
653
|
parsed_value = value
|
|
652
654
|
|
|
655
|
+
if key == "scope.default_scope" and parsed_value not in {
|
|
656
|
+
"personal", "shared", "global",
|
|
657
|
+
}:
|
|
658
|
+
message = "scope.default_scope must be personal, shared, or global"
|
|
659
|
+
if use_json:
|
|
660
|
+
from superlocalmemory.cli.json_output import json_print
|
|
661
|
+
json_print("config", error={
|
|
662
|
+
"code": "INVALID_VALUE", "message": message,
|
|
663
|
+
})
|
|
664
|
+
else:
|
|
665
|
+
print(f"Error: {message}")
|
|
666
|
+
sys.exit(1)
|
|
667
|
+
if key in {
|
|
668
|
+
"scope.recall_include_global", "scope.recall_include_shared",
|
|
669
|
+
} and not isinstance(parsed_value, bool):
|
|
670
|
+
message = f"{key} must be true or false"
|
|
671
|
+
if use_json:
|
|
672
|
+
from superlocalmemory.cli.json_output import json_print
|
|
673
|
+
json_print("config", error={
|
|
674
|
+
"code": "INVALID_VALUE", "message": message,
|
|
675
|
+
})
|
|
676
|
+
else:
|
|
677
|
+
print(f"Error: {message}")
|
|
678
|
+
sys.exit(1)
|
|
679
|
+
|
|
680
|
+
if key.startswith("scope."):
|
|
681
|
+
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
682
|
+
|
|
683
|
+
if is_daemon_running():
|
|
684
|
+
field = key.split(".", 1)[1]
|
|
685
|
+
result = daemon_request(
|
|
686
|
+
"PUT", "/api/v3/scope/config", {field: parsed_value},
|
|
687
|
+
)
|
|
688
|
+
if not isinstance(result, dict) or result.get("success") is not True:
|
|
689
|
+
message = "resident daemon rejected the scope configuration"
|
|
690
|
+
if use_json:
|
|
691
|
+
from superlocalmemory.cli.json_output import json_print
|
|
692
|
+
json_print("config", error={
|
|
693
|
+
"code": "CONFIG_APPLY_FAILED", "message": message,
|
|
694
|
+
})
|
|
695
|
+
else:
|
|
696
|
+
print(f"Error: {message}")
|
|
697
|
+
sys.exit(1)
|
|
698
|
+
old_value = None
|
|
699
|
+
if use_json:
|
|
700
|
+
from superlocalmemory.cli.json_output import json_print
|
|
701
|
+
json_print("config", data={
|
|
702
|
+
"key": key, "old_value": old_value,
|
|
703
|
+
"new_value": result.get(field), "runtime": "daemon",
|
|
704
|
+
})
|
|
705
|
+
else:
|
|
706
|
+
print(f"{key}: applied to resident daemon -> {result.get(field)}")
|
|
707
|
+
return
|
|
708
|
+
|
|
653
709
|
# Set via dot-notation (e.g. "evolution.enabled" -> cfg["evolution"]["enabled"])
|
|
654
710
|
parts = key.split(".")
|
|
655
711
|
node = cfg
|
|
@@ -1579,10 +1635,47 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1579
1635
|
from superlocalmemory.core.config import SLMConfig
|
|
1580
1636
|
|
|
1581
1637
|
config = SLMConfig.load()
|
|
1638
|
+
daemon_status = None
|
|
1639
|
+
try:
|
|
1640
|
+
from superlocalmemory.cli.daemon import (
|
|
1641
|
+
daemon_request,
|
|
1642
|
+
is_daemon_running,
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
if is_daemon_running():
|
|
1646
|
+
candidate = daemon_request("GET", "/status")
|
|
1647
|
+
if isinstance(candidate, dict) and candidate.get("profile"):
|
|
1648
|
+
daemon_status = candidate
|
|
1649
|
+
except Exception:
|
|
1650
|
+
logger.debug(
|
|
1651
|
+
"cmd_status: daemon runtime status unavailable; using offline view",
|
|
1652
|
+
exc_info=True,
|
|
1653
|
+
)
|
|
1582
1654
|
|
|
1583
1655
|
if getattr(args, 'json', False):
|
|
1584
1656
|
from superlocalmemory.cli.json_output import json_print
|
|
1585
1657
|
|
|
1658
|
+
if daemon_status is not None:
|
|
1659
|
+
data = {
|
|
1660
|
+
"mode": str(daemon_status.get("mode", "unknown")).upper(),
|
|
1661
|
+
"provider": daemon_status.get("provider", "none"),
|
|
1662
|
+
"profile": daemon_status["profile"],
|
|
1663
|
+
"base_dir": daemon_status.get("base_dir", str(config.base_dir)),
|
|
1664
|
+
"db_path": daemon_status.get("db_path", str(config.db_path)),
|
|
1665
|
+
"db_size_mb": float(daemon_status.get("db_size_mb", 0.0)),
|
|
1666
|
+
"fact_count": int(daemon_status.get("fact_count", 0)),
|
|
1667
|
+
"entity_count": int(daemon_status.get("entity_count", 0)),
|
|
1668
|
+
"edge_count": int(daemon_status.get("edge_count", 0)),
|
|
1669
|
+
"profile_generation": int(
|
|
1670
|
+
daemon_status.get("profile_generation", 0)
|
|
1671
|
+
),
|
|
1672
|
+
}
|
|
1673
|
+
json_print("status", data=data, next_actions=[
|
|
1674
|
+
{"command": "slm health --json", "description": "Check math layer health"},
|
|
1675
|
+
{"command": "slm list --json", "description": "List recent memories"},
|
|
1676
|
+
])
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1586
1679
|
# WP-02 D8: canonical key set — db_size_mb always present (0.0 if absent).
|
|
1587
1680
|
db_size_mb = 0.0
|
|
1588
1681
|
if config.db_path.exists():
|
|
@@ -1635,6 +1728,7 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1635
1728
|
"fact_count": fact_count,
|
|
1636
1729
|
"entity_count": entity_count,
|
|
1637
1730
|
"edge_count": edge_count,
|
|
1731
|
+
"profile_generation": 0,
|
|
1638
1732
|
}
|
|
1639
1733
|
json_print("status", data=data, next_actions=[
|
|
1640
1734
|
{"command": "slm health --json", "description": "Check math layer health"},
|
|
@@ -1645,6 +1739,10 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1645
1739
|
print("SuperLocalMemory V3")
|
|
1646
1740
|
print(f" Mode: {config.mode.value.upper()}")
|
|
1647
1741
|
print(f" Provider: {config.llm.provider or 'none'}")
|
|
1742
|
+
print(
|
|
1743
|
+
f" Profile: "
|
|
1744
|
+
f"{daemon_status.get('profile') if daemon_status else config.active_profile}"
|
|
1745
|
+
)
|
|
1648
1746
|
print(f" Base dir: {config.base_dir}")
|
|
1649
1747
|
print(f" Database: {config.db_path}")
|
|
1650
1748
|
if config.db_path.exists():
|
|
@@ -1901,7 +1999,7 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
1901
1999
|
|
|
1902
2000
|
# 3. Search deps
|
|
1903
2001
|
search_mods = {"sentence_transformers": "sentence-transformers", "torch": "torch",
|
|
1904
|
-
"sklearn": "scikit-learn"
|
|
2002
|
+
"sklearn": "scikit-learn"}
|
|
1905
2003
|
search_ok = []
|
|
1906
2004
|
for mod, pkg in search_mods.items():
|
|
1907
2005
|
try:
|
|
@@ -1910,7 +2008,7 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
1910
2008
|
except Exception: # dependency import may fail after module discovery
|
|
1911
2009
|
pass
|
|
1912
2010
|
if len(search_ok) == len(search_mods):
|
|
1913
|
-
_check("Search deps", "PASS", "sentence-transformers, torch, sklearn
|
|
2011
|
+
_check("Search deps", "PASS", "sentence-transformers, torch, sklearn")
|
|
1914
2012
|
else:
|
|
1915
2013
|
missing = set(search_mods) - set(search_ok)
|
|
1916
2014
|
_check("Search deps", "WARN", f"Missing: {', '.join(missing)}",
|
|
@@ -2522,6 +2620,42 @@ def cmd_dashboard(args: Namespace) -> None:
|
|
|
2522
2620
|
# -- Profiles (supports --json) -------------------------------------------
|
|
2523
2621
|
|
|
2524
2622
|
|
|
2623
|
+
def _switch_profile_runtime(config, profile_name: str) -> dict:
|
|
2624
|
+
"""Switch through the resident daemon, or persist an offline fallback."""
|
|
2625
|
+
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
2626
|
+
|
|
2627
|
+
if is_daemon_running():
|
|
2628
|
+
result = daemon_request(
|
|
2629
|
+
"POST",
|
|
2630
|
+
f"/api/profiles/{profile_name}/switch",
|
|
2631
|
+
)
|
|
2632
|
+
if not result or not result.get("success"):
|
|
2633
|
+
raise RuntimeError(
|
|
2634
|
+
"resident daemon did not acknowledge the profile switch"
|
|
2635
|
+
)
|
|
2636
|
+
acknowledged = str(result.get("active_profile", ""))
|
|
2637
|
+
if acknowledged != profile_name:
|
|
2638
|
+
raise RuntimeError(
|
|
2639
|
+
"resident daemon acknowledged a different active profile"
|
|
2640
|
+
)
|
|
2641
|
+
return {
|
|
2642
|
+
"action": "switched",
|
|
2643
|
+
"profile": acknowledged,
|
|
2644
|
+
"generation": int(result.get("generation", 0)),
|
|
2645
|
+
"runtime": "daemon",
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
from superlocalmemory.server.profile_runtime import persist_active_profile
|
|
2649
|
+
|
|
2650
|
+
persist_active_profile(profile_name)
|
|
2651
|
+
config.active_profile = profile_name
|
|
2652
|
+
return {
|
|
2653
|
+
"action": "switched",
|
|
2654
|
+
"profile": profile_name,
|
|
2655
|
+
"runtime": "offline",
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
|
|
2525
2659
|
def cmd_profile(args: Namespace) -> None:
|
|
2526
2660
|
"""Profile management (list, switch, create).
|
|
2527
2661
|
|
|
@@ -2532,7 +2666,7 @@ def cmd_profile(args: Namespace) -> None:
|
|
|
2532
2666
|
from superlocalmemory.storage.database import DatabaseManager
|
|
2533
2667
|
from superlocalmemory.storage import schema
|
|
2534
2668
|
from superlocalmemory.server.routes.helpers import (
|
|
2535
|
-
ensure_profile_in_json,
|
|
2669
|
+
ensure_profile_in_json,
|
|
2536
2670
|
)
|
|
2537
2671
|
|
|
2538
2672
|
config = SLMConfig.load()
|
|
@@ -2552,10 +2686,25 @@ def cmd_profile(args: Namespace) -> None:
|
|
|
2552
2686
|
{"command": "slm profile switch <name> --json", "description": "Switch profile"},
|
|
2553
2687
|
])
|
|
2554
2688
|
elif args.action == "switch":
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2689
|
+
rows = db.execute(
|
|
2690
|
+
"SELECT 1 FROM profiles WHERE profile_id = ?",
|
|
2691
|
+
(args.name,),
|
|
2692
|
+
)
|
|
2693
|
+
if not rows:
|
|
2694
|
+
json_print("profile", error={
|
|
2695
|
+
"code": "PROFILE_NOT_FOUND",
|
|
2696
|
+
"message": f"Profile '{args.name}' does not exist.",
|
|
2697
|
+
})
|
|
2698
|
+
sys.exit(1)
|
|
2699
|
+
try:
|
|
2700
|
+
result = _switch_profile_runtime(config, args.name)
|
|
2701
|
+
except Exception as exc:
|
|
2702
|
+
json_print("profile", error={
|
|
2703
|
+
"code": "PROFILE_SWITCH_FAILED",
|
|
2704
|
+
"message": str(exc),
|
|
2705
|
+
})
|
|
2706
|
+
sys.exit(1)
|
|
2707
|
+
json_print("profile", data=result)
|
|
2559
2708
|
elif args.action == "create":
|
|
2560
2709
|
db.execute(
|
|
2561
2710
|
"INSERT OR IGNORE INTO profiles (profile_id, name) VALUES (?, ?)",
|
|
@@ -2576,10 +2725,21 @@ def cmd_profile(args: Namespace) -> None:
|
|
|
2576
2725
|
d = dict(r)
|
|
2577
2726
|
print(f" - {d['profile_id']}: {d.get('name', '')}")
|
|
2578
2727
|
elif args.action == "switch":
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2728
|
+
rows = db.execute(
|
|
2729
|
+
"SELECT 1 FROM profiles WHERE profile_id = ?",
|
|
2730
|
+
(args.name,),
|
|
2731
|
+
)
|
|
2732
|
+
if not rows:
|
|
2733
|
+
print(f"Profile '{args.name}' does not exist.", file=sys.stderr)
|
|
2734
|
+
sys.exit(1)
|
|
2735
|
+
try:
|
|
2736
|
+
result = _switch_profile_runtime(config, args.name)
|
|
2737
|
+
except Exception as exc:
|
|
2738
|
+
print(f"Profile switch failed: {exc}", file=sys.stderr)
|
|
2739
|
+
sys.exit(1)
|
|
2740
|
+
generation = result.get("generation")
|
|
2741
|
+
suffix = f" (generation {generation})" if generation is not None else ""
|
|
2742
|
+
print(f"Switched to profile: {args.name}{suffix}")
|
|
2583
2743
|
elif args.action == "create":
|
|
2584
2744
|
db.execute(
|
|
2585
2745
|
"INSERT OR IGNORE INTO profiles (profile_id, name) VALUES (?, ?)",
|
|
@@ -251,6 +251,23 @@ def _embedding_is_remote(config: Any) -> bool:
|
|
|
251
251
|
return provider in ("openai", "openai-compatible", "remote") or bool(endpoint)
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
def _build_wizard_config(mode):
|
|
255
|
+
"""Apply mode-owned presets without erasing existing user-owned blocks."""
|
|
256
|
+
from superlocalmemory.core.config import SLMConfig
|
|
257
|
+
from superlocalmemory.infra.data_root import state_path
|
|
258
|
+
|
|
259
|
+
template = SLMConfig.for_mode(mode)
|
|
260
|
+
if not state_path("config.json").exists():
|
|
261
|
+
return template
|
|
262
|
+
existing = SLMConfig.load()
|
|
263
|
+
existing.mode = mode
|
|
264
|
+
existing.llm = template.llm
|
|
265
|
+
existing.retrieval = template.retrieval
|
|
266
|
+
existing.math = template.math
|
|
267
|
+
existing.channel_weights = template.channel_weights
|
|
268
|
+
return existing
|
|
269
|
+
|
|
270
|
+
|
|
254
271
|
# ---------------------------------------------------------------------------
|
|
255
272
|
# Verification
|
|
256
273
|
# ---------------------------------------------------------------------------
|
|
@@ -397,7 +414,7 @@ def run_wizard(auto: bool = False) -> None:
|
|
|
397
414
|
from superlocalmemory.storage.models import Mode
|
|
398
415
|
|
|
399
416
|
mode_map = {"a": Mode.A, "b": Mode.B, "c": Mode.C}
|
|
400
|
-
config =
|
|
417
|
+
config = _build_wizard_config(mode_map[choice])
|
|
401
418
|
|
|
402
419
|
# -- Multi-scope (shared memory) opt-in — v3.6.15 --
|
|
403
420
|
# OFF by default: your memories stay private to this profile (3.6.14 behaviour).
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
2
|
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
3
|
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
-
"""
|
|
4
|
+
"""API-key primitives used by the unified identity boundary.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
A configured key authorizes remote callers that present ``X-SLM-API-Key``.
|
|
7
|
+
The unified daemon separately trusts exact-process capabilities, the
|
|
8
|
+
same-origin dashboard install token, and uncredentialed loopback peers as the
|
|
9
|
+
local OS-user boundary. Read endpoints remain open for backward compatibility.
|
|
10
10
|
|
|
11
11
|
V3 change: base directory moved from ``~/.claude-memory/`` to
|
|
12
12
|
``~/.superlocalmemory/``.
|
|
@@ -15,6 +15,7 @@ V3 change: base directory moved from ``~/.claude-memory/`` to
|
|
|
15
15
|
import hashlib
|
|
16
16
|
import hmac
|
|
17
17
|
import logging
|
|
18
|
+
import os
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import Optional
|
|
20
21
|
|
|
@@ -26,6 +27,13 @@ logger = logging.getLogger("superlocalmemory.auth")
|
|
|
26
27
|
MEMORY_DIR = DynamicStatePath()
|
|
27
28
|
API_KEY_FILE = DynamicStatePath("api_key")
|
|
28
29
|
|
|
30
|
+
# v3.7.8 (F1): opt-in env flag that restores the pre-v3.7.6 shared-host
|
|
31
|
+
# posture -- when set AND an api_key file is configured, uncredentialed
|
|
32
|
+
# loopback writes must also present a matching X-SLM-API-Key. Default OFF
|
|
33
|
+
# preserves the v3.7.6 local-first fix (#71/#73/#74): loopback callers are
|
|
34
|
+
# trusted as the local OS-user boundary without needing any credential.
|
|
35
|
+
SLM_REQUIRE_API_KEY_LOOPBACK_ENV = "SLM_REQUIRE_API_KEY_LOOPBACK"
|
|
36
|
+
|
|
29
37
|
|
|
30
38
|
def _load_api_key_hash(key_file: Optional[Path] = None) -> Optional[str]:
|
|
31
39
|
"""Load and hash the API key from disk.
|
|
@@ -96,6 +104,26 @@ def verify_api_key(
|
|
|
96
104
|
return hmac.compare_digest(actual, expected)
|
|
97
105
|
|
|
98
106
|
|
|
107
|
+
def loopback_strict_mode_enabled(key_file: Optional[Path] = None) -> bool:
|
|
108
|
+
"""Whether uncredentialed loopback writes must present the API key.
|
|
109
|
+
|
|
110
|
+
v3.7.8 (F1/F2): opt-in via ``SLM_REQUIRE_API_KEY_LOOPBACK`` (any of
|
|
111
|
+
"1"/"true"/"yes"/"on", case-insensitive). This is the sole enforcement
|
|
112
|
+
point for the strict shared-host posture -- it is checked ONLY for the
|
|
113
|
+
uncredentialed-loopback case (a caller presenting none of
|
|
114
|
+
X-SLM-Daemon-Capability / X-Install-Token / X-SLM-API-Key). Callers who
|
|
115
|
+
already present a valid capability or install token are unaffected: those
|
|
116
|
+
are stronger, explicit credentials and this flag never re-litigates them.
|
|
117
|
+
|
|
118
|
+
Returns ``False`` (no-op) when the flag is unset/false, OR when no
|
|
119
|
+
api_key file is configured -- there is nothing to require in that case.
|
|
120
|
+
"""
|
|
121
|
+
raw = os.environ.get(SLM_REQUIRE_API_KEY_LOOPBACK_ENV, "")
|
|
122
|
+
if raw.strip().lower() not in ("1", "true", "yes", "on"):
|
|
123
|
+
return False
|
|
124
|
+
return _load_api_key_hash(key_file) is not None
|
|
125
|
+
|
|
126
|
+
|
|
99
127
|
def authorize_http_mcp_request(
|
|
100
128
|
request_headers: dict,
|
|
101
129
|
*,
|
|
@@ -17,10 +17,8 @@ engine state exists in exactly one process: the daemon.
|
|
|
17
17
|
"""
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
-
import json
|
|
21
20
|
import logging
|
|
22
21
|
import urllib.parse
|
|
23
|
-
import urllib.request
|
|
24
22
|
from typing import Any
|
|
25
23
|
|
|
26
24
|
logger = logging.getLogger(__name__)
|
|
@@ -40,9 +38,6 @@ class DaemonPoolProxy:
|
|
|
40
38
|
self._port = port
|
|
41
39
|
self._timeout = timeout_s
|
|
42
40
|
|
|
43
|
-
def _url(self, path: str) -> str:
|
|
44
|
-
return f"http://127.0.0.1:{self._port}{path}"
|
|
45
|
-
|
|
46
41
|
def recall(
|
|
47
42
|
self, query: str, limit: int = 10, session_id: str = "",
|
|
48
43
|
fast: bool = False,
|
|
@@ -64,15 +59,18 @@ class DaemonPoolProxy:
|
|
|
64
59
|
_params["include_shared"] = "true" if include_shared else "false"
|
|
65
60
|
params = urllib.parse.urlencode(_params)
|
|
66
61
|
try:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
from superlocalmemory.cli.daemon import daemon_request
|
|
63
|
+
|
|
64
|
+
data = daemon_request(
|
|
65
|
+
"GET",
|
|
66
|
+
f"/recall?{params}",
|
|
67
|
+
timeout_seconds=self._timeout,
|
|
68
|
+
)
|
|
71
69
|
except Exception as exc:
|
|
72
70
|
logger.warning("daemon /recall failed: %s", exc)
|
|
73
71
|
return {"ok": False, "error": str(exc)}
|
|
74
72
|
if not isinstance(data, dict):
|
|
75
|
-
return {"ok": False, "error": "
|
|
73
|
+
return {"ok": False, "error": "owned daemon unavailable"}
|
|
76
74
|
data.setdefault("ok", True)
|
|
77
75
|
return data
|
|
78
76
|
|
|
@@ -89,6 +89,7 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
89
89
|
# Core memory operations (8)
|
|
90
90
|
"remember", "recall", "search", "fetch",
|
|
91
91
|
"list_recent", "delete_memory", "update_memory", "get_status",
|
|
92
|
+
"switch_profile",
|
|
92
93
|
# Session lifecycle (3)
|
|
93
94
|
"session_init", "observe", "close_session",
|
|
94
95
|
# Feedback / learning signals — reachable Dash-Core path for
|