superlocalmemory 4.1.9 → 4.1.12

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 (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +15 -4
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/agents/slm-memory-advisor.md +1 -1
  25. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  26. package/plugin-src/rules/AGENTS.md +1 -1
  27. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  39. package/pyproject.toml +1 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/cli/daemon.py +18 -0
  42. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +229 -0
  43. package/src/superlocalmemory/learning/database.py +2 -1
  44. package/src/superlocalmemory/loops/__init__.py +2 -0
  45. package/src/superlocalmemory/loops/ledger.py +35 -0
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +27 -8
  47. package/src/superlocalmemory/mcp/server.py +6 -1
  48. package/src/superlocalmemory/mcp/tools_active.py +20 -5
  49. package/src/superlocalmemory/mcp/tools_brain.py +38 -1
  50. package/src/superlocalmemory/mcp/tools_learning.py +123 -4
  51. package/src/superlocalmemory/mcp/tools_loops.py +51 -17
  52. package/src/superlocalmemory/server/unified_daemon.py +17 -0
  53. package/src/superlocalmemory/storage/_migration_internals.py +2 -0
  54. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  55. package/src/superlocalmemory/storage/agent_experience.py +38 -4
  56. package/src/superlocalmemory/storage/execution_learning.py +285 -0
  57. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  58. package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +70 -0
  59. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  60. package/src/superlocalmemory/ui/index.html +2 -2
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import hashlib
6
7
  import json
7
8
  import os
8
9
  import shutil
@@ -12,7 +13,13 @@ from copy import deepcopy
12
13
  from pathlib import Path
13
14
  from typing import Any
14
15
 
16
+ from superlocalmemory.storage.execution_learning import (
17
+ VerifiedExecutionEvidence,
18
+ _seal_verified_execution_evidence,
19
+ )
20
+
15
21
  CONTRACT_ID = "bounded-loops.dev/slm-bridge/v1"
22
+ CONTRACT_V2_ID = "bounded-loops.dev/slm-bridge/v2"
16
23
  _OBSERVATION_TIMEOUT_SECONDS = 5.0
17
24
  _MAX_MCP_TEXT_BYTES = 2 * 1024 * 1024
18
25
  _ADVERTISEMENT = {
@@ -20,6 +27,11 @@ _ADVERTISEMENT = {
20
27
  "tool": "bl_graph_evidence",
21
28
  "operation": "observe_terminal_run",
22
29
  }
30
+ _ADVERTISEMENT_V2 = {
31
+ "id": CONTRACT_V2_ID,
32
+ "tool": "bl_graph_execution_evidence",
33
+ "operation": "observe_verified_terminal_run",
34
+ }
23
35
 
24
36
 
25
37
  class BridgeUnavailable(ValueError):
@@ -36,6 +48,37 @@ def supports_bridge(capabilities: dict[str, Any]) -> bool:
36
48
  )
37
49
 
38
50
 
51
+ def supports_bridge_v2(capabilities: dict[str, Any]) -> bool:
52
+ """Negotiate the additive v2 capability without altering v1 semantics."""
53
+ advertised = capabilities.get("evidence_contracts")
54
+ return isinstance(advertised, list) and any(
55
+ isinstance(item, dict)
56
+ and all(item.get(key) == value for key, value in _ADVERTISEMENT_V2.items())
57
+ for item in advertised
58
+ )
59
+
60
+
61
+ def _bridge_v2_payload(
62
+ evidence: dict[str, Any], *, profile_id: str, terminal_run: dict[str, Any]
63
+ ) -> dict[str, Any]:
64
+ """Bind v2 evidence to the terminal run independently enumerated this session.
65
+
66
+ A v2 producer cannot promote an arbitrary schema-shaped JSON object: the
67
+ evidence must agree exactly with the separately fetched terminal listing.
68
+ The resulting payload is sealed below before it can reach storage.
69
+ """
70
+ if evidence.get("contract") != CONTRACT_V2_ID:
71
+ raise BridgeUnavailable("unsupported bounded-loops execution evidence contract")
72
+ required_listing = ("run_ref", "run_id", "run_state", "terminal_at")
73
+ if any(not isinstance(terminal_run.get(field), str) for field in required_listing):
74
+ raise BridgeUnavailable("bounded-loops terminal listing is malformed")
75
+ if any(evidence.get(field) != terminal_run[field] for field in required_listing):
76
+ raise BridgeUnavailable("bounded-loops execution evidence does not match terminal listing")
77
+ payload = deepcopy(evidence)
78
+ payload["profile_id"] = profile_id
79
+ return payload
80
+
81
+
39
82
  def bridge_payload(evidence: dict[str, Any], *, profile_id: str) -> dict[str, Any]:
40
83
  """Attach active-profile identity after refusing incompatible evidence."""
41
84
  if evidence.get("contract") != CONTRACT_ID:
@@ -96,6 +139,126 @@ async def observe_terminal_runs(
96
139
  return observed
97
140
 
98
141
 
142
+ async def observe_terminal_runs_v2(
143
+ call_tool: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]], *, profile_id: str,
144
+ producer_identity: str,
145
+ ) -> list[VerifiedExecutionEvidence]:
146
+ """Collect v2 receipts only after verified local producer provenance.
147
+
148
+ The producer identity is measured by the stdio launcher, not supplied by
149
+ producer JSON. The capability and terminal-listing digests record the
150
+ exact independently observed control-plane statements that authorized
151
+ each sealed payload.
152
+ """
153
+ if not isinstance(producer_identity, str) or not producer_identity:
154
+ raise BridgeUnavailable("bounded-loops execution producer identity is unavailable")
155
+ discovery = await call_tool("bl_capabilities", {})
156
+ if discovery.get("status") != "ok" or not supports_bridge_v2(discovery.get("capabilities", {})):
157
+ raise BridgeUnavailable("bounded-loops does not advertise slm-bridge/v2")
158
+ listing = await call_tool("bl_graph_terminal_runs", {"limit": 100})
159
+ if (
160
+ listing.get("status") != "ok"
161
+ or listing.get("contract") != CONTRACT_ID
162
+ or not isinstance(listing.get("runs"), list)
163
+ ):
164
+ raise BridgeUnavailable("bounded-loops terminal listing is unavailable")
165
+ capability_digest = _canonical_digest(discovery["capabilities"])
166
+ listing_digest = _canonical_digest(listing["runs"])
167
+ observed: list[VerifiedExecutionEvidence] = []
168
+ for run in listing["runs"][:100]:
169
+ if not isinstance(run, dict) or any(
170
+ not isinstance(run.get(field), str)
171
+ for field in ("run_ref", "run_id", "run_state", "terminal_at")
172
+ ):
173
+ raise BridgeUnavailable("bounded-loops terminal listing is malformed")
174
+ response = await call_tool("bl_graph_execution_evidence", {"run_ref": run["run_ref"]})
175
+ if response.get("status") == "unavailable":
176
+ continue
177
+ if response.get("status") != "ok" or not isinstance(response.get("evidence"), dict):
178
+ raise BridgeUnavailable("bounded-loops execution evidence response is malformed")
179
+ payload = _bridge_v2_payload(
180
+ response["evidence"], profile_id=profile_id, terminal_run=run
181
+ )
182
+ try:
183
+ observed.append(_seal_verified_execution_evidence(
184
+ payload,
185
+ producer_identity=producer_identity,
186
+ capability_digest=capability_digest,
187
+ terminal_listing_digest=listing_digest,
188
+ ))
189
+ except ValueError as exc:
190
+ raise BridgeUnavailable("bounded-loops execution evidence is invalid") from exc
191
+ return observed
192
+
193
+
194
+ def _canonical_digest(value: Any) -> str:
195
+ """Stable audit identity for a negotiated MCP control-plane document."""
196
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
197
+ return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
198
+
199
+
200
+ def _file_digest(path: Path, digest: hashlib._Hash) -> None:
201
+ """Feed one regular file into an already-bound identity hash."""
202
+ try:
203
+ with path.open("rb") as handle:
204
+ for chunk in iter(lambda: handle.read(64 * 1024), b""):
205
+ digest.update(chunk)
206
+ except OSError as exc:
207
+ raise BridgeUnavailable("bounded-loops producer source could not be identified") from exc
208
+
209
+
210
+ def _package_source_identity(executable: Path) -> str:
211
+ """Digest the bounded_loops source loaded by the MCP launcher's venv.
212
+
213
+ The console script is only a shim. Its shebang identifies the interpreter
214
+ whose site-packages directory owns the running MCP server, so bind the
215
+ source files from that environment rather than whichever package happens
216
+ to be importable by SLM itself.
217
+ """
218
+ try:
219
+ first_line = executable.open("rb").readline(4096).decode("utf-8").strip()
220
+ except (OSError, UnicodeDecodeError) as exc:
221
+ raise BridgeUnavailable("bounded-loops executable has no readable interpreter") from exc
222
+ if not first_line.startswith("#!"):
223
+ raise BridgeUnavailable("bounded-loops executable has no absolute interpreter")
224
+ interpreter = Path(first_line[2:])
225
+ if not interpreter.is_absolute() or not interpreter.is_file():
226
+ raise BridgeUnavailable("bounded-loops executable interpreter is unavailable")
227
+ if interpreter.parent.name not in {"bin", "Scripts"}:
228
+ raise BridgeUnavailable("bounded-loops executable is not an isolated environment launcher")
229
+ environment = interpreter.parent.parent
230
+ candidates = sorted(
231
+ candidate.resolve(strict=True)
232
+ for candidate in (environment / "lib").glob("python*/site-packages/bounded_loops")
233
+ if candidate.is_dir() and not candidate.is_symlink()
234
+ )
235
+ if len(candidates) != 1:
236
+ raise BridgeUnavailable("bounded-loops package source is unavailable or ambiguous")
237
+ package = candidates[0]
238
+ digest = hashlib.sha256()
239
+ source_files = sorted(
240
+ path for path in package.rglob("*.py") if path.is_file() and not path.is_symlink()
241
+ )
242
+ if not source_files:
243
+ raise BridgeUnavailable("bounded-loops package source is empty")
244
+ for path in source_files:
245
+ digest.update(path.relative_to(package).as_posix().encode("utf-8"))
246
+ digest.update(b"\0")
247
+ _file_digest(path, digest)
248
+ digest.update(b"\0")
249
+ return "sha256:" + digest.hexdigest()
250
+
251
+
252
+ def _producer_identity(executable: Path) -> str:
253
+ """Bind the trusted launcher and actual bounded_loops package source."""
254
+ digest = hashlib.sha256()
255
+ digest.update(b"bounded-loops-launcher\0")
256
+ _file_digest(executable, digest)
257
+ digest.update(b"\0bounded-loops-package\0")
258
+ digest.update(_package_source_identity(executable).encode("ascii"))
259
+ return "sha256:" + digest.hexdigest()
260
+
261
+
99
262
  def _assert_trusted_executable(executable: Path) -> None:
