superlocalmemory 4.0.2 → 4.0.4

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 (57) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +36 -40
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +4 -4
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +3 -3
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +4 -4
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +1 -1
  37. package/scripts/postinstall-interactive.js +1 -0
  38. package/scripts/postinstall.js +4 -0
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +10 -19
  41. package/src/superlocalmemory/cli/host_upgrades.py +175 -0
  42. package/src/superlocalmemory/cli/main.py +29 -4
  43. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +184 -0
  44. package/src/superlocalmemory/learning/database.py +2 -1
  45. package/src/superlocalmemory/mcp/profiles.py +28 -13
  46. package/src/superlocalmemory/mcp/server.py +7 -6
  47. package/src/superlocalmemory/mcp/tools_brain.py +89 -4
  48. package/src/superlocalmemory/server/routes/brain.py +6 -1
  49. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  50. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  51. package/src/superlocalmemory/storage/agent_experience.py +26 -4
  52. package/src/superlocalmemory/storage/external_evidence.py +359 -0
  53. package/src/superlocalmemory/storage/migration_runner.py +5 -0
  54. package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
  55. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  56. package/src/superlocalmemory/ui/js/od-brain.js +9 -0
  57. package/src/superlocalmemory/ui/js/od-mcp.js +6 -6
@@ -0,0 +1,175 @@
1
+ """Consented, non-destructive upgrades for existing host integrations.
2
+
3
+ Package installation owns the SLM executable. This module owns the separate
4
+ operator-approved step that refreshes SLM-owned integration assets. It never
5
+ discovers a host and edits it implicitly: preview is the default and apply
6
+ requires either explicit hosts or an explicit all-detected acknowledgement.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from argparse import Namespace
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def validate_upgrade_request(*, apply: bool, hosts: list[str], all_detected: bool) -> None:
17
+ """Reject unbounded mutations before any host filesystem access."""
18
+ if apply and not hosts and not all_detected:
19
+ raise ValueError("--apply requires at least one --host or --all-detected")
20
+ if hosts and all_detected:
21
+ raise ValueError("choose explicit --host values or --all-detected, not both")
22
+
23
+
24
+ def cmd_upgrade_hosts(args: Namespace) -> None:
25
+ """Preview or explicitly apply host integration refreshes."""
26
+ hosts = list(getattr(args, "hosts", []) or [])
27
+ apply = bool(getattr(args, "apply", False))
28
+ all_detected = bool(getattr(args, "all_detected", False))
29
+ try:
30
+ validate_upgrade_request(apply=apply, hosts=hosts, all_detected=all_detected)
31
+ except ValueError as exc:
32
+ print(f"Error: {exc}")
33
+ return
34
+
35
+ from superlocalmemory.hooks.portable_kit import supported_ides
36
+
37
+ detected = set(_detected_hosts())
38
+ targets = sorted(detected) if all_detected else (hosts or sorted(detected))
39
+ managed_hosts = set(supported_ides()) | {"claude-code", "hermes"}
40
+ unknown = sorted(set(targets) - managed_hosts)
41
+ if unknown:
42
+ print(f"Error: unsupported host(s): {', '.join(unknown)}")
43
+ return
44
+ if not targets:
45
+ print("No existing SLM host integrations were detected.")
46
+ print("Run `slm setup` for first-time setup, or use `--host <name>` to target one host.")
47
+ return
48
+
49
+ operation = "Applying" if apply else "Previewing"
50
+ print(f"{operation} SLM host upgrades: {', '.join(targets)}")
51
+ if not apply:
52
+ print("No host files will be changed. Re-run with --apply and explicit targets to proceed.")
53
+
54
+ for host in targets:
55
+ result = _upgrade_host(host, apply=apply, already_integrated=host in detected)
56
+ status = result.get("status", "error")
57
+ detail = result.get("detail", "")
58
+ print(f" [{status}] {host}: {detail}")
59
+
60
+ if apply:
61
+ print("Restart affected host applications, then run `slm doctor` to verify SLM itself.")
62
+
63
+
64
+ def _detected_hosts(home: Path | None = None) -> list[str]:
65
+ """Return only hosts already containing an SLM-owned integration block."""
66
+ from superlocalmemory.hooks.portable_kit import (
67
+ IDE_MATRIX,
68
+ _load_config,
69
+ _ParseError,
70
+ )
71
+
72
+ effective_home = home or Path.home()
73
+ detected: list[str] = []
74
+ for host, desc in IDE_MATRIX.items():
75
+ if not desc.fmt:
76
+ continue
77
+ path = effective_home / desc.mcp_path_global
78
+ try:
79
+ data = _load_config(path, desc.fmt)
80
+ except _ParseError:
81
+ continue
82
+ if _contains_slm_block(data, desc):
83
+ detected.append(host)
84
+
85
+ # Claude Code's plugin is host-managed. A local SLM hook installation is
86
+ # the durable evidence that its SLM integration exists; upgrading the
87
+ # plugin remains an explicit Claude Code marketplace action.
88
+ try:
89
+ from superlocalmemory.hooks.claude_code_hooks import check_status
90
+
91
+ if check_status().get("installed"):
92
+ detected.append("claude-code")
93
+ except Exception:
94
+ pass
95
+
96
+ # Hermes has a native YAML mapping rather than the portable-kit schema.
97
+ # Detect it for reporting, but never overwrite it with an invented format.
98
+ hermes_path = effective_home / ".hermes" / "config.yaml"
99
+ try:
100
+ hermes = _load_config(hermes_path, "yaml")
101
+ servers = hermes.get("mcp_servers", {})
102
+ if isinstance(servers, dict) and "superlocalmemory" in servers:
103
+ detected.append("hermes")
104
+ except _ParseError:
105
+ pass
106
+ return sorted(set(detected))
107
+
108
+
109
+ def _contains_slm_block(data: dict[str, Any], desc: Any) -> bool:
110
+ if desc.fmt == "yaml":
111
+ providers = data.get(desc.server_key, [])
112
+ return isinstance(providers, list) and any(
113
+ isinstance(item, dict)
114
+ and item.get("params", {}).get("serverName") == "superlocalmemory"
115
+ for item in providers
116
+ )
117
+ servers = data.get(desc.server_key, {})
118
+ return isinstance(servers, dict) and "superlocalmemory" in servers
119
+
120
+
121
+ def _upgrade_host(host: str, *, apply: bool, already_integrated: bool) -> dict[str, str]:
122
+ """Refresh assets without degrading a working, host-specific MCP block."""
123
+ if host == "claude-code":
124
+ return {
125
+ "status": "plugin-managed",
126
+ "detail": "run `claude plugin update superlocalmemory@qualixar` in Claude Code",
127
+ }
128
+
129
+ if host == "hermes":
130
+ if already_integrated:
131
+ return {
132
+ "status": "verified",
133
+ "detail": "existing native Hermes SLM mapping preserved",
134
+ }
135
+ return {
136
+ "status": "blocked",
137
+ "detail": "Hermes is not connected; follow docs/ide-setup.md before retrying",
138
+ }
139
+
140
+ if host == "codex" and already_integrated:
141
+ from superlocalmemory.hooks.codex_assets import install_assets
142
+ from superlocalmemory.hooks.codex_hooks import install_hooks
143
+
144
+ assets, hooks = install_assets(dry_run=not apply), install_hooks(dry_run=not apply)
145
+ if not assets.get("success") or not hooks.get("success"):
146
+ return {"status": "blocked", "detail": "could not refresh SLM-owned Codex assets"}
147
+ action = "would refresh" if not apply else "refreshed"
148
+ return {
149
+ "status": "updated" if apply else "preview",
150
+ "detail": (
151
+ f"{action} SLM-owned Codex skills, agents, and hooks; "
152
+ "MCP block preserved"
153
+ ),
154
+ }
155
+
156
+ if already_integrated:
157
+ return {
158
+ "status": "verified",
159
+ "detail": "existing SLM MCP block preserved; no portable host assets require refresh",
160
+ }
161
+
162
+ # An explicitly named, not-yet-connected host is a first-time connection.
163
+ # This branch is never reached by --all-detected.
164
+ from superlocalmemory.hooks.portable_kit import connect_ide
165
+
166
+ result = connect_ide(host, dry_run=not apply)
167
+ if result.get("error"):
168
+ return {"status": "blocked", "detail": str(result["error"])}
169
+ action = result.get("mcp_config", "unknown")
170
+ if not apply:
171
+ return {"status": "preview", "detail": f"would refresh SLM MCP block ({action})"}
172
+ return {"status": "updated", "detail": f"SLM MCP block {action}"}
173
+
174
+
175
+ __all__ = ("cmd_upgrade_hosts", "validate_upgrade_request")
@@ -69,7 +69,7 @@ documentation:
69
69
 
70
70
 
71
71
  _NO_DAEMON_COMMANDS = {
72
- "setup", "mode", "provider", "connect", "migrate", "mcp", "warmup", "hooks", "codex",
72
+ "setup", "mode", "provider", "connect", "upgrade-hosts", "migrate", "mcp", "warmup", "hooks", "codex",
73
73
  "config", "evolve", "db",
74
74
  # v3.4.22 escape hatches — never auto-start the daemon on these.
75
75
  "disable", "enable", "clear-cache", "reconfigure", "benchmark",
@@ -123,12 +123,17 @@ def main() -> None:
123
123
  and sys.argv[1] == "connect"
124
124
  and "--dry-run" in sys.argv[2:]
125
125
  )
126
+ _is_host_upgrade_preview = (
127
+ len(sys.argv) >= 2
128
+ and sys.argv[1] == "upgrade-hosts"
129
+ and "--apply" not in sys.argv[2:]
130
+ )
126
131
 
127
132
  # WP-07: lazy first-run init — runs after hook/mcp fast-paths so stdout
128
133
  # is never polluted on those paths (CRIT-3, MCP JSON-RPC purity).
129
134
  # Guarded: any failure must not crash the CLI (AC4).
130
135
  _is_mcp_cmd = len(sys.argv) >= 2 and sys.argv[1] == "mcp"
131
- if not _is_mcp_cmd and not _is_metadata_cmd and not _is_connect_dry_run:
136
+ if not _is_mcp_cmd and not _is_metadata_cmd and not _is_connect_dry_run and not _is_host_upgrade_preview:
132
137
  try:
133
138
  from superlocalmemory.cli._lazy_init import _ensure_initialized
134
139
  _ensure_initialized()
@@ -148,7 +153,7 @@ def main() -> None:
148
153
 
149
154
  # One-time post-upgrade banner — silent for fresh installs and
150
155
  # same-version runs. Guarded against I/O errors internally.
151
- if not _is_mcp_stdio and not _is_metadata_cmd and not _is_connect_dry_run:
156
+ if not _is_mcp_stdio and not _is_metadata_cmd and not _is_connect_dry_run and not _is_host_upgrade_preview:
152
157
  from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
153
158
  if check_and_emit_upgrade_banner(_ver):
154
159
  # First post-upgrade invocation: apply the data-dir migration if
@@ -285,6 +290,23 @@ def main() -> None:
285
290
  ),
286
291
  )
287
292
 
293
+ upgrade_p = sub.add_parser(
294
+ "upgrade-hosts",
295
+ help="Preview or explicitly refresh installed SLM host integrations",
296
+ )
297
+ upgrade_p.add_argument(
298
+ "--host", action="append", dest="hosts", default=[],
299
+ help="Explicit host to refresh (repeatable; for example: codex, cursor)",
300
+ )
301
+ upgrade_p.add_argument(
302
+ "--all-detected", action="store_true", default=False,
303
+ help="Target only hosts that already contain an SLM integration",
304
+ )
305
+ upgrade_p.add_argument(
306
+ "--apply", action="store_true", default=False,
307
+ help="Apply the previewed changes; default is read-only preview",
308
+ )
309
+
288
310
  migrate_p = sub.add_parser("migrate", help="Migrate data from V2 to V3 schema")
289
311
  migrate_p.add_argument(
290
312
  "--rollback", action="store_true", help="Rollback migration",
@@ -966,7 +988,10 @@ def main() -> None:
966
988
  sys.exit(0)
967
989
 
968
990
  # V3.3.19: Auto-trigger setup wizard on first use
969
- if not (args.command == "connect" and getattr(args, "dry_run", False)):
991
+ if not (
992
+ (args.command == "connect" and getattr(args, "dry_run", False))
993
+ or (args.command == "upgrade-hosts" and not getattr(args, "apply", False))
994
+ ):
970
995
  from superlocalmemory.cli.setup_wizard import check_first_use
971
996
  check_first_use(args.command)
972
997
 
@@ -0,0 +1,184 @@
1
+ """Versioned, read-only contract boundary for Bounded Loops MCP evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import shutil
9
+ import stat
10
+ from collections.abc import Awaitable, Callable
11
+ from copy import deepcopy
12
+ from datetime import timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ CONTRACT_ID = "bounded-loops.dev/slm-bridge/v1"
17
+ _OBSERVATION_TIMEOUT_SECONDS = 5.0
18
+ _MAX_MCP_TEXT_BYTES = 2 * 1024 * 1024
19
+ _ADVERTISEMENT = {
20
+ "id": CONTRACT_ID,
21
+ "tool": "bl_graph_evidence",
22
+ "operation": "observe_terminal_run",
23
+ }
24
+
25
+
26
+ class BridgeUnavailable(ValueError):
27
+ """The installed producer does not advertise a compatible bridge contract."""
28
+
29
+
30
+ def supports_bridge(capabilities: dict[str, Any]) -> bool:
31
+ """Negotiate on the declared public contract, never producer semver."""
32
+ advertised = capabilities.get("evidence_contracts")
33
+ return isinstance(advertised, list) and any(
34
+ isinstance(item, dict)
35
+ and all(item.get(key) == value for key, value in _ADVERTISEMENT.items())
36
+ for item in advertised
37
+ )
38
+
39
+
40
+ def bridge_payload(evidence: dict[str, Any], *, profile_id: str) -> dict[str, Any]:
41
+ """Attach active-profile identity after refusing incompatible evidence."""
42
+ if evidence.get("contract") != CONTRACT_ID:
43
+ raise BridgeUnavailable("unsupported bounded-loops evidence contract")
44
+ if evidence.get("eligible_for_learning") is not False:
45
+ raise BridgeUnavailable("bounded-loops evidence is not observation-only")
46
+ # The producer has organisation/project metadata for its own control plane.
47
+ # SLM stores only the v1 observation receipt needed by its profile-scoped
48
+ # learning database; retaining arbitrary producer extensions would turn a
49
+ # versioned contract into an unbounded schema sink.
50
+ fields = (
51
+ "contract",
52
+ "workspace_id",
53
+ "run_ref",
54
+ "run_id",
55
+ "outcome",
56
+ "run_state",
57
+ "demonstration",
58
+ "eligible_for_learning",
59
+ "terminal_at",
60
+ "graph_digest",
61
+ "plan_digest",
62
+ "policy_digest",
63
+ "receipt",
64
+ "nodes",
65
+ )
66
+ if any(field not in evidence for field in fields):
67
+ raise BridgeUnavailable("bounded-loops evidence is missing required v1 fields")
68
+ return {field: deepcopy(evidence[field]) for field in fields} | {"profile_id": profile_id}
69
+
70
+
71
+ async def observe_terminal_runs(
72
+ call_tool: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]], *, profile_id: str
73
+ ) -> list[dict[str, Any]]:
74
+ """Collect only producer-advertised terminal evidence over an injected MCP transport."""
75
+ discovery = await call_tool("bl_capabilities", {})
76
+ if discovery.get("status") != "ok" or not supports_bridge(discovery.get("capabilities", {})):
77
+ raise BridgeUnavailable("bounded-loops does not advertise slm-bridge/v1")
78
+ listing = await call_tool("bl_graph_terminal_runs", {"limit": 100})
79
+ if listing.get("status") != "ok" or listing.get("contract") != CONTRACT_ID:
80
+ raise BridgeUnavailable("bounded-loops terminal listing is unavailable")
81
+ runs = listing.get("runs")
82
+ if not isinstance(runs, list):
83
+ raise BridgeUnavailable("bounded-loops terminal listing is malformed")
84
+ # The producer's limit is advisory. Keep this explicit operation bounded
85
+ # even against a compatible but faulty/malicious producer.
86
+ runs = runs[:100]
87
+ observed: list[dict[str, Any]] = []
88
+ for run in runs:
89
+ if not isinstance(run, dict) or not isinstance(run.get("run_ref"), str):
90
+ raise BridgeUnavailable("bounded-loops terminal listing is malformed")
91
+ response = await call_tool("bl_graph_evidence", {"run_ref": run["run_ref"]})
92
+ if response.get("status") == "unavailable":
93
+ continue
94
+ if response.get("status") != "ok" or not isinstance(response.get("evidence"), dict):
95
+ raise BridgeUnavailable("bounded-loops evidence response is malformed")
96
+ observed.append(bridge_payload(response["evidence"], profile_id=profile_id))
97
+ return observed
98
+
99
+
100
+ async def observe_from_stdio(*, command: str, cwd: str, profile_id: str) -> list[dict[str, Any]]:
101
+ """Run one bounded, explicit MCP 2 observation; never call from recall or remember."""
102
+ executable, workspace = Path(command), Path(cwd)
103
+ if (
104
+ not executable.is_absolute()
105
+ or not executable.is_file()
106
+ or not workspace.is_absolute()
107
+ or not workspace.is_dir()
108
+ or workspace.is_symlink()
109
+ ):
110
+ raise BridgeUnavailable(
111
+ "bounded-loops bridge requires an approved executable and workspace"
112
+ )
113
+ try:
114
+ executable = executable.resolve(strict=True)
115
+ workspace = workspace.resolve(strict=True)
116
+ mode = executable.stat().st_mode
117
+ except OSError as exc:
118
+ raise BridgeUnavailable("bounded-loops bridge path is unavailable") from exc
119
+ if not stat.S_ISREG(mode) or (
120
+ os.name != "nt" and mode & (stat.S_IWGRP | stat.S_IWOTH)
121
+ ):
122
+ raise BridgeUnavailable("bounded-loops executable is not a trusted regular file")
123
+ if executable.stat().st_uid not in {0, os.geteuid()}:
124
+ raise BridgeUnavailable("bounded-loops executable owner is not trusted")
125
+
126
+ from mcp import ClientSession, StdioServerParameters
127
+ from mcp.client.stdio import stdio_client
128
+
129
+ try:
130
+ parameters = StdioServerParameters(
131
+ command=str(executable), args=[], cwd=str(workspace)
132
+ )
133
+ async with stdio_client(parameters) as (read, write):
134
+ async with ClientSession(
135
+ read,
136
+ write,
137
+ read_timeout_seconds=timedelta(seconds=_OBSERVATION_TIMEOUT_SECONDS),
138
+ ) as session:
139
+ async def observe() -> list[dict[str, Any]]:
140
+ await session.initialize()
141
+
142
+ async def call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
143
+ result = await session.call_tool(name, arguments)
144
+ if result.isError:
145
+ raise BridgeUnavailable(
146
+ "bounded-loops rejected the observation request"
147
+ )
148
+ texts = [item.text for item in result.content if hasattr(item, "text")]
149
+ if len(texts) != 1 or len(texts[0].encode("utf-8")) > _MAX_MCP_TEXT_BYTES:
150
+ raise BridgeUnavailable("bounded-loops returned an invalid MCP payload")
151
+ try:
152
+ payload = json.loads(texts[0])
153
+ except json.JSONDecodeError as exc:
154
+ raise BridgeUnavailable("bounded-loops returned invalid JSON") from exc
155
+ if not isinstance(payload, dict):
156
+ raise BridgeUnavailable("bounded-loops returned an invalid MCP payload")
157
+ return payload
158
+
159
+ return await observe_terminal_runs(call, profile_id=profile_id)
160
+ return await asyncio.wait_for(observe(), timeout=_OBSERVATION_TIMEOUT_SECONDS)
161
+ except BridgeUnavailable:
162
+ raise
163
+ except Exception as exc:
164
+ raise BridgeUnavailable("bounded-loops observation timed out or could not start") from exc
165
+
166
+
167
+ async def observe_installed(*, workspace: str, profile_id: str) -> list[dict[str, Any]]:
168
+ """Observe a user-installed producer without accepting an agent command.
169
+
170
+ Discovery deliberately resolves exactly the public ``bounded-loops-mcp``
171
+ executable. It does not accept a command, shell fragment, or arguments
172
+ from an MCP caller; the only caller-supplied value is the existing project
173
+ workspace whose Bounded Loops state is to be read.
174
+ """
175
+ command = shutil.which("bounded-loops-mcp")
176
+ if command is None:
177
+ raise BridgeUnavailable("bounded-loops-mcp is not installed")
178
+ if not Path(command).is_absolute():
179
+ raise BridgeUnavailable("bounded-loops-mcp discovery returned an unsafe path")
180
+ if Path(command).resolve().name not in {"bounded-loops-mcp", "bounded-loops-mcp.exe"}:
181
+ raise BridgeUnavailable("bounded-loops-mcp discovery returned an unsafe executable")
182
+ return await observe_from_stdio(
183
+ command=str(Path(command).resolve()), cwd=workspace, profile_id=profile_id
184
+ )
@@ -613,7 +613,8 @@ class LearningDatabase:
613
613
  row[0]
