superlocalmemory 4.0.3 → 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 +22 -0
- package/README.md +11 -10
- 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/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +184 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/mcp/profiles.py +11 -5
- package/src/superlocalmemory/mcp/server.py +5 -2
- 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
|
@@ -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
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""M041 — typed, observation-only external evidence in ``learning.db``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
NAME = "M041_external_evidence_receipts"
|
|
8
|
+
DB_TARGET = "learning"
|
|
9
|
+
|
|
10
|
+
DDL = """
|
|
11
|
+
BEGIN IMMEDIATE;
|
|
12
|
+
CREATE TABLE IF NOT EXISTS external_evidence_receipts (
|
|
13
|
+
profile_id TEXT NOT NULL,
|
|
14
|
+
contract_id TEXT NOT NULL,
|
|
15
|
+
workspace_id TEXT NOT NULL,
|
|
16
|
+
run_ref TEXT NOT NULL,
|
|
17
|
+
run_id TEXT NOT NULL,
|
|
18
|
+
outcome TEXT NOT NULL,
|
|
19
|
+
run_state TEXT NOT NULL,
|
|
20
|
+
demonstration INTEGER NOT NULL CHECK (demonstration IN (0, 1)),
|
|
21
|
+
eligible_for_learning INTEGER NOT NULL CHECK (eligible_for_learning IN (0, 1)),
|
|
22
|
+
terminal_at TEXT NOT NULL,
|
|
23
|
+
graph_digest TEXT NOT NULL,
|
|
24
|
+
plan_digest TEXT NOT NULL,
|
|
25
|
+
policy_digest TEXT NOT NULL,
|
|
26
|
+
receipt_sequence INTEGER NOT NULL,
|
|
27
|
+
receipt_head_digest TEXT NOT NULL,
|
|
28
|
+
receipt_trust TEXT NOT NULL,
|
|
29
|
+
nodes_json TEXT NOT NULL,
|
|
30
|
+
artifact_digests_json TEXT NOT NULL,
|
|
31
|
+
payload_sha256 TEXT NOT NULL,
|
|
32
|
+
observed_at TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (profile_id, contract_id, workspace_id, run_ref)
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_external_evidence_profile_terminal
|
|
36
|
+
ON external_evidence_receipts (profile_id, terminal_at DESC);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_external_evidence_profile_workspace
|
|
38
|
+
ON external_evidence_receipts (profile_id, workspace_id, terminal_at DESC);
|
|
39
|
+
COMMIT;
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_TABLE = "external_evidence_receipts"
|
|
43
|
+
_COLUMNS = (
|
|
44
|
+
"profile_id",
|
|
45
|
+
"contract_id",
|
|
46
|
+
"workspace_id",
|
|
47
|
+
"run_ref",
|
|
48
|
+
"run_id",
|
|
49
|
+
"outcome",
|
|
50
|
+
"run_state",
|
|
51
|
+
"demonstration",
|
|
52
|
+
"eligible_for_learning",
|
|
53
|
+
"terminal_at",
|
|
54
|
+
"graph_digest",
|
|
55
|
+
"plan_digest",
|
|
56
|
+
"policy_digest",
|
|
57
|
+
"receipt_sequence",
|
|
58
|
+
"receipt_head_digest",
|
|
59
|
+
"receipt_trust",
|
|
60
|
+
"nodes_json",
|
|
61
|
+
"artifact_digests_json",
|
|
62
|
+
"payload_sha256",
|
|
63
|
+
"observed_at",
|
|
64
|
+
)
|
|
65
|
+
_TYPES = (
|
|
66
|
+
"TEXT",
|
|
67
|
+
"TEXT",
|
|
68
|
+
"TEXT",
|
|
69
|
+
"TEXT",
|
|
70
|
+
"TEXT",
|
|
71
|
+
"TEXT",
|
|
72
|
+
"TEXT",
|
|
73
|
+
"INTEGER",
|
|
74
|
+
"INTEGER",
|
|
75
|
+
"TEXT",
|
|
76
|
+
"TEXT",
|
|
77
|
+
"TEXT",
|
|
78
|
+
"TEXT",
|
|
79
|
+
"INTEGER",
|
|
80
|
+
"TEXT",
|
|
81
|
+
"TEXT",
|
|
82
|
+
"TEXT",
|
|
83
|
+
"TEXT",
|
|
84
|
+
"TEXT",
|
|
85
|
+
"TEXT",
|
|
86
|
+
)
|
|
87
|
+
_PRIMARY_KEY = ("profile_id", "contract_id", "workspace_id", "run_ref")
|
|
88
|
+
_INDEXES = {
|
|
89
|
+
"idx_external_evidence_profile_terminal": (
|
|
90
|
+
"external_evidence_receipts", (("profile_id", False), ("terminal_at", True)),
|
|
91
|
+
),
|
|
92
|
+
"idx_external_evidence_profile_workspace": (
|
|
93
|
+
"external_evidence_receipts",
|
|
94
|
+
(("profile_id", False), ("workspace_id", False), ("terminal_at", True)),
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
100
|
+
"""Install the additive receipt table atomically and idempotently."""
|
|
101
|
+
if _table_exists(conn) and not _table_is_valid(conn):
|
|
102
|
+
raise sqlite3.OperationalError(
|
|
103
|
+
"M041 external evidence table is malformed; refusing rebuild"
|
|
104
|
+
)
|
|
105
|
+
conn.executescript(DDL)
|
|
106
|
+
if not verify(conn):
|
|
107
|
+
repair(conn)
|
|
108
|
+
if not verify(conn):
|
|
109
|
+
raise sqlite3.OperationalError("M041 external evidence schema did not reach its end-state")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def repair(conn: sqlite3.Connection) -> None:
|
|
113
|
+
"""Restore only M041's derived indexes without touching stored evidence."""
|
|
114
|
+
if _table_exists(conn) and not _table_is_valid(conn):
|
|
115
|
+
raise sqlite3.OperationalError(
|
|
116
|
+
"M041 external evidence table is malformed; refusing rebuild"
|
|
117
|
+
)
|
|
118
|
+
if not _table_exists(conn):
|
|
119
|
+
apply(conn)
|
|
120
|
+
return
|
|
121
|
+
drops = "\n".join(f"DROP INDEX IF EXISTS {name};" for name in _INDEXES)
|
|
122
|
+
creates = "\n".join(
|
|
123
|
+
f"CREATE INDEX {name} ON {table} ({_index_sql_columns(columns)});"
|
|
124
|
+
for name, (table, columns) in _INDEXES.items()
|
|
125
|
+
)
|
|
126
|
+
conn.executescript(f"BEGIN IMMEDIATE;\n{drops}\n{creates}\nCOMMIT;")
|
|
127
|
+
if not verify(conn):
|
|
128
|
+
raise sqlite3.OperationalError("M041 index repair did not restore required end-state")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
132
|
+
if not _table_is_valid(conn):
|
|
133
|
+
return False
|
|
134
|
+
for name, (table, columns) in _INDEXES.items():
|
|
135
|
+
row = conn.execute(
|
|
136
|
+
"SELECT tbl_name FROM sqlite_master WHERE type='index' AND name=?", (name,)
|
|
137
|
+
).fetchone()
|
|
138
|
+
if row is None or row[0] != table:
|
|
139
|
+
return False
|
|
140
|
+
actual = tuple(
|
|
141
|
+
(item[2], bool(item[3]))
|
|
142
|
+
for item in conn.execute(f"PRAGMA index_xinfo({name})")
|
|
143
|
+
if item[5] and item[2] is not None
|
|
144
|
+
)
|
|
145
|
+
if actual != columns:
|
|
146
|
+
return False
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _table_exists(conn: sqlite3.Connection) -> bool:
|
|
151
|
+
return (
|
|
152
|
+
conn.execute(
|
|
153
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (_TABLE,)
|
|
154
|
+
).fetchone()
|
|
155
|
+
is not None
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _table_is_valid(conn: sqlite3.Connection) -> bool:
|
|
160
|
+
if not _table_exists(conn):
|
|
161
|
+
return False
|
|
162
|
+
info = conn.execute(f"PRAGMA table_info({_TABLE})").fetchall()
|
|
163
|
+
columns = tuple(row[1] for row in info)
|
|
164
|
+
types = tuple(str(row[2]).upper() for row in info)
|
|
165
|
+
primary_key = tuple(row[1] for row in sorted(info, key=lambda row: row[5]) if row[5])
|
|
166
|
+
not_null = {row[1] for row in info if row[3] or row[5]}
|
|
167
|
+
return (
|
|
168
|
+
columns == _COLUMNS
|
|
169
|
+
and types == _TYPES
|
|
170
|
+
and primary_key == _PRIMARY_KEY
|
|
171
|
+
and not_null == set(_COLUMNS)
|
|
172
|
+
and conn.execute(f"PRAGMA foreign_key_list({_TABLE})").fetchone() is None
|
|
173
|
+
and _required_checks_present(conn)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _required_checks_present(conn: sqlite3.Connection) -> bool:
|
|
178
|
+
row = conn.execute(
|
|
179
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (_TABLE,)
|
|
180
|
+
).fetchone()
|
|
181
|
+
sql = "" if row is None or row[0] is None else "".join(str(row[0]).lower().split())
|
|
182
|
+
return (
|
|
183
|
+
"check(demonstrationin(0,1))" in sql
|
|
184
|
+
and "check(eligible_for_learningin(0,1))" in sql
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _index_sql_columns(columns: tuple[tuple[str, bool], ...]) -> str:
|
|
189
|
+
return ", ".join(column + (" DESC" if desc else "") for column, desc in columns)
|
|
@@ -31,6 +31,7 @@ from . import (
|
|
|
31
31
|
M038_learning_feedback_channel,
|
|
32
32
|
M039_scene_fact_members,
|
|
33
33
|
M040_agent_experience_receipts,
|
|
34
|
+
M041_external_evidence_receipts,
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
# ---------------------------------------------------------------------------
|
|
@@ -87,6 +88,7 @@ __all__ = (
|
|
|
87
88
|
"M038_learning_feedback_channel",
|
|
88
89
|
"M039_scene_fact_members",
|
|
89
90
|
"M040_agent_experience_receipts",
|
|
91
|
+
"M041_external_evidence_receipts",
|
|
90
92
|
# Legacy re-exports (backward compat):
|
|
91
93
|
"CURRENT_SCHEMA_VERSION",
|
|
92
94
|
"get_schema_version",
|
|
@@ -230,6 +230,7 @@
|
|
|
230
230
|
var feedback = (living && living.feedback) || {};
|
|
231
231
|
var graph = (living && living.graph) || {};
|
|
232
232
|
var experience = (living && living.agent_experience) || {};
|
|
233
|
+
var externalEvidence = experience.external_graph_evidence || {};
|
|
233
234
|
|
|
234
235
|
// KPI strip
|
|
235
236
|
var strip = EL('div', { className: 'kpi-strip', style: 'margin-bottom:16px' });
|
|
@@ -318,6 +319,8 @@
|
|
|
318
319
|
['Claimed evidence authority', String(experience.claimed_evidence_experiences || 0)],
|
|
319
320
|
['Cognitive turns', String(experience.turns_total || 0) +
|
|
320
321
|
' · ' + String((experience.turns_by_state || {}).finalized || 0) + ' finalized'],
|
|
322
|
+
['Bounded Loop observations', String(externalEvidence.total || 0) +
|
|
323
|
+
(externalEvidence.is_real ? ' terminal receipts' : ' unavailable')],
|
|
321
324
|
['Graph evidence', String(graph.fact_nodes || 0) + ' nodes · ' +
|
|
322
325
|
String(graph.association_edges || 0) + ' edges'],
|
|
323
326
|
].forEach(function (row) {
|
|
@@ -362,6 +365,12 @@
|
|
|
362
365
|
fmtNum((experience.turns_by_state || {}).open || 0) + ' open · ' +
|
|
363
366
|
fmtNum((experience.turns_by_state || {}).finalized || 0) + ' finalized',
|
|
364
367
|
Number(experience.turns_total || 0) > 0, undefined, true));
|
|
368
|
+
evGrid.appendChild(kpiCard('account_tree', 'Bounded Loop observations',
|
|
369
|
+
fmtNum(externalEvidence.total || 0),
|
|
370
|
+
externalEvidence.is_real
|
|
371
|
+
? fmtNum(externalEvidence.demonstrations || 0) + ' demonstrations · no automatic learning'
|
|
372
|
+
: 'connect Bounded Loops to observe terminal runs',
|
|
373
|
+
Number(externalEvidence.total || 0) > 0, undefined, true));
|
|
365
374
|
evb.appendChild(evGrid);
|
|
366
375
|
evc.appendChild(evb);
|
|
367
376
|
sec.appendChild(evc);
|