100
263
  """Raise BridgeUnavailable if executable does not pass the bridge trust checks."""
101
264
  try:
@@ -182,6 +345,58 @@ async def observe_from_stdio(*, command: str, cwd: str, profile_id: str) -> list
182
345
  raise BridgeUnavailable("bounded-loops observation timed out or could not start") from exc
183
346
 
184
347
 
348
+ async def observe_v2_from_stdio(
349
+ *, command: str, cwd: str, profile_id: str
350
+ ) -> list[VerifiedExecutionEvidence]:
351
+ """Use the same trusted stdio boundary for additive v2 evidence."""
352
+ # Reuse the hardened v1 launcher while selecting only v2 after capability
353
+ # negotiation. The injected helper is intentionally local to this call.
354
+ executable, workspace = Path(command), Path(cwd)
355
+ if (
356
+ not executable.is_absolute()
357
+ or not executable.is_file()
358
+ or not workspace.is_absolute()
359
+ or not workspace.is_dir()
360
+ or workspace.is_symlink()
361
+ ):
362
+ raise BridgeUnavailable("bounded-loops bridge requires an approved executable and workspace")
363
+ try:
364
+ executable = executable.resolve(strict=True)
365
+ workspace = workspace.resolve(strict=True)
366
+ except OSError as exc:
367
+ raise BridgeUnavailable("bounded-loops bridge path is unavailable") from exc
368
+ _assert_trusted_executable(executable)
369
+ producer_identity = _producer_identity(executable)
370
+ from mcp import ClientSession, StdioServerParameters
371
+ from mcp.client.stdio import stdio_client
372
+ try:
373
+ parameters = StdioServerParameters(command=str(executable), args=[], cwd=str(workspace))
374
+ async with stdio_client(parameters) as (read, write):
375
+ async with ClientSession(read, write, read_timeout_seconds=_OBSERVATION_TIMEOUT_SECONDS) as session:
376
+ await session.initialize()
377
+ async def call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
378
+ result = await session.call_tool(name, arguments)
379
+ texts = [item.text for item in result.content if hasattr(item, "text")]
380
+ if result.is_error or len(texts) != 1 or len(texts[0].encode("utf-8")) > _MAX_MCP_TEXT_BYTES:
381
+ raise BridgeUnavailable("bounded-loops returned an invalid MCP payload")
382
+ payload = json.loads(texts[0])
383
+ if not isinstance(payload, dict):
384
+ raise BridgeUnavailable("bounded-loops returned an invalid MCP payload")
385
+ return payload
386
+ return await asyncio.wait_for(
387
+ observe_terminal_runs_v2(
388
+ call,
389
+ profile_id=profile_id,
390
+ producer_identity=producer_identity,
391
+ ),
392
+ _OBSERVATION_TIMEOUT_SECONDS,
393
+ )
394
+ except BridgeUnavailable:
395
+ raise
396
+ except Exception as exc:
397
+ raise BridgeUnavailable("bounded-loops execution observation timed out or could not start") from exc
398
+
399
+
185
400
  async def observe_installed(*, workspace: str, profile_id: str) -> list[dict[str, Any]]:
