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.
Files changed (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin-src/manifest.json +1 -1
  7. package/plugin-src/requirements.txt +1 -1
  8. package/pyproject.toml +6 -7
  9. package/src/superlocalmemory/__init__.py +1 -1
  10. package/src/superlocalmemory/cli/commands.py +171 -11
  11. package/src/superlocalmemory/cli/setup_wizard.py +18 -1
  12. package/src/superlocalmemory/infra/auth_middleware.py +33 -5
  13. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  14. package/src/superlocalmemory/mcp/server.py +1 -0
  15. package/src/superlocalmemory/mcp/tools_core.py +216 -20
  16. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  17. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  18. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  19. package/src/superlocalmemory/server/api.py +17 -0
  20. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  21. package/src/superlocalmemory/server/recall_health.py +12 -6
  22. package/src/superlocalmemory/server/routes/chat.py +63 -12
  23. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  24. package/src/superlocalmemory/server/routes/memories.py +58 -11
  25. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  26. package/src/superlocalmemory/server/routes/v3_api.py +128 -52
  27. package/src/superlocalmemory/server/ui.py +10 -0
  28. package/src/superlocalmemory/server/unified_daemon.py +290 -74
  29. package/src/superlocalmemory/storage/migration_runner.py +17 -3
  30. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  31. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  32. package/src/superlocalmemory/ui/index.html +32 -1
  33. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  34. 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.5</h1>
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.5</code> — <strong>Scale Engine graph and vector projection parity, durable promotion recovery, and honest runtime status.</strong><br/>
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> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; 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.6",
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 --test 'tests/ui/*.mjs'"
54
+ "test": "node scripts/run-ui-tests.mjs"
55
55
  },
56
56
  "engines": {
57
57
  "node": ">=18.0.0",
@@ -16,5 +16,5 @@
16
16
  "mcpServers": "./.mcp.json",
17
17
  "name": "superlocalmemory",
18
18
  "repository": "https://github.com/qualixar/superlocalmemory",
19
- "version": "3.7.6"
19
+ "version": "3.7.8"
20
20
  }
@@ -1 +1 @@
1
- superlocalmemory==3.7.6
1
+ superlocalmemory==3.7.8
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.7.6",
2
+ "version": "3.7.8",
3
3
  "pluginName": "superlocalmemory",
4
4
  "displayName": "SuperLocalMemory",
5
5
  "repository": "https://github.com/qualixar/superlocalmemory",
@@ -1 +1 @@
1
- superlocalmemory==3.7.6
1
+ superlocalmemory==3.7.8
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.7.6"
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.27.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.136.1",
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==45.0.2",
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==4.57.6",
71
- "huggingface_hub==0.36.2",
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.6"
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", "geoopt": "geoopt"}
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, geoopt")
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, set_active_profile_everywhere,
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
- set_active_profile_everywhere(args.name)
2556
- config.active_profile = args.name
2557
- config.save()
2558
- json_print("profile", data={"action": "switched", "profile": args.name})
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
- set_active_profile_everywhere(args.name)
2580
- config.active_profile = args.name
2581
- config.save()
2582
- print(f"Switched to profile: {args.name}")
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 = SLMConfig.for_mode(mode_map[choice])
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
- """Opt-in API-key authentication middleware.
4
+ """API-key primitives used by the unified identity boundary.
5
5
 
6
- When ``~/.superlocalmemory/api_key`` exists, write endpoints require the
7
- ``X-SLM-API-Key`` header. Read endpoints remain open for backward
8
- compatibility. If the key file is absent auth is completely disabled --
9
- all requests pass.
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
- with urllib.request.urlopen(
68
- self._url(f"/recall?{params}"), timeout=self._timeout,
69
- ) as resp:
70
- data = json.loads(resp.read().decode() or "{}")
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": "non-dict response"}
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