superlocalmemory 4.1.11 → 4.1.13

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 (52) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +15 -4
  4. package/package.json +2 -2
  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/integrations/bounded_loops_mcp.py +229 -0
  42. package/src/superlocalmemory/learning/database.py +2 -1
  43. package/src/superlocalmemory/mcp/tools_active.py +20 -5
  44. package/src/superlocalmemory/mcp/tools_brain.py +38 -1
  45. package/src/superlocalmemory/mcp/tools_learning.py +123 -4
  46. package/src/superlocalmemory/storage/_migration_internals.py +2 -0
  47. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  48. package/src/superlocalmemory/storage/agent_experience.py +38 -4
  49. package/src/superlocalmemory/storage/execution_learning.py +285 -0
  50. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  51. package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +70 -0
  52. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
@@ -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:
@@ -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)}
@@ -15,11 +15,14 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import asyncio
18
19
  import json
19
20
  import logging
20
21
  import os
22
+ import sqlite3
21
23
  import uuid
22
24
  from datetime import datetime, timezone
25
+ from pathlib import Path
23
26
  from typing import Callable
24
27
 
25
28
  from mcp.types import ToolAnnotations
@@ -33,6 +36,72 @@ logger = logging.getLogger(__name__)
33
36
  _MAX_SUMMARY_LEN = 500 # Truncate input/output summaries
34
37
 
35
38
 
39
+ def settle_pending_session_outcomes(
40
+ memory_db_path: str | Path,
41
+ *,
42
+ profile_id: str,
43
+ session_id: str,
44
+ evidence_only: bool = False,
45
+ ) -> dict[str, int]:
46
+ """Finalize real pending recalls for one host-owned session.
47
+
48
+ This is deliberately a narrow adapter around ``EngagementRewardModel``:
49
+ it selects only pending rows belonging to the supplied active profile and
50
+ exact host session, then delegates every state transition and reward
51
+ calculation to the established finalizer. No feedback is inferred here.
52
+ Repeating the call is a no-op because settled rows are not selected.
53
+ """
54
+ session_id = session_id.strip()
55
+ if not session_id:
56
+ return {"selected": 0, "settled": 0}
57
+
58
+ path = Path(memory_db_path)
59
+ try:
60
+ with sqlite3.connect(str(path), timeout=2.0) as conn:
61
+ rows = conn.execute(
62
+ "SELECT outcome_id, signals_json FROM pending_outcomes "
63
+ "WHERE profile_id=? AND session_id=? AND status='pending'",
64
+ (profile_id, session_id),
65
+ ).fetchall()
66
+ except sqlite3.Error as exc:
67
+ raise RuntimeError("pending outcome lookup failed") from exc
68
+
69
+ if evidence_only:
70
+ from superlocalmemory.learning.reward import _carries_evidence
71
+
72
+ rows = [
73
+ row for row in rows
74
+ if _carries_evidence(json.loads(row[1] or "{}"))
75
+ ]
76
+
77
+ if not rows:
78
+ return {"selected": 0, "settled": 0}
79
+
80
+ from superlocalmemory.learning.reward import EngagementRewardModel
81
+
82
+ model = EngagementRewardModel(memory_db_path=str(path))
83
+ try:
84
+ for outcome_id, _signals_json in rows:
85
+ # Preserve the established keyword-only finalization contract.
86
+ model.finalize_outcome(outcome_id=outcome_id)
87
+ finally:
88
+ model.close()
89
+
90
+ outcome_ids = [row[0] for row in rows]
91
+ placeholders = ",".join("?" for _ in outcome_ids)
92
+ try:
93
+ with sqlite3.connect(str(path), timeout=2.0) as conn:
94
+ settled = conn.execute(
95
+ "SELECT COUNT(*) FROM pending_outcomes "
96
+ "WHERE profile_id=? AND session_id=? AND status='settled' "
97
+ f"AND outcome_id IN ({placeholders})",
98
+ (profile_id, session_id, *outcome_ids),
99
+ ).fetchone()[0]
100
+ except sqlite3.Error as exc:
101
+ raise RuntimeError("pending outcome settlement verification failed") from exc
102
+ return {"selected": len(outcome_ids), "settled": int(settled)}
103
+
104
+
36
105
  def register_learning_tools(server, get_engine: Callable) -> None:
37
106
  """Register learning MCP tools for two-way intelligence."""
38
107
 
@@ -45,6 +114,9 @@ def register_learning_tools(server, get_engine: Callable) -> None:
45
114
  output_summary: str = "",
46
115
  duration_ms: int = 0,
47
116
  metadata: str = "{}",
117
+ session_id: str = "",
118
+ agent_id: str = "",
119
+ project_path: str = "",
48
120
  ) -> dict:
49
121
  """Log a tool usage event for behavioral learning.
50
122
 
@@ -62,8 +134,11 @@ def register_learning_tools(server, get_engine: Callable) -> None:
62
134
  """
63
135
  engine = get_engine()
64
136
  now = datetime.now(timezone.utc).isoformat()
65
- session_id = os.environ.get("CLAUDE_SESSION_ID", "unknown")
66
- project_path = (
137
+ from superlocalmemory.mcp.session_binding import resolve_session_id
138
+ effective_session_id = resolve_session_id(
139
+ session_id, agent_id=agent_id or "mcp_client", allow_agent_fallback=True,
140
+ )
141
+ effective_project_path = project_path or (
67
142
  os.environ.get("CLAUDE_PROJECT_DIR")
68
143
  or os.environ.get("PROJECT_PATH")
69
144
  or os.getcwd()
@@ -86,15 +161,59 @@ def register_learning_tools(server, get_engine: Callable) -> None:
86
161
  "(session_id, profile_id, project_path, tool_name, event_type, "
87
162
  " input_summary, output_summary, duration_ms, metadata, created_at) "
88
163
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
89
- (session_id, engine.profile_id, project_path, tool_name,
164
+ (effective_session_id, engine.profile_id, effective_project_path, tool_name,
90
165
  event_type, input_clean, output_clean, duration_ms, metadata, now),
91
166
  )
92
167
  authorization.complete()
93
- return {"success": True, "tool": tool_name, "event": event_type}
168
+ return {"success": True, "tool": tool_name, "event": event_type,
169
+ "session_id": effective_session_id}
94
170
  except Exception as exc:
95
171
  logger.debug("log_tool_event failed: %s", exc)
96
172
  return {"success": False, "error": str(exc)}
97
173
 
174
+ @server.tool()
175
+ @admits(OperationKind.REMEMBER)
176
+ async def settle_session_outcomes(
177
+ session_id: str, agent_id: str = "", finalize: bool = False,
178
+ ) -> dict:
179
+ """Settle pending recall outcomes for one exact host session.
180
+
181
+ Native hosts call this after each turn and at finalization. Per-turn
182
+ calls settle only recalls that have actual evidence; finalization also
183
+ clears evidence-free pending rows. The operation never treats a hook
184
+ boundary itself as feedback. ``session_id`` must be explicit.
185
+ """
186
+ if not session_id or not session_id.strip():
187
+ return {"success": False, "error": "session_id is required"}
188
+ engine = get_engine()
189
+ try:
190
+ authorization = authorize_mcp_mutation(
191
+ engine,
192
+ "update",
193
+ mutation_source="mcp-settle-session-outcomes",
194
+ profile_id=engine.profile_id,
195
+ content_preview=agent_id[:100],
196
+ )
197
+ from superlocalmemory.hooks._outcome_common import memory_db_path
198
+
199
+ result = await asyncio.to_thread(
200
+ settle_pending_session_outcomes,
201
+ memory_db_path(),
202
+ profile_id=engine.profile_id,
203
+ session_id=session_id,
204
+ evidence_only=not finalize,
205
+ )
206
+ authorization.complete()
207
+ return {
208
+ "success": True,
209
+ "session_id": session_id.strip(),
210
+ "agent_id": agent_id.strip(),
211
+ **result,
212
+ }
213
+ except Exception as exc:
214
+ logger.debug("settle_session_outcomes failed: %s", exc)
215
+ return {"success": False, "error": str(exc)}
216
+
98
217
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
99
218
  async def get_assertions(
100
219
  min_confidence: float = 0.0,
@@ -165,6 +165,7 @@ from superlocalmemory.storage.migrations import (
165
165
  M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
166
166
  M048_upcoming_holds_only_what_is_upcoming as _M048,
167
167
  M049_a_schema_version_marker_is_one_row as _M049,
168
+ M050_execution_learning_v2 as _M050,
168
169
  )
169
170
 
170
171
  # Emit under the runner's logger name so operational log filters that key on
@@ -223,6 +224,7 @@ _MODULES = {
223
224
  _M047.NAME: _M047,
224
225
  _M048.NAME: _M048,
225
226
  _M049.NAME: _M049,
227
+ _M050.NAME: _M050,
226
228
  }
227
229
 
228
230
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -21,7 +21,7 @@ import sqlite3
21
21
  from pathlib import Path
22
22
 
23
23
  #: Highest schema_version this runner can write. Matches the trailing serial of
24
- #: the latest migration (M049). Increment when adding new migrations or
24
+ #: the latest migration (M050). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
26
  #:
27
27
  #: This sat at 42 while M043, M044 and M045 shipped, so for three migrations the
@@ -44,7 +44,7 @@ from pathlib import Path
44
44
  #: be additive is a silent bad write. Those are not comparable, and judging
45
45
  #: additivity per migration is exactly the judgement that let it fall three
46
46
  #: behind.
47
- SUPPORTED_SCHEMA_VERSION: int = 49
47
+ SUPPORTED_SCHEMA_VERSION: int = 50
48
48
 
49
49
 
50
50
  class SchemaVersionError(RuntimeError):