186
401
  """Observe a user-installed producer without accepting an agent command.
187
402
 
@@ -200,3 +415,17 @@ async def observe_installed(*, workspace: str, profile_id: str) -> list[dict[str
200
415
  return await observe_from_stdio(
201
416
  command=str(Path(command).resolve()), cwd=workspace, profile_id=profile_id
202
417
  )
418
+
419
+
420
+ async def observe_installed_v2(
421
+ *, workspace: str, profile_id: str
422
+ ) -> list[VerifiedExecutionEvidence]:
423
+ """Observe only from the installed bounded-loops MCP executable."""
424
+ command = shutil.which("bounded-loops-mcp")
425
+ if command is None or not Path(command).is_absolute():
426
+ raise BridgeUnavailable("bounded-loops-mcp is not installed")
427
+ executable = Path(command).resolve()
428
+ if executable.name not in {"bounded-loops-mcp", "bounded-loops-mcp.exe"}:
429
+ raise BridgeUnavailable("bounded-loops-mcp discovery returned an unsafe executable")
430
+ return await observe_v2_from_stdio(command=str(executable), cwd=workspace,
431
+ profile_id=profile_id)
@@ -639,7 +639,8 @@ class LearningDatabase:
639
639
  for row in conn.execute(
640
640
  "SELECT name FROM sqlite_master WHERE type='table' "
641
641
  "AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
642
- "'external_evidence_receipts')"
642
+ "'external_evidence_receipts', 'execution_learning_receipts', "
643
+ "'execution_learning_events')"
643
644
  )
644
645
  }
645
646
  if profile_id is None:
@@ -21,6 +21,7 @@ from superlocalmemory.loops.ledger import (
21
21
  SLMMemoryLedger,
22
22
  engine_backed_ledger,
23
23
  open_engine_store,
24
+ pool_backed_ledger,
24
25
  )
25
26
  from superlocalmemory.loops.models import (
26
27
  Bounds,
@@ -49,6 +50,7 @@ __all__ = [
49
50
  "InMemoryLedger",
50
51
  "SLMMemoryLedger",
51
52
  "engine_backed_ledger",
53
+ "pool_backed_ledger",
52
54
  "open_engine_store",
53
55
  "no_progress",
54
56
  "rung_requires_approval",
@@ -223,6 +223,36 @@ class _EngineLedgerStore:
223
223
  self._engine.close()
224
224
 
225
225
 
226
+ class _PoolLedgerStore(_EngineLedgerStore):
227
+ """Write through the owned daemon/worker; read through the LIGHT engine.
228
+
229
+ MCP intentionally keeps a LIGHT engine in-process. That engine owns a
230
+ read-only database view but refuses ``store``/``store_fast``. The pool is
231
+ the admitted writer and shares the same canonical store, so loop laps stay
232
+ immediately queryable without turning every MCP process into a heavy
233
+ writer.
234
+ """
235
+
236
+ def __init__(self, pool: Any, reader_engine: Any) -> None:
237
+ super().__init__(reader_engine, owns_engine=False)
238
+ self._pool = pool
239
+
240
+ def add(self, content: str, *, session_id: str, metadata: dict) -> None:
241
+ result = self._pool.store(
242
+ content,
243
+ metadata={
244
+ **metadata,
245
+ "session_id": session_id,
246
+ "profile_id": self._engine.profile_id,
247
+ },
248
+ )
249
+ accepted = bool(result.get("ok") or result.get("success"))
250
+ if not accepted:
251
+ raise RuntimeError(
252
+ result.get("error", "owned SLM writer rejected loop ledger entry")
253
+ )
254
+
255
+
226
256
  def engine_backed_ledger(engine: Any) -> SLMMemoryLedger:
227
257
  """Build an SLM-backed ledger over an ALREADY-OPEN engine.
228
258
 
@@ -236,6 +266,11 @@ def engine_backed_ledger(engine: Any) -> SLMMemoryLedger:
236
266
  return SLMMemoryLedger(_EngineLedgerStore(engine, owns_engine=False))
237
267
 
238
268
 
269
+ def pool_backed_ledger(pool: Any, reader_engine: Any) -> SLMMemoryLedger:
270
+ """Build an MCP-safe ledger: admitted pool writes, LIGHT engine reads."""
271
+ return SLMMemoryLedger(_PoolLedgerStore(pool, reader_engine))
272
+
273
+
239
274
  def open_engine_store(db_path: str | Path) -> _EngineLedgerStore:
240
275
  """Build an engine-backed ledger store rooted at ``db_path``.
241
276
 
@@ -139,21 +139,40 @@ class DaemonPoolProxy:
139
139
  ) -> dict[str, Any]:
140
140
  if self._unavailable:
141
141
  return self._unavailable_response()
142
+ tags = (metadata or {}).get("tags", "")
143
+ if isinstance(tags, (list, tuple, set)):
144
+ tags = ",".join(str(tag) for tag in tags)
142
145
  body = {
143
146
  "content": content,
144
- "tags": (metadata or {}).get("tags", ""),
147
+ "tags": tags,
145
148
  "metadata": metadata or {},
146
149
  "session_id": (metadata or {}).get("session_id", ""),
147
150
  "idempotency_key": (metadata or {}).get("idempotency_key") or None,
151
+ "profile_id": (metadata or {}).get("profile_id", ""),
148
152
  }
153
+ # One identity-aware daemon client owns descriptor validation,
154
+ # capability delivery, and exact-instance targeting. A raw urllib POST
155
+ # here previously became unauthenticated when /remember was hardened
156
+ # and could also attach to a stale/foreign port.
149
157
  try:
150
- # One identity-aware daemon client owns descriptor validation,
151
- # capability delivery, and exact-instance targeting. A raw urllib
152
- # POST here previously became unauthenticated when /remember was
153
- # hardened and could also attach to a stale/foreign port.
154
- from superlocalmemory.cli.daemon import daemon_request
155
-
156
- data = daemon_request("POST", "/remember", body)
158
+ from superlocalmemory.cli.daemon import DaemonConflict, daemon_request
159
+ except Exception as exc:
160
+ logger.warning("daemon client import failed: %s", exc)
161
+ return self._unavailable_response()
162
+ try:
163
+ data = daemon_request(
164
+ "POST",
165
+ "/remember",
166
+ body,
167
+ preserve_conflict=True,
168
+ )
169
+ except DaemonConflict as exc:
170
+ return {
171
+ "ok": False,
172
+ "code": "PROFILE_MISMATCH",
173
+ "retryable": False,
174
+ "error": str(exc),
175
+ }
157
176
  except Exception as exc:
158
177
  logger.warning("daemon /remember failed: %s", exc)
159
178
  return self._unavailable_response()
@@ -280,7 +280,12 @@ register_evolution_tools(_target, get_engine) # v3.4.11: Skill evolution tools
280
280
  from superlocalmemory.mcp.tools_optimize import register_optimize_tools
281
281
  register_optimize_tools(_target) # v3.6.11: Surface B Optimize tools (proxy-free)
282
282
  from superlocalmemory.mcp.tools_loops import register_loop_tools
283
- register_loop_tools(_target, get_engine) # v3.8.0: bounded-loop tools (CLI+command+MCP)
283
+ from superlocalmemory.mcp._daemon_proxy import choose_pool as _choose_loop_pool
284
+ register_loop_tools(
285
+ _target,
286
+ get_engine,
287
+ _choose_loop_pool,
288
+ ) # v3.8.0: bounded-loop tools (CLI+command+MCP)
284
289
  from superlocalmemory.mcp.tools_ops import register_ops_tools
285
290
  register_ops_tools(_target, get_engine) # operational recovery & admin remediation
286
291
  from superlocalmemory.mcp.tools_brain import register_brain_tools
@@ -318,6 +318,8 @@ def register_active_tools(server, get_engine: Callable) -> None:
318
318
  query: str = "",
319
319
  max_results: int = 10,
320
320
  max_age_days: int = 30,
321
+ session_id: str = "",
322
+ agent_id: str = "",
321
323
  ) -> dict:
322
324
  """Initialize session with relevant memory context.
323
325
 
@@ -563,18 +565,24 @@ def register_active_tools(server, get_engine: Callable) -> None:
563
565
  )
564
566
  feedback_count = 0
565
567
 
566
- # v3.6.9 (#35): generate a stable session_id so clients can pass it
567
- # to remember() and close_session() for proper session aggregation.
568
- session_id = (
568
+ # A gateway can serve concurrent host conversations. An explicit
569
+ # host id therefore always wins; generating one is retained only
570
+ # for older clients that cannot supply lifecycle identity.
571
+ effective_session_id = session_id.strip() or (
569
572
  f"slm-{datetime.datetime.now(datetime.timezone.utc):%Y%m%d}"
570
573
  f"-{uuid.uuid4().hex[:8]}"
571
574
  )
575
+ effective_agent_id = agent_id.strip() or _get_agent_id()
576
+ # Backward-compatible default for legacy close_session() callers;
577
+ # native hosts must pass their explicit id when sessions overlap.
578
+ engine._last_session_id = effective_session_id
572
579
 
573
580
  _upcoming_events = _upcoming_scheduled_facts(engine, _now)
574
581
 
575
582
  return {
576
583
  "success": True,
577
- "session_id": session_id,
584
+ "session_id": effective_session_id,
585
+ "agent_id": effective_agent_id,
578
586
  "context": context,
579
587
  "memories": memories[:max_results],
580
588
  "memory_count": len(memories),
@@ -623,6 +631,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
623
631
  async def observe(
624
632
  content: str,
625
633
  agent_id: str | None = None,
634
+ session_id: str = "",
626
635
  ) -> dict:
627
636
  """Observe conversation content for automatic memory capture.
628
637
 
@@ -673,11 +682,16 @@ def register_active_tools(server, get_engine: Callable) -> None:
673
682
  # Auto-store via engine.
674
683
  # pool_store uses blocking urllib (DaemonPoolProxy) — run in
675
684
  # thread so the MCP event loop stays unblocked (#34 class).
685
+ from superlocalmemory.mcp.session_binding import resolve_session_id
686
+ effective_session_id = resolve_session_id(
687
+ session_id, agent_id=agent_id, allow_agent_fallback=False,
688
+ )
676
689
  stored = await asyncio.to_thread(
677
690
  auto.capture,
678
691
  content,
679
692
  category=decision.category,
680
- metadata={"agent_id": agent_id, "source": "auto-observe"},
693
+ metadata={"agent_id": agent_id, "session_id": effective_session_id,
694
+ "source": "auto-observe"},
681
695
  )
682
696
 
683
697
  if stored:
@@ -693,6 +707,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
693
707
  "category": decision.category,
694
708
  "confidence": round(decision.confidence, 3),
695
709
  "reason": decision.reason,
710
+ "session_id": effective_session_id,
696
711
  }
697
712
  except Exception as exc:
698
713
  logger.exception("observe failed")
@@ -20,7 +20,15 @@ from superlocalmemory.brain.truth import BrainTruthService
20
20
  from superlocalmemory.core.admission import admits
21
21
  from superlocalmemory.core.operation_request import OperationKind
22
22
  from superlocalmemory.infra.data_root import state_path
23
- from superlocalmemory.integrations.bounded_loops_mcp import BridgeUnavailable, observe_installed
23
+ from superlocalmemory.integrations.bounded_loops_mcp import (
24
+ BridgeUnavailable,
25
+ observe_installed,
26
+ observe_installed_v2,
27
+ )
28
+ from superlocalmemory.storage.execution_learning import (
29
+ ExecutionLearningStore,
30
+ ExecutionLearningValidationError,
31
+ )
24
32
  from superlocalmemory.storage.agent_experience import (
25
33
  AgentExperienceConflictError,
26
34
  AgentExperienceStore,
@@ -51,6 +59,14 @@ def _external_store_for(engine: Any) -> ExternalEvidenceStore:
51
59
  )
52
60
 
53
61
 
62
+ def _execution_store_for(engine: Any) -> ExecutionLearningStore:
63
+ active_profile = engine.profile_id
64
+ return ExecutionLearningStore(
65
+ Path(state_path("learning.db")),
66
+ is_profile_active=lambda profile_id: profile_id == active_profile,
67
+ )
68
+
69
+
54
70
  def _brain_truth_for(engine: Any) -> dict[str, Any]:
55
71
  """Read the portable truth snapshot without opening an engine or a writer."""
56
72
  return BrainTruthService(
@@ -259,3 +275,24 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
259
275
  "created": created,
260
276
  "control_plane": "observation_only",
261
277
  }
278
+
279
+ @server.tool()
280
+ @admits(OperationKind.REMEMBER)
281
+ async def observe_bounded_loop_execution_learning(workspace: str) -> dict[str, Any]:
282
+ """Ingest negotiated bridge-v2 terminal evidence into the execution plane.
283
+
284
+ The producer is reached only through the installed bounded-loops MCP
285
+ capability handshake. This never writes semantic facts or preferences.
286
+ """
287
+ engine = get_engine()
288
+ try:
289
+ observed = await observe_installed_v2(workspace=workspace, profile_id=engine.profile_id)
290
+ store = _execution_store_for(engine)
291
+ created = 0
292
+ for payload in observed:
293
+ created += int(await asyncio.to_thread(store.ingest, payload))
294
+ return {"success": True, "durable": True, "observed": len(observed),
295
+ "created": created, "control_plane": "execution_reliability_only"}
296
+ except (BridgeUnavailable, ExecutionLearningValidationError, ProfileAdmissionError,
297
+ sqlite3.Error) as exc:
298
+ return {"success": False, "durable": False, "error": str(exc)}