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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +37 -0
- package/README.md +15 -4
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +1 -1
- package/plugin-src/agents/slm-optimize-advisor.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +229 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/mcp/tools_active.py +20 -5
- package/src/superlocalmemory/mcp/tools_brain.py +38 -1
- package/src/superlocalmemory/mcp/tools_learning.py +123 -4
- package/src/superlocalmemory/storage/_migration_internals.py +2 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +38 -4
- package/src/superlocalmemory/storage/execution_learning.py +285 -0
- package/src/superlocalmemory/storage/migration_runner.py +3 -0
- package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +70 -0
- 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
|
-
#
|
|
567
|
-
#
|
|
568
|
-
|
|
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":
|
|
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, "
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
(
|
|
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 (
|
|
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 =
|
|
47
|
+
SUPPORTED_SCHEMA_VERSION: int = 50
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
class SchemaVersionError(RuntimeError):
|