superlocalmemory 4.0.2 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +36 -40
- 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 +4 -4
- 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/rules/AGENTS.md +3 -3
- 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 +4 -4
- 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/scripts/postinstall-interactive.js +1 -0
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +10 -19
- package/src/superlocalmemory/cli/host_upgrades.py +175 -0
- package/src/superlocalmemory/cli/main.py +29 -4
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +184 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/mcp/profiles.py +28 -13
- package/src/superlocalmemory/mcp/server.py +7 -6
- package/src/superlocalmemory/mcp/tools_brain.py +89 -4
- package/src/superlocalmemory/server/routes/brain.py +6 -1
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +26 -4
- package/src/superlocalmemory/storage/external_evidence.py +359 -0
- package/src/superlocalmemory/storage/migration_runner.py +5 -0
- package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/ui/js/od-brain.js +9 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +6 -6
|
@@ -9,6 +9,8 @@ or changes a memory answer.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import asyncio
|
|
13
|
+
import sqlite3
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any, Callable
|
|
14
16
|
|
|
@@ -17,6 +19,7 @@ from mcp.types import ToolAnnotations
|
|
|
17
19
|
from superlocalmemory.core.admission import admits
|
|
18
20
|
from superlocalmemory.core.operation_request import OperationKind
|
|
19
21
|
from superlocalmemory.infra.data_root import state_path
|
|
22
|
+
from superlocalmemory.integrations.bounded_loops_mcp import BridgeUnavailable, observe_installed
|
|
20
23
|
from superlocalmemory.storage.agent_experience import (
|
|
21
24
|
AgentExperienceConflictError,
|
|
22
25
|
AgentExperienceStore,
|
|
@@ -25,6 +28,12 @@ from superlocalmemory.storage.agent_experience import (
|
|
|
25
28
|
ProfileAdmissionError,
|
|
26
29
|
get_profile_receipt_summary,
|
|
27
30
|
)
|
|
31
|
+
from superlocalmemory.storage.external_evidence import (
|
|
32
|
+
ExternalEvidenceConflictError,
|
|
33
|
+
ExternalEvidenceStore,
|
|
34
|
+
ExternalEvidenceValidationError,
|
|
35
|
+
get_profile_external_evidence_summary,
|
|
36
|
+
)
|
|
28
37
|
|
|
29
38
|
|
|
30
39
|
def _store_for(engine: Any) -> AgentExperienceStore:
|
|
@@ -35,6 +44,14 @@ def _store_for(engine: Any) -> AgentExperienceStore:
|
|
|
35
44
|
)
|
|
36
45
|
|
|
37
46
|
|
|
47
|
+
def _external_store_for(engine: Any) -> ExternalEvidenceStore:
|
|
48
|
+
active_profile = engine.profile_id
|
|
49
|
+
return ExternalEvidenceStore(
|
|
50
|
+
Path(state_path("learning.db")),
|
|
51
|
+
is_profile_active=lambda profile_id: profile_id == active_profile,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
38
55
|
def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
|
|
39
56
|
supplied = payload.get("profile_id")
|
|
40
57
|
if supplied != engine.profile_id:
|
|
@@ -42,6 +59,27 @@ def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
|
|
|
42
59
|
return None
|
|
43
60
|
|
|
44
61
|
|
|
62
|
+
class _ExternalEvidenceWriteError(Exception):
|
|
63
|
+
"""Preserve committed receipt count when a later snapshot item fails."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, created: int, cause: Exception) -> None:
|
|
66
|
+
super().__init__(str(cause))
|
|
67
|
+
self.created = created
|
|
68
|
+
self.cause = cause
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _record_external_evidence(store: ExternalEvidenceStore, observed: list[dict[str, Any]]) -> int:
|
|
72
|
+
"""Write bounded evidence off the async MCP loop; return durable inserts."""
|
|
73
|
+
created = 0
|
|
74
|
+
for payload in observed:
|
|
75
|
+
try:
|
|
76
|
+
if store.record(payload):
|
|
77
|
+
created += 1
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise _ExternalEvidenceWriteError(created, exc) from exc
|
|
80
|
+
return created
|
|
81
|
+
|
|
82
|
+
|
|
45
83
|
def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
46
84
|
"""Register transport-neutral receipt reads and writes.
|
|
47
85
|
|
|
@@ -52,7 +90,7 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
52
90
|
|
|
53
91
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
54
92
|
async def get_brain_evidence_status() -> dict[str, Any]:
|
|
55
|
-
"""Get profile-scoped
|
|
93
|
+
"""Get profile-scoped, observation-only Brain evidence totals."""
|
|
56
94
|
engine = get_engine()
|
|
57
95
|
return {
|
|
58
96
|
"success": True,
|
|
@@ -60,6 +98,9 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
60
98
|
"agent_experience": get_profile_receipt_summary(
|
|
61
99
|
state_path("learning.db"), engine.profile_id
|
|
62
100
|
),
|
|
101
|
+
"external_graph_evidence": get_profile_external_evidence_summary(
|
|
102
|
+
state_path("learning.db"), engine.profile_id
|
|
103
|
+
),
|
|
63
104
|
"control_plane": "observation_only",
|
|
64
105
|
}
|
|
65
106
|
|
|
@@ -110,9 +151,7 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
110
151
|
|
|
111
152
|
@server.tool()
|
|
112
153
|
@admits(OperationKind.REMEMBER)
|
|
113
|
-
async def finalize_cognitive_turn(
|
|
114
|
-
receipt_id: str, outcome: dict[str, Any]
|
|
115
|
-
) -> dict[str, Any]:
|
|
154
|
+
async def finalize_cognitive_turn(receipt_id: str, outcome: dict[str, Any]) -> dict[str, Any]:
|
|
116
155
|
"""Finalize an active-profile cognitive turn with outcome evidence."""
|
|
117
156
|
engine = get_engine()
|
|
118
157
|
try:
|
|
@@ -130,3 +169,49 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
130
169
|
except (TypeError, ValueError) as exc:
|
|
131
170
|
return {"success": False, "durable": False, "error": str(exc)}
|
|
132
171
|
return {"success": True, "durable": True, "finalized": finalized}
|
|
172
|
+
|
|
173
|
+
@server.tool()
|
|
174
|
+
@admits(OperationKind.REMEMBER)
|
|
175
|
+
async def observe_bounded_loop_evidence(workspace: str) -> dict[str, Any]:
|
|
176
|
+
"""Import one explicit, read-only snapshot from installed Bounded Loops.
|
|
177
|
+
|
|
178
|
+
Bounded Loops remains optional. This tool negotiates its public MCP
|
|
179
|
+
contract at runtime, records only compatible terminal evidence, and
|
|
180
|
+
never changes recall, ranking, routing, or learned behaviour.
|
|
181
|
+
"""
|
|
182
|
+
engine = get_engine()
|
|
183
|
+
created = 0
|
|
184
|
+
try:
|
|
185
|
+
observed = await observe_installed(workspace=workspace, profile_id=engine.profile_id)
|
|
186
|
+
store = _external_store_for(engine)
|
|
187
|
+
created = await asyncio.to_thread(_record_external_evidence, store, observed)
|
|
188
|
+
except _ExternalEvidenceWriteError as exc:
|
|
189
|
+
return {
|
|
190
|
+
"success": False,
|
|
191
|
+
"durable": exc.created > 0,
|
|
192
|
+
"created": exc.created,
|
|
193
|
+
"retryable": isinstance(exc.cause, LearningWriteBusyError),
|
|
194
|
+
"error": str(exc.cause),
|
|
195
|
+
}
|
|
196
|
+
except (
|
|
197
|
+
BridgeUnavailable,
|
|
198
|
+
ExternalEvidenceConflictError,
|
|
199
|
+
ExternalEvidenceValidationError,
|
|
200
|
+
ProfileAdmissionError,
|
|
201
|
+
sqlite3.Error,
|
|
202
|
+
) as exc:
|
|
203
|
+
return {
|
|
204
|
+
"success": False,
|
|
205
|
+
"durable": created > 0,
|
|
206
|
+
"created": created,
|
|
207
|
+
"error": str(exc),
|
|
208
|
+
}
|
|
209
|
+
except LearningWriteBusyError as exc:
|
|
210
|
+
return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
|
|
211
|
+
return {
|
|
212
|
+
"success": True,
|
|
213
|
+
"durable": True,
|
|
214
|
+
"observed": len(observed),
|
|
215
|
+
"created": created,
|
|
216
|
+
"control_plane": "observation_only",
|
|
217
|
+
}
|
|
@@ -327,7 +327,12 @@ def _compute_agent_experience(profile_id: str) -> dict:
|
|
|
327
327
|
Receipt rows record observation and verification evidence only; this read
|
|
328
328
|
model never alters retrieval, ranking, or model routing.
|
|
329
329
|
"""
|
|
330
|
-
|
|
330
|
+
from superlocalmemory.storage.external_evidence import get_profile_external_evidence_summary
|
|
331
|
+
path = _learning_db_path()
|
|
332
|
+
return {
|
|
333
|
+
**get_profile_receipt_summary(path, profile_id),
|
|
334
|
+
"external_graph_evidence": get_profile_external_evidence_summary(path, profile_id),
|
|
335
|
+
}
|
|
331
336
|
|
|
332
337
|
|
|
333
338
|
def _resolve_phase(signals: int, model_active: bool,
|
|
@@ -147,6 +147,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
147
147
|
from superlocalmemory.storage.migrations import (
|
|
148
148
|
M040_agent_experience_receipts as _M040,
|
|
149
149
|
)
|
|
150
|
+
from superlocalmemory.storage.migrations import (
|
|
151
|
+
M041_external_evidence_receipts as _M041,
|
|
152
|
+
)
|
|
150
153
|
|
|
151
154
|
# Emit under the runner's logger name so operational log filters that key on
|
|
152
155
|
# "superlocalmemory.storage.migration_runner" keep matching after this split.
|
|
@@ -195,6 +198,7 @@ _MODULES = {
|
|
|
195
198
|
_M038.NAME: _M038,
|
|
196
199
|
_M039.NAME: _M039,
|
|
197
200
|
_M040.NAME: _M040,
|
|
201
|
+
_M041.NAME: _M041,
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
# Exact historical DDL fingerprints whose resulting schema is intentionally
|
|
@@ -21,9 +21,9 @@ import sqlite3
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
#: Highest schema_version this runner can write. Matches the trailing serial
|
|
24
|
-
#: of the latest migration (
|
|
24
|
+
#: of the latest migration (M041). Increment when adding new migrations or
|
|
25
25
|
#: table-level breaking changes.
|
|
26
|
-
SUPPORTED_SCHEMA_VERSION: int =
|
|
26
|
+
SUPPORTED_SCHEMA_VERSION: int = 41
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class SchemaVersionError(RuntimeError):
|
|
@@ -245,17 +245,29 @@ class AgentExperienceStore:
|
|
|
245
245
|
turn_count = conn.execute(
|
|
246
246
|
"DELETE FROM cognitive_turn_receipts WHERE profile_id=?", (profile_id,)
|
|
247
247
|
).rowcount
|
|
248
|
+
external_count = 0
|
|
249
|
+
has_external = conn.execute(
|
|
250
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
251
|
+
"AND name='external_evidence_receipts'"
|
|
252
|
+
).fetchone() is not None
|
|
253
|
+
if has_external:
|
|
254
|
+
external_count = conn.execute(
|
|
255
|
+
"DELETE FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
|
|
256
|
+
).rowcount
|
|
257
|
+
receipt_tables = ["agent_experiences", "cognitive_turn_receipts"]
|
|
258
|
+
if has_external:
|
|
259
|
+
receipt_tables.append("external_evidence_receipts")
|
|
248
260
|
residue = sum(
|
|
249
261
|
int(
|
|
250
262
|
conn.execute(
|
|
251
263
|
f"SELECT COUNT(*) FROM {table} WHERE profile_id=?", (profile_id,)
|
|
252
264
|
).fetchone()[0]
|
|
253
265
|
)
|
|
254
|
-
for table in
|
|
266
|
+
for table in receipt_tables
|
|
255
267
|
)
|
|
256
268
|
if residue:
|
|
257
269
|
raise RuntimeError("learning receipt erasure left profile residue")
|
|
258
|
-
return experience_count + turn_count
|
|
270
|
+
return experience_count + turn_count + external_count
|
|
259
271
|
|
|
260
272
|
return self._write(erase)
|
|
261
273
|
|
|
@@ -422,7 +434,7 @@ def purge_profile_receipts(
|
|
|
422
434
|
for row in conn.execute(
|
|
423
435
|
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
424
436
|
"AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
|
|
425
|
-
"'agent_receipt_profile_closures')"
|
|
437
|
+
"'agent_receipt_profile_closures', 'external_evidence_receipts')"
|
|
426
438
|
)
|
|
427
439
|
}
|
|
428
440
|
if not tables:
|
|
@@ -431,6 +443,16 @@ def purge_profile_receipts(
|
|
|
431
443
|
"agent_experiences", "cognitive_turn_receipts", "agent_receipt_profile_closures"
|
|
432
444
|
}
|
|
433
445
|
if tables != expected:
|
|
446
|
+
if tables == expected | {"external_evidence_receipts"}:
|
|
447
|
+
from superlocalmemory.storage.migrations import M041_external_evidence_receipts as m041
|
|
448
|
+
|
|
449
|
+
with sqlite3.connect(path) as conn:
|
|
450
|
+
# Erasure needs a valid table, not its optional performance indexes.
|
|
451
|
+
# A damaged index must never strand profile-scoped evidence.
|
|
452
|
+
if m041._table_is_valid(conn):
|
|
453
|
+
return AgentExperienceStore(
|
|
454
|
+
path, is_profile_active=lambda _: True
|
|
455
|
+
).erase_profile(profile_id, close_profile=close_profile)
|
|
434
456
|
raise sqlite3.OperationalError("incomplete Agent Experience receipt schema")
|
|
435
457
|
return AgentExperienceStore(
|
|
436
458
|
path, is_profile_active=lambda _: True
|
|
@@ -459,7 +481,7 @@ def get_profile_receipt_summary(
|
|
|
459
481
|
if not path.exists():
|
|
460
482
|
return unavailable
|
|
461
483
|
try:
|
|
462
|
-
conn = sqlite3.connect(f"
|
|
484
|
+
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
|
|
463
485
|
try:
|
|
464
486
|
experience = conn.execute(
|
|
465
487
|
"SELECT COUNT(*) FROM agent_experiences WHERE profile_id=?", (profile_id,)
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Typed storage for versioned, observation-only MCP evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from superlocalmemory.storage.agent_experience import (
|
|
16
|
+
_PROCESS_LOCKS,
|
|
17
|
+
_PROCESS_LOCKS_GUARD,
|
|
18
|
+
_PROFILE_GATES,
|
|
19
|
+
LearningWriteBusyError,
|
|
20
|
+
ProfileAdmissionError,
|
|
21
|
+
_ProfileAdmissionGate,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_CONTRACT = "bounded-loops.dev/slm-bridge/v1"
|
|
25
|
+
_SHA256 = re.compile(r"\Asha256:[a-f0-9]{64}\Z")
|
|
26
|
+
_IDENTIFIER = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
27
|
+
_RUN_STATES = frozenset({"SUCCEEDED", "FAILED", "HALTED", "CANCELLED", "EXPIRED"})
|
|
28
|
+
_OUTCOMES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
|
|
29
|
+
_MAX_NODES = 256
|
|
30
|
+
_MAX_ARTIFACTS_PER_NODE = 64
|
|
31
|
+
_MAX_ARTIFACTS_TOTAL = 2_048
|
|
32
|
+
_MAX_NODES_JSON_BYTES = 64 * 1024
|
|
33
|
+
_MAX_TIMESTAMP_BYTES = 128
|
|
34
|
+
_MAX_RECEIPT_SEQUENCE = (1 << 63) - 1
|
|
35
|
+
_INSERT = (
|
|
36
|
+
"INSERT INTO external_evidence_receipts (profile_id, contract_id, workspace_id, "
|
|
37
|
+
"run_ref, run_id, outcome, run_state, demonstration, "
|
|
38
|
+
"eligible_for_learning, terminal_at, graph_digest, plan_digest, "
|
|
39
|
+
"policy_digest, receipt_sequence, receipt_head_digest, receipt_trust, "
|
|
40
|
+
"nodes_json, artifact_digests_json, payload_sha256, observed_at) "
|
|
41
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
42
|
+
"ON CONFLICT(profile_id, contract_id, workspace_id, run_ref) DO NOTHING"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExternalEvidenceConflictError(ValueError):
|
|
47
|
+
"""A stable external run address produced a different terminal receipt head."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExternalEvidenceValidationError(ValueError):
|
|
51
|
+
"""An external evidence document does not satisfy the public v1 contract."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExternalEvidenceStore:
|
|
55
|
+
"""Persist evidence without entering SLM's memory/recall lock domain."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str | Path, *, is_profile_active: Callable[[str], bool]) -> None:
|
|
58
|
+
self._path = Path(path)
|
|
59
|
+
self._is_profile_active = is_profile_active
|
|
60
|
+
resolved = str(self._path.resolve())
|
|
61
|
+
with _PROCESS_LOCKS_GUARD:
|
|
62
|
+
self._lock = _PROCESS_LOCKS.setdefault(resolved, threading.Lock())
|
|
63
|
+
self._gate = _PROFILE_GATES.setdefault(resolved, _ProfileAdmissionGate())
|
|
64
|
+
|
|
65
|
+
def record(self, payload: dict[str, Any]) -> bool:
|
|
66
|
+
_validate(payload)
|
|
67
|
+
profile_id = payload["profile_id"]
|
|
68
|
+
self._gate.admit(profile_id, self._is_profile_active)
|
|
69
|
+
digest = _payload_digest(payload)
|
|
70
|
+
deadline = time.monotonic() + 0.90
|
|
71
|
+
if not self._lock.acquire(timeout=0.90):
|
|
72
|
+
self._gate.release(profile_id)
|
|
73
|
+
raise LearningWriteBusyError("external evidence write deadline exceeded")
|
|
74
|
+
try:
|
|
75
|
+
while True:
|
|
76
|
+
conn: sqlite3.Connection | None = None
|
|
77
|
+
try:
|
|
78
|
+
conn = sqlite3.connect(str(self._path), timeout=0, isolation_level=None)
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
81
|
+
conn.execute("PRAGMA busy_timeout=0")
|
|
82
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
83
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
84
|
+
_assert_profile_open(conn, profile_id)
|
|
85
|
+
row = _row(payload, digest)
|
|
86
|
+
cursor = conn.execute(_INSERT, row)
|
|
87
|
+
if cursor.rowcount:
|
|
88
|
+
conn.execute("COMMIT")
|
|
89
|
+
return True
|
|
90
|
+
existing = _get_conn(
|
|
91
|
+
conn,
|
|
92
|
+
profile_id,
|
|
93
|
+
payload["contract"],
|
|
94
|
+
payload["workspace_id"],
|
|
95
|
+
payload["run_ref"],
|
|
96
|
+
)
|
|
97
|
+
conn.execute("ROLLBACK")
|
|
98
|
+
if _payload_digest(existing) == digest:
|
|
99
|
+
return False
|
|
100
|
+
raise ExternalEvidenceConflictError(
|
|
101
|
+
"external run address has a different receipt head"
|
|
102
|
+
)
|
|
103
|
+
except sqlite3.OperationalError as exc:
|
|
104
|
+
if conn is not None and conn.in_transaction:
|
|
105
|
+
conn.execute("ROLLBACK")
|
|
106
|
+
busy = "locked" in str(exc).lower() or "busy" in str(exc).lower()
|
|
107
|
+
if not busy or time.monotonic() >= deadline:
|
|
108
|
+
if busy:
|
|
109
|
+
raise LearningWriteBusyError(
|
|
110
|
+
"external evidence write deadline exceeded"
|
|
111
|
+
) from exc
|
|
112
|
+
raise
|
|
113
|
+
time.sleep(0.02)
|
|
114
|
+
finally:
|
|
115
|
+
if conn is not None:
|
|
116
|
+
conn.close()
|
|
117
|
+
finally:
|
|
118
|
+
self._lock.release()
|
|
119
|
+
self._gate.release(profile_id)
|
|
120
|
+
|
|
121
|
+
def get(self, profile_id: str, workspace_id: str, run_ref: str) -> dict[str, Any] | None:
|
|
122
|
+
conn = sqlite3.connect(f"{self._path.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
|
|
123
|
+
conn.row_factory = sqlite3.Row
|
|
124
|
+
try:
|
|
125
|
+
return _get_conn(conn, profile_id, _CONTRACT, workspace_id, run_ref)
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_profile_external_evidence_summary(path: str | Path, profile_id: str) -> dict[str, Any]:
|
|
131
|
+
"""Return indexed Living Brain totals without opening SLM's memory database."""
|
|
132
|
+
empty = {
|
|
133
|
+
"is_real": False,
|
|
134
|
+
"availability": "unavailable",
|
|
135
|
+
"total": 0,
|
|
136
|
+
"by_run_state": {},
|
|
137
|
+
"demonstrations": 0,
|
|
138
|
+
}
|
|
139
|
+
target = Path(path)
|
|
140
|
+
if not target.exists():
|
|
141
|
+
return empty
|
|
142
|
+
conn: sqlite3.Connection | None = None
|
|
143
|
+
try:
|
|
144
|
+
conn = sqlite3.connect(f"{target.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
|
|
145
|
+
total = conn.execute(
|
|
146
|
+
"SELECT COUNT(*) FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
|
|
147
|
+
).fetchone()[0]
|
|
148
|
+
demo = conn.execute(
|
|
149
|
+
"SELECT COUNT(*) FROM external_evidence_receipts "
|
|
150
|
+
"WHERE profile_id=? AND demonstration=1",
|
|
151
|
+
(profile_id,),
|
|
152
|
+
).fetchone()[0]
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
"SELECT run_state, COUNT(*) FROM external_evidence_receipts "
|
|
155
|
+
"WHERE profile_id=? GROUP BY run_state",
|
|
156
|
+
(profile_id,),
|
|
157
|
+
).fetchall()
|
|
158
|
+
except sqlite3.Error:
|
|
159
|
+
return empty
|
|
160
|
+
finally:
|
|
161
|
+
if conn is not None:
|
|
162
|
+
conn.close()
|
|
163
|
+
return {
|
|
164
|
+
"is_real": True,
|
|
165
|
+
"availability": "available",
|
|
166
|
+
"total": int(total),
|
|
167
|
+
"by_run_state": {str(k): int(v) for k, v in rows},
|
|
168
|
+
"demonstrations": int(demo),
|
|
169
|
+
"control_plane": "observation_only",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate(payload: dict[str, Any]) -> None:
|
|
174
|
+
required = {
|
|
175
|
+
"contract",
|
|
176
|
+
"profile_id",
|
|
177
|
+
"workspace_id",
|
|
178
|
+
"run_ref",
|
|
179
|
+
"run_id",
|
|
180
|
+
"outcome",
|
|
181
|
+
"run_state",
|
|
182
|
+
"demonstration",
|
|
183
|
+
"eligible_for_learning",
|
|
184
|
+
"terminal_at",
|
|
185
|
+
"graph_digest",
|
|
186
|
+
"plan_digest",
|
|
187
|
+
"policy_digest",
|
|
188
|
+
"receipt",
|
|
189
|
+
"nodes",
|
|
190
|
+
}
|
|
191
|
+
if set(payload) != required:
|
|
192
|
+
raise ExternalEvidenceValidationError("external evidence fields do not match v1")
|
|
193
|
+
if payload["contract"] != _CONTRACT:
|
|
194
|
+
raise ExternalEvidenceValidationError("unsupported external evidence contract")
|
|
195
|
+
for name in ("profile_id", "run_ref", "run_id"):
|
|
196
|
+
if not isinstance(payload[name], str) or not _IDENTIFIER.match(payload[name]):
|
|
197
|
+
raise ExternalEvidenceValidationError(f"{name} must be a safe identifier")
|
|
198
|
+
for name in ("workspace_id", "graph_digest", "plan_digest", "policy_digest"):
|
|
199
|
+
if not isinstance(payload[name], str) or not _SHA256.match(payload[name]):
|
|
200
|
+
raise ExternalEvidenceValidationError(f"{name} must be a sha256 digest")
|
|
201
|
+
if payload["outcome"] not in _OUTCOMES or payload["run_state"] not in _RUN_STATES:
|
|
202
|
+
raise ExternalEvidenceValidationError("outcome or run_state is unsupported")
|
|
203
|
+
if payload["run_state"] == "SUCCEEDED" and payload["outcome"] != "SUCCEEDED":
|
|
204
|
+
raise ExternalEvidenceValidationError("SUCCEEDED run_state must keep its outcome")
|
|
205
|
+
if (
|
|
206
|
+
not isinstance(payload["terminal_at"], str)
|
|
207
|
+
or len(payload["terminal_at"].encode("utf-8")) > _MAX_TIMESTAMP_BYTES
|
|
208
|
+
):
|
|
209
|
+
raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp")
|
|
210
|
+
try:
|
|
211
|
+
datetime.fromisoformat(payload["terminal_at"].replace("Z", "+00:00"))
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp") from exc
|
|
214
|
+
if (
|
|
215
|
+
not isinstance(payload["demonstration"], bool)
|
|
216
|
+
or payload["eligible_for_learning"] is not False
|
|
217
|
+
):
|
|
218
|
+
raise ExternalEvidenceValidationError("v1 evidence is observation-only")
|
|
219
|
+
receipt = payload["receipt"]
|
|
220
|
+
if not isinstance(receipt, dict) or set(receipt) != {
|
|
221
|
+
"sequence",
|
|
222
|
+
"head_digest",
|
|
223
|
+
"trust",
|
|
224
|
+
}:
|
|
225
|
+
raise ExternalEvidenceValidationError("receipt shape is invalid")
|
|
226
|
+
if (
|
|
227
|
+
not isinstance(receipt["sequence"], int)
|
|
228
|
+
or receipt["sequence"] < 1
|
|
229
|
+
or receipt["sequence"] > _MAX_RECEIPT_SEQUENCE
|
|
230
|
+
or receipt["trust"] != "local_hash_chain_only"
|
|
231
|
+
):
|
|
232
|
+
raise ExternalEvidenceValidationError("receipt metadata is invalid")
|
|
233
|
+
if not isinstance(receipt["head_digest"], str) or not _SHA256.match(receipt["head_digest"]):
|
|
234
|
+
raise ExternalEvidenceValidationError("receipt head digest is invalid")
|
|
235
|
+
if not isinstance(payload["nodes"], list):
|
|
236
|
+
raise ExternalEvidenceValidationError("nodes must be a list")
|
|
237
|
+
if len(payload["nodes"]) > _MAX_NODES:
|
|
238
|
+
raise ExternalEvidenceValidationError("node count exceeds v1 safety limit")
|
|
239
|
+
artifact_count = 0
|
|
240
|
+
for node in payload["nodes"]:
|
|
241
|
+
if not isinstance(node, dict) or set(node) != {
|
|
242
|
+
"node_id",
|
|
243
|
+
"state",
|
|
244
|
+
"gate_passed",
|
|
245
|
+
"attempts",
|
|
246
|
+
"artifact_digests",
|
|
247
|
+
}:
|
|
248
|
+
raise ExternalEvidenceValidationError("node shape is invalid")
|
|
249
|
+
valid_node = _IDENTIFIER.match(str(node["node_id"])) and _IDENTIFIER.match(
|
|
250
|
+
str(node["state"])
|
|
251
|
+
)
|
|
252
|
+
if not valid_node:
|
|
253
|
+
raise ExternalEvidenceValidationError("node identifiers are invalid")
|
|
254
|
+
if (
|
|
255
|
+
node["gate_passed"] not in (True, False, None)
|
|
256
|
+
or not isinstance(node["attempts"], int)
|
|
257
|
+
or node["attempts"] < 1
|
|
258
|
+
):
|
|
259
|
+
raise ExternalEvidenceValidationError("node gate metadata is invalid")
|
|
260
|
+
if not isinstance(node["artifact_digests"], list) or any(
|
|
261
|
+
not isinstance(item, str) or not _SHA256.match(item)
|
|
262
|
+
for item in node["artifact_digests"]
|
|
263
|
+
):
|
|
264
|
+
raise ExternalEvidenceValidationError("node artifact digests are invalid")
|
|
265
|
+
if len(node["artifact_digests"]) > _MAX_ARTIFACTS_PER_NODE:
|
|
266
|
+
raise ExternalEvidenceValidationError("node artifact count exceeds v1 safety limit")
|
|
267
|
+
artifact_count += len(node["artifact_digests"])
|
|
268
|
+
if artifact_count > _MAX_ARTIFACTS_TOTAL:
|
|
269
|
+
raise ExternalEvidenceValidationError("artifact count exceeds v1 safety limit")
|
|
270
|
+
if len(_json(payload["nodes"]).encode("utf-8")) > _MAX_NODES_JSON_BYTES:
|
|
271
|
+
raise ExternalEvidenceValidationError("node evidence exceeds v1 size limit")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _row(payload: dict[str, Any], digest: str) -> tuple[Any, ...]:
|
|
275
|
+
artifacts = sorted({item for node in payload["nodes"] for item in node["artifact_digests"]})
|
|
276
|
+
receipt = payload["receipt"]
|
|
277
|
+
return (
|
|
278
|
+
payload["profile_id"],
|
|
279
|
+
payload["contract"],
|
|
280
|
+
payload["workspace_id"],
|
|
281
|
+
payload["run_ref"],
|
|
282
|
+
payload["run_id"],
|
|
283
|
+
payload["outcome"],
|
|
284
|
+
payload["run_state"],
|
|
285
|
+
int(payload["demonstration"]),
|
|
286
|
+
0,
|
|
287
|
+
payload["terminal_at"],
|
|
288
|
+
payload["graph_digest"],
|
|
289
|
+
payload["plan_digest"],
|
|
290
|
+
payload["policy_digest"],
|
|
291
|
+
receipt["sequence"],
|
|
292
|
+
receipt["head_digest"],
|
|
293
|
+
receipt["trust"],
|
|
294
|
+
_json(payload["nodes"]),
|
|
295
|
+
_json(artifacts),
|
|
296
|
+
digest,
|
|
297
|
+
datetime.now(timezone.utc).isoformat(),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _assert_profile_open(conn: sqlite3.Connection, profile_id: str) -> None:
|
|
302
|
+
"""Use M040's durable tombstone inside this writer transaction."""
|
|
303
|
+
table = conn.execute(
|
|
304
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
305
|
+
"AND name='agent_receipt_profile_closures'"
|
|
306
|
+
).fetchone()
|
|
307
|
+
if (
|
|
308
|
+
table is not None
|
|
309
|
+
and conn.execute(
|
|
310
|
+
"SELECT 1 FROM agent_receipt_profile_closures WHERE profile_id=?", (profile_id,)
|
|
311
|
+
).fetchone()
|
|
312
|
+
is not None
|
|
313
|
+
):
|
|
314
|
+
raise ProfileAdmissionError("profile is inactive or closing for erasure")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _get_conn(
|
|
318
|
+
conn: sqlite3.Connection,
|
|
319
|
+
profile_id: str,
|
|
320
|
+
contract_id: str,
|
|
321
|
+
workspace_id: str,
|
|
322
|
+
run_ref: str,
|
|
323
|
+
) -> dict[str, Any] | None:
|
|
324
|
+
row = conn.execute(
|
|
325
|
+
"SELECT * FROM external_evidence_receipts WHERE profile_id=? AND contract_id=? "
|
|
326
|
+
"AND workspace_id=? AND run_ref=?",
|
|
327
|
+
(profile_id, contract_id, workspace_id, run_ref),
|
|
328
|
+
).fetchone()
|
|
329
|
+
if row is None:
|
|
330
|
+
return None
|
|
331
|
+
return {
|
|
332
|
+
"contract": row["contract_id"],
|
|
333
|
+
"profile_id": row["profile_id"],
|
|
334
|
+
"workspace_id": row["workspace_id"],
|
|
335
|
+
"run_ref": row["run_ref"],
|
|
336
|
+
"run_id": row["run_id"],
|
|
337
|
+
"outcome": row["outcome"],
|
|
338
|
+
"run_state": row["run_state"],
|
|
339
|
+
"demonstration": bool(row["demonstration"]),
|
|
340
|
+
"eligible_for_learning": bool(row["eligible_for_learning"]),
|
|
341
|
+
"terminal_at": row["terminal_at"],
|
|
342
|
+
"graph_digest": row["graph_digest"],
|
|
343
|
+
"plan_digest": row["plan_digest"],
|
|
344
|
+
"policy_digest": row["policy_digest"],
|
|
345
|
+
"receipt": {
|
|
346
|
+
"sequence": row["receipt_sequence"],
|
|
347
|
+
"head_digest": row["receipt_head_digest"],
|
|
348
|
+
"trust": row["receipt_trust"],
|
|
349
|
+
},
|
|
350
|
+
"nodes": json.loads(row["nodes_json"]),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _payload_digest(payload: dict[str, Any]) -> str:
|
|
355
|
+
return hashlib.sha256(_json(payload).encode("utf-8")).hexdigest()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _json(value: Any) -> str:
|
|
359
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
@@ -154,6 +154,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
154
154
|
from superlocalmemory.storage.migrations import (
|
|
155
155
|
M040_agent_experience_receipts as _M040,
|
|
156
156
|
)
|
|
157
|
+
from superlocalmemory.storage.migrations import (
|
|
158
|
+
M041_external_evidence_receipts as _M041,
|
|
159
|
+
)
|
|
157
160
|
from superlocalmemory.storage._schema_version import (
|
|
158
161
|
SUPPORTED_SCHEMA_VERSION,
|
|
159
162
|
SchemaVersionError,
|
|
@@ -234,6 +237,8 @@ MIGRATIONS: list[Migration] = [
|
|
|
234
237
|
# lifecycle performs explicit cross-store erasure rather than an FK.
|
|
235
238
|
Migration(name=_M040.NAME, db_target="learning", ddl=_M040.DDL,
|
|
236
239
|
dependencies=(_M003.NAME,)),
|
|
240
|
+
Migration(name=_M041.NAME, db_target="learning", ddl=_M041.DDL,
|
|
241
|
+
dependencies=(_M040.NAME,)),
|
|
237
242
|
# M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
|
|
238
243
|
]
|
|
239
244
|
|