614
614
  for row in conn.execute(
615
615
  "SELECT name FROM sqlite_master WHERE type='table' "
616
- "AND name IN ('agent_experiences', 'cognitive_turn_receipts')"
616
+ "AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
617
+ "'external_evidence_receipts')"
617
618
  )
618
619
  }
619
620
  if profile_id is None:
@@ -23,7 +23,15 @@ _PROFILE_CORE: frozenset[str] = frozenset({ # 14
23
23
  "slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
24
24
  })
25
25
 
26
- _PROFILE_CODE: frozenset[str] = _PROFILE_CORE | frozenset({ # 24
26
+ # Portable Brain evidence must reach the coding-host profile shipped by the
27
+ # Claude/Codex plugins, not only an unrestricted server.
28
+ _PROFILE_BRAIN: frozenset[str] = frozenset({
29
+ "get_brain_evidence_status", "record_agent_experience",
30
+ "record_cognitive_turn", "finalize_cognitive_turn",
31
+ "observe_bounded_loop_evidence",
32
+ })
33
+
34
+ _PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ # 29
27
35
  "build_code_graph", "get_blast_radius", "query_graph",
28
36
  "semantic_search_code", "get_review_context", "detect_changes",
29
37
  # switch_profile lets a plugin/IDE session change the active workspace over
@@ -40,23 +48,22 @@ _PROFILE_FULL_MESH: frozenset[str] = frozenset({ # 8
40
48
  "mesh_state", "mesh_lock", "mesh_events", "mesh_status",
41
49
  })
42
50
 
43
- _PROFILE_FULL: frozenset[str] = frozenset({ # 34 base — EXPLICIT literal, NOT runtime _ESSENTIAL_TOOLS (OQ-2)
51
+ _PROFILE_FULL: frozenset[str] = frozenset({ # 39 base — EXPLICIT literal, NOT runtime _ESSENTIAL_TOOLS (OQ-2)
44
52
  "remember", "recall", "search", "fetch", "list_recent", "delete_memory", "update_memory",
45
53
  "get_status", "session_init", "observe", "close_session", "report_feedback", "forget",
46
54
  "run_maintenance", "consolidate_cognitive", "get_soft_prompts", "set_mode", "report_outcome",
47
55
  "log_tool_event", "get_assertions", "reinforce_assertion", "contradict_assertion",
56
+ "get_brain_evidence_status", "record_agent_experience",
57
+ "record_cognitive_turn", "finalize_cognitive_turn",
58
+ "observe_bounded_loop_evidence",
48
59
  "evolve_skill", "skill_health", "skill_lineage", "switch_profile",
49
60
  "slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
50
61
  # v3.8.0: bounded-loop tools (CLI + /slm-loop command + MCP).
51
62
  "slm_loop_run", "slm_loop_history", "slm_loop_show",
52
- # v4: prestage_context IS registered (see mcp/server.py) but is deliberately
53
- # NOT in full/power. The profile names are a user-facing config contract
54
- # (SLM_MCP_PROFILE=full42) and the published tool-count table depends on
55
- # them; adding a tool here would make "full42" serve 43 tools. New tools
56
- # reach users through the `whole` profile until a profile rename is shipped.
57
- }) | _PROFILE_FULL_MESH # 42
63
+ # prestage_context remains registered but deliberately raw-server-only.
64
+ }) | _PROFILE_FULL_MESH # 47
58
65
 
59
- _PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 54
66
+ _PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 59
60
67
  "get_version", "get_mode", "health", "consistency_check", "recall_trace",
61
68
  "get_lifecycle_status", "set_retention_policy", "compact_memories",
62
69
  "get_behavioral_patterns", "audit_trail", "quantize", "get_retention_stats",
@@ -79,21 +86,29 @@ _PROFILE_DEFINITIONS: dict[str, frozenset[str]] = {
79
86
  # at server startup. Any other value is a configuration error (fail closed).
80
87
  _PROFILE_ALIASES: dict[str, str] = {
81
88
  "core14": "core",
82
- # 3.8.0: switch_profile (+1) then bounded-loop tools (+3) grew code/full/
89
+ # 3.8.0 and later additions grew code/full/power; every historical count
83
90
  # power. Every historical count-suffixed name is kept so a v3.6/3.7/early-
84
91
  # 3.8 config still resolves (back-compat); new 3.8.0 counts added alongside.
85
92
  "code20": "code",
86
93
  "code21": "code",
87
94
  "code24": "code",
95
+ "code28": "code",
96
+ "code29": "code",
88
97
  "full38": "full",
89
98
  "full39": "full",
90
99
  "full42": "full",
100
+ "full46": "full",
101
+ "full47": "full",
91
102
  "power50": "power",
92
103
  "power51": "power",
93
104
  "power54": "power",
105
+ "power58": "power",
106
+ "power59": "power",
94
107
  "mesh8": "mesh",
95
108
  "whole81": "whole",
96
109
  "whole84": "whole",
110
+ "whole91": "whole",
111
+ "whole92": "whole",
97
112
  }
98
113
 
99
114
  # Plain-English descriptions for UI display.
@@ -101,8 +116,8 @@ _PROFILE_ALIASES: dict[str, str] = {
101
116
  # one sentence, user-facing language only.
102
117
  PROFILE_DESCRIPTIONS: dict[str, str] = {
103
118
  "core": "Essential memory: store, recall, search, sessions",
104
- "code": "Core + code-graph tools + profile switching (default for IDE coding agents)",
105
- "full": "All everyday memory, optimization, and mesh tools",
106
- "power": "Everything in full plus advanced governance/behavioral tools",
119
+ "code": "Core + code graph, portable Brain evidence, and profile switching (default for IDE coding agents)",
120
+ "full": "All everyday memory, portable Brain evidence, optimization, and mesh tools",
121
+ "power": "Everything in full plus advanced governance and behavioral tools",
107
122
  "mesh": "Cross-device mesh coordination only",
108
123
  }
@@ -77,13 +77,13 @@ def reset_engine():
77
77
 
78
78
  # Register tools and resources -------------------------------------------------
79
79
  #
80
- # Essential-only default: 34 base tools + 8 mesh tools = 42 registered
80
+ # Essential-only default: 39 base tools + 8 mesh tools = 47 registered.
81
81
  # when mesh is enabled. Set ``SLM_MCP_ALL_TOOLS=1`` to expose the full
82
82
  # toolset. Rationale: IDEs cap at 50-100 tools total (Cursor,
83
83
  # Antigravity, Windsurf) and a maximal SLM registration crowds out
84
84
  # other MCP servers the user may have installed.
85
85
  # Admin/diagnostics tools remain available via CLI (`slm <command>`).
86
- # Set SLM_MCP_ALL_TOOLS=1 to enable all 87 tools (power users).
86
+ # Set SLM_MCP_ALL_TOOLS=1 to enable all 92 tools (power users).
87
87
 
88
88
  import os as _os_reg
89
89
 
@@ -100,13 +100,14 @@ _ESSENTIAL_TOOLS: set[str] = {
100
100
  # v4.0.2 portable Brain evidence: profile-scoped receipt reads/writes.
101
101
  "get_brain_evidence_status", "record_agent_experience",
102
102
  "record_cognitive_turn", "finalize_cognitive_turn",
103
+ # v4.0.4: explicit, optional observation from the separately installed
104
+ # Bounded Loops MCP producer. It never participates in recall/ranking.
105
+ "observe_bounded_loop_evidence",
103
106
  # Memory management (2)
104
107
  "forget", "run_maintenance",
105
108
  # NOTE: prestage_context IS registered (see register_prestage_tool below)
106
- # but is deliberately absent from the default surface. _ESSENTIAL_TOOLS must
107
- # stay at 42 to match the full42 profile: the profile names are a
108
- # user-facing config contract and the published tool-count table depends on
109
- # them. New tools reach users via the `whole` profile until a rename ships.
109
+ # but is deliberately absent from the default surface. Brain evidence is
110
+ # intentionally present: it is the portable contract for installed agents.
110
111
  # Infinite memory + learning (4)
111
112
  "consolidate_cognitive", "get_soft_prompts",
112
113
  "set_mode", "report_outcome",