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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +43 -0
- package/README.md +15 -4
- package/package.json +1 -1
- 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/cli/daemon.py +18 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +229 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/loops/__init__.py +2 -0
- package/src/superlocalmemory/loops/ledger.py +35 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +27 -8
- package/src/superlocalmemory/mcp/server.py +6 -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/mcp/tools_loops.py +51 -17
- package/src/superlocalmemory/server/unified_daemon.py +17 -0
- 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
- 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":
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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)}
|