superlocalmemory 4.0.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +10 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +3 -2
  37. package/src/superlocalmemory/__init__.py +1 -1
  38. package/src/superlocalmemory/cli/commands.py +60 -1
  39. package/src/superlocalmemory/cli/main.py +24 -1
  40. package/src/superlocalmemory/compliance/gdpr.py +104 -73
  41. package/src/superlocalmemory/contracts/__init__.py +1 -0
  42. package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
  43. package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
  44. package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
  45. package/src/superlocalmemory/contracts/v402.py +62 -0
  46. package/src/superlocalmemory/core/engine.py +10 -0
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -0
  48. package/src/superlocalmemory/core/recall_worker.py +12 -0
  49. package/src/superlocalmemory/core/worker_pool.py +12 -0
  50. package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
  51. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
  52. package/src/superlocalmemory/hooks/session_registry.py +136 -3
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
  54. package/src/superlocalmemory/integrations/__init__.py +1 -0
  55. package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
  56. package/src/superlocalmemory/learning/database.py +21 -14
  57. package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
  58. package/src/superlocalmemory/mcp/server.py +5 -0
  59. package/src/superlocalmemory/mcp/tools_brain.py +132 -0
  60. package/src/superlocalmemory/mcp/tools_core.py +25 -6
  61. package/src/superlocalmemory/mcp/tools_v3.py +16 -2
  62. package/src/superlocalmemory/retrieval/engine.py +43 -1
  63. package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
  64. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
  65. package/src/superlocalmemory/server/routes/brain.py +206 -1
  66. package/src/superlocalmemory/server/routes/helpers.py +53 -35
  67. package/src/superlocalmemory/server/routes/v3_api.py +118 -11
  68. package/src/superlocalmemory/server/unified_daemon.py +25 -0
  69. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  70. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  71. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  72. package/src/superlocalmemory/storage/database.py +189 -34
  73. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  74. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  75. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  76. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  77. package/src/superlocalmemory/storage/schema.py +4 -0
  78. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  79. package/src/superlocalmemory/ui/js/brain.js +57 -1
  80. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  81. package/src/superlocalmemory/ui/js/od-settings.js +8 -1
@@ -3729,6 +3729,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
3729
3729
  include_shared: bool | None = None,
3730
3730
  window: str = "",
3731
3731
  as_of: str = "",
3732
+ known_as_of: str = "",
3733
+ valid_at: str = "",
3734
+ include_unknown: bool = False,
3732
3735
  ):
3733
3736
  _update_activity()
3734
3737
  search_query = q or query # Accept both ?q= and ?query= for compatibility
@@ -3754,6 +3757,25 @@ def _register_daemon_routes(application: FastAPI) -> None:
3754
3757
  as_of = _as_of_norm
3755
3758
  else:
3756
3759
  as_of = ""
3760
+ def _normalize_temporal_query(value: str, error_code: str):
3761
+ raw = value.strip() if value else ""
3762
+ if not raw:
3763
+ return ""
3764
+ from superlocalmemory.retrieval.temporal_utils import normalize_as_of
3765
+ normalized = normalize_as_of(raw)
3766
+ if normalized is None:
3767
+ from starlette.responses import JSONResponse
3768
+ return JSONResponse(
3769
+ {"error": error_code, "message": f"Cannot parse {error_code}: {raw!r}"},
3770
+ status_code=400,
3771
+ )
3772
+ return normalized
3773
+ known_as_of = _normalize_temporal_query(known_as_of, "invalid_known_as_of")
3774
+ if not isinstance(known_as_of, str):
3775
+ return known_as_of
3776
+ valid_at = _normalize_temporal_query(valid_at, "invalid_valid_at")
3777
+ if not isinstance(valid_at, str):
3778
+ return valid_at
3757
3779
  # v3.8.2: resolve the client-driven-agentic default now so the concrete
3758
3780
  # bool drives BOTH the full-recall semaphore below and engine.recall().
3759
3781
  from superlocalmemory.core.recall_pipeline import resolve_hot_path_fast
@@ -3813,6 +3835,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
3813
3835
  include_shared=include_shared,
3814
3836
  window=window or None,
3815
3837
  as_of=as_of or None,
3838
+ known_as_of=known_as_of or None,
3839
+ valid_at=valid_at or None,
3840
+ include_unknown=include_unknown,
3816
3841
  ),
3817
3842
  )
3818
3843
  _budget = _recall_budget_s()
@@ -144,6 +144,9 @@ from superlocalmemory.storage.migrations import (
144
144
  from superlocalmemory.storage.migrations import (
145
145
  M039_scene_fact_members as _M039,
146
146
  )
147
+ from superlocalmemory.storage.migrations import (
148
+ M040_agent_experience_receipts as _M040,
149
+ )
147
150
 
148
151
  # Emit under the runner's logger name so operational log filters that key on
149
152
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -191,6 +194,7 @@ _MODULES = {
191
194
  _M037.NAME: _M037,
192
195
  _M038.NAME: _M038,
193
196
  _M039.NAME: _M039,
197
+ _M040.NAME: _M040,
194
198
  }
195
199
 
196
200
  # 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 (M039). Increment when adding new migrations or
24
+ #: of the latest migration (M040). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
- SUPPORTED_SCHEMA_VERSION: int = 39
26
+ SUPPORTED_SCHEMA_VERSION: int = 40
27
27
 
28
28
 
29
29
  class SchemaVersionError(RuntimeError):
@@ -0,0 +1,490 @@
1
+ """Bounded, profile-scoped receipt persistence for the learning plane.
2
+
3
+ This module never opens ``memory.db``. A caller must admit a profile before a
4
+ receipt transaction starts; profile deletion closes that admission and drains
5
+ these short transactions before its cross-store erasure saga continues.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import random
13
+ import sqlite3
14
+ import threading
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Callable, TypeVar
19
+
20
+ from superlocalmemory.contracts.v402 import validate_agent_experience, validate_cognitive_turn
21
+
22
+ _T = TypeVar("_T")
23
+ _WRITE_DEADLINE_SECONDS = 0.90
24
+ _PROCESS_LOCKS: dict[str, threading.Lock] = {}
25
+ _PROCESS_LOCKS_GUARD = threading.Lock()
26
+ _PROFILE_GATES: dict[str, "_ProfileAdmissionGate"] = {}
27
+
28
+
29
+ class AgentExperienceConflictError(ValueError):
30
+ """An opaque receipt identifier already names different evidence."""
31
+
32
+
33
+ class CognitiveTurnTransitionError(ValueError):
34
+ """A cognitive turn was missing or attempted an invalid transition."""
35
+
36
+
37
+ class LearningWriteBusyError(RuntimeError):
38
+ """Learning receipt admission could not acquire SQLite before its deadline."""
39
+
40
+
41
+ class ProfileAdmissionError(ValueError):
42
+ """The profile is inactive or currently closing for erasure."""
43
+
44
+
45
+ class _ProfileAdmissionGate:
46
+ """Path-scoped admission leases which make receipt erasure race-free."""
47
+
48
+ def __init__(self) -> None:
49
+ self._condition = threading.Condition()
50
+ self._closing: set[str] = set()
51
+ self._inflight: dict[str, int] = {}
52
+
53
+ def admit(self, profile_id: str, is_active: Callable[[str], bool]) -> None:
54
+ if not is_active(profile_id):
55
+ raise ProfileAdmissionError("profile is inactive or closing for erasure")
56
+ with self._condition:
57
+ if profile_id in self._closing or not is_active(profile_id):
58
+ raise ProfileAdmissionError("profile is inactive or closing for erasure")
59
+ self._inflight[profile_id] = self._inflight.get(profile_id, 0) + 1
60
+
61
+ def release(self, profile_id: str) -> None:
62
+ with self._condition:
63
+ remaining = self._inflight.get(profile_id, 0) - 1
64
+ if remaining > 0:
65
+ self._inflight[profile_id] = remaining
66
+ else:
67
+ self._inflight.pop(profile_id, None)
68
+ self._condition.notify_all()
69
+
70
+ def close_and_drain(self, profile_id: str, timeout_seconds: float = 5.0) -> None:
71
+ deadline = time.monotonic() + timeout_seconds
72
+ with self._condition:
73
+ self._closing.add(profile_id)
74
+ while self._inflight.get(profile_id, 0):
75
+ remaining = deadline - time.monotonic()
76
+ if remaining <= 0:
77
+ raise LearningWriteBusyError("profile receipt drain deadline exceeded")
78
+ self._condition.wait(remaining)
79
+
80
+
81
+ def _canonical(payload: dict[str, Any]) -> tuple[str, str]:
82
+ encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
83
+ return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
84
+
85
+
86
+ def _now() -> str:
87
+ return datetime.now(timezone.utc).isoformat()
88
+
89
+
90
+ class AgentExperienceStore:
91
+ """Persist durable learning receipts without entering recall's lock domain."""
92
+
93
+ def __init__(
94
+ self, learning_db_path: str | Path, *, is_profile_active: Callable[[str], bool]
95
+ ) -> None:
96
+ self._path = Path(learning_db_path).resolve()
97
+ self._is_profile_active = is_profile_active
98
+ with _PROCESS_LOCKS_GUARD:
99
+ self._lock = _PROCESS_LOCKS.setdefault(str(self._path), threading.Lock())
100
+ self._gate = _PROFILE_GATES.setdefault(str(self._path), _ProfileAdmissionGate())
101
+
102
+ def record_experience(self, payload: dict[str, Any]) -> bool:
103
+ validate_agent_experience(payload)
104
+ profile_id = payload["profile_id"]
105
+ self._admit(profile_id)
106
+ _, digest = _canonical(payload)
107
+
108
+ def write(conn: sqlite3.Connection) -> bool:
109
+ self._assert_profile_open(conn, profile_id)
110
+ row = self._experience_row(payload, digest)
111
+ cursor = conn.execute(
112
+ "INSERT INTO agent_experiences ("
113
+ "profile_id, experience_id, occurred_at, task_class, project_scope, "
114
+ "route_json, verification_authority, verification_digest, "
115
+ "verification_reference, producer_claim, "
116
+ "terminal_status, failure_class, human_intervention, lessons, receipt_digest, "
117
+ "artifact_digests_json, payload_sha256, created_at"
118
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
119
+ "ON CONFLICT(profile_id, experience_id) DO NOTHING",
120
+ row,
121
+ )
122
+ if cursor.rowcount:
123
+ return True
124
+ existing = self._get_experience_conn(
125
+ conn, payload["profile_id"], payload["experience_id"]
126
+ )
127
+ if existing == payload:
128
+ return False
129
+ raise AgentExperienceConflictError("receipt ID already names different evidence")
130
+
131
+ try:
132
+ return self._write(write)
133
+ finally:
134
+ self._gate.release(profile_id)
135
+
136
+ def get_experience(self, profile_id: str, experience_id: str) -> dict[str, Any] | None:
137
+ conn = self._read_connection()
138
+ try:
139
+ return self._get_experience_conn(conn, profile_id, experience_id)
140
+ finally:
141
+ conn.close()
142
+
143
+ def create_cognitive_turn(self, payload: dict[str, Any]) -> bool:
144
+ validate_cognitive_turn(payload)
145
+ if payload["state"] != "open":
146
+ raise CognitiveTurnTransitionError("new cognitive turns must be open")
147
+ profile_id = payload["profile_id"]
148
+ self._admit(profile_id)
149
+ _, digest = _canonical(payload)
150
+
151
+ def write(conn: sqlite3.Connection) -> bool:
152
+ self._assert_profile_open(conn, profile_id)
153
+ cursor = conn.execute(
154
+ "INSERT INTO cognitive_turn_receipts ("
155
+ "profile_id, receipt_id, task_id, project_scope, query_digest, "
156
+ "fact_decisions_json, "
157
+ "state, outcome_json, payload_sha256, created_at, updated_at"
158
+ ") VALUES (?, ?, ?, ?, ?, ?, 'open', NULL, ?, ?, ?) "
159
+ "ON CONFLICT(profile_id, receipt_id) DO NOTHING",
160
+ (
161
+ payload["profile_id"],
162
+ payload["receipt_id"],
163
+ payload["task_id"],
164
+ payload["project_scope"],
165
+ payload["query_digest"],
166
+ self._json(payload["fact_decisions"]),
167
+ digest,
168
+ _now(),
169
+ _now(),
170
+ ),
171
+ )
172
+ if cursor.rowcount:
173
+ return True
174
+ existing = self._get_turn_conn(conn, payload["profile_id"], payload["receipt_id"])
175
+ if existing == payload:
176
+ return False
177
+ raise AgentExperienceConflictError("receipt ID already names different evidence")
178
+
179
+ try:
180
+ return self._write(write)
181
+ finally:
182
+ self._gate.release(profile_id)
183
+
184
+ def get_cognitive_turn(self, profile_id: str, receipt_id: str) -> dict[str, Any] | None:
185
+ conn = self._read_connection()
186
+ try:
187
+ return self._get_turn_conn(conn, profile_id, receipt_id)
188
+ finally:
189
+ conn.close()
190
+
191
+ def finalize_cognitive_turn(
192
+ self, profile_id: str, receipt_id: str, outcome: dict[str, Any]
193
+ ) -> bool:
194
+ self._admit(profile_id)
195
+
196
+ def write(conn: sqlite3.Connection) -> bool:
197
+ self._assert_profile_open(conn, profile_id)
198
+ current = self._get_turn_conn(conn, profile_id, receipt_id)
199
+ if current is None:
200
+ raise CognitiveTurnTransitionError("cognitive turn not found for this profile")
201
+ finalized = {**current, "state": "finalized", "outcome": outcome}
202
+ validate_cognitive_turn(finalized)
203
+ _, digest = _canonical(finalized)
204
+ cursor = conn.execute(
205
+ "UPDATE cognitive_turn_receipts SET state='finalized', outcome_json=?, "
206
+ "payload_sha256=?, updated_at=? WHERE profile_id=? AND receipt_id=? "
207
+ "AND state='open'",
208
+ (self._json(outcome), digest, _now(), profile_id, receipt_id),
209
+ )
210
+ if cursor.rowcount:
211
+ return True
212
+ existing = self._get_turn_conn(conn, profile_id, receipt_id)
213
+ if existing == finalized:
214
+ return False
215
+ raise AgentExperienceConflictError("cognitive turn finalized differently")
216
+
217
+ try:
218
+ return self._write(write)
219
+ finally:
220
+ self._gate.release(profile_id)
221
+
222
+ def erase_profile(self, profile_id: str, *, close_profile: bool = True) -> int:
223
+ """Purge all receipts, permanently closing admission for profile erasure.
224
+
225
+ A standalone learning reset can opt out of closing because the memory
226
+ profile remains active and should be able to collect new evidence.
227
+ """
228
+ if close_profile:
229
+ self._gate.close_and_drain(profile_id)
230
+
231
+ def erase(conn: sqlite3.Connection) -> int:
232
+ # This durable closure is checked inside every receipt write
233
+ # transaction. SQLite's writer serialization makes an erasure
234
+ # followed by a stale process's write fail closed across processes.
235
+ if close_profile:
236
+ conn.execute(
237
+ "INSERT INTO agent_receipt_profile_closures (profile_id, closed_at) "
238
+ "VALUES (?, ?) ON CONFLICT(profile_id) "
239
+ "DO UPDATE SET closed_at=excluded.closed_at",
240
+ (profile_id, _now()),
241
+ )
242
+ experience_count = conn.execute(
243
+ "DELETE FROM agent_experiences WHERE profile_id=?", (profile_id,)
244
+ ).rowcount
245
+ turn_count = conn.execute(
246
+ "DELETE FROM cognitive_turn_receipts WHERE profile_id=?", (profile_id,)
247
+ ).rowcount
248
+ residue = sum(
249
+ int(
250
+ conn.execute(
251
+ f"SELECT COUNT(*) FROM {table} WHERE profile_id=?", (profile_id,)
252
+ ).fetchone()[0]
253
+ )
254
+ for table in ("agent_experiences", "cognitive_turn_receipts")
255
+ )
256
+ if residue:
257
+ raise RuntimeError("learning receipt erasure left profile residue")
258
+ return experience_count + turn_count
259
+
260
+ return self._write(erase)
261
+
262
+ def _admit(self, profile_id: str) -> None:
263
+ self._gate.admit(profile_id, self._is_profile_active)
264
+
265
+ @staticmethod
266
+ def _assert_profile_open(conn: sqlite3.Connection, profile_id: str) -> None:
267
+ closed = conn.execute(
268
+ "SELECT 1 FROM agent_receipt_profile_closures WHERE profile_id=?", (profile_id,)
269
+ ).fetchone()
270
+ if closed is not None:
271
+ raise ProfileAdmissionError("profile is inactive or closing for erasure")
272
+
273
+ def _write(self, operation: Callable[[sqlite3.Connection], _T]) -> _T:
274
+ deadline = time.monotonic() + _WRITE_DEADLINE_SECONDS
275
+ if not self._lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
276
+ raise LearningWriteBusyError("learning receipt write deadline exceeded")
277
+ try:
278
+ while True:
279
+ conn: sqlite3.Connection | None = None
280
+ try:
281
+ conn = sqlite3.connect(str(self._path), timeout=0, isolation_level=None)
282
+ conn.row_factory = sqlite3.Row
283
+ conn.execute("PRAGMA journal_mode=WAL")
284
+ conn.execute("PRAGMA synchronous=NORMAL")
285
+ conn.execute("PRAGMA busy_timeout=0")
286
+ conn.execute("BEGIN IMMEDIATE")
287
+ result = operation(conn)
288
+ conn.execute("COMMIT")
289
+ return result
290
+ except sqlite3.OperationalError as exc:
291
+ if conn is not None and conn.in_transaction:
292
+ conn.execute("ROLLBACK")
293
+ if not self._is_busy(exc) or time.monotonic() >= deadline:
294
+ if self._is_busy(exc):
295
+ raise LearningWriteBusyError(
296
+ "learning receipt write deadline exceeded"
297
+ ) from exc
298
+ raise
299
+ time.sleep(min(deadline - time.monotonic(), 0.01 + random.random() * 0.02))
300
+ except Exception:
301
+ if conn is not None and conn.in_transaction:
302
+ conn.execute("ROLLBACK")
303
+ raise
304
+ finally:
305
+ if conn is not None:
306
+ conn.close()
307
+ finally:
308
+ self._lock.release()
309
+
310
+ def _read_connection(self) -> sqlite3.Connection:
311
+ conn = sqlite3.connect(str(self._path), timeout=0.5)
312
+ conn.row_factory = sqlite3.Row
313
+ conn.execute("PRAGMA journal_mode=WAL")
314
+ conn.execute("PRAGMA busy_timeout=500")
315
+ return conn
316
+
317
+ @staticmethod
318
+ def _is_busy(exc: sqlite3.OperationalError) -> bool:
319
+ message = str(exc).lower()
320
+ return "locked" in message or "busy" in message
321
+
322
+ @staticmethod
323
+ def _json(value: Any) -> str:
324
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
325
+
326
+ def _experience_row(self, payload: dict[str, Any], digest: str) -> tuple[Any, ...]:
327
+ verification = payload["verification"]
328
+ return (
329
+ payload["profile_id"],
330
+ payload["experience_id"],
331
+ payload["occurred_at"],
332
+ payload["task_class"],
333
+ payload["project_scope"],
334
+ self._json(payload["route"]),
335
+ verification["authority"],
336
+ verification["evidence_digest"],
337
+ verification.get("reference"),
338
+ payload["producer_claim"],
339
+ payload["terminal_status"],
340
+ payload.get("failure_class"),
341
+ None if "human_intervention" not in payload else int(payload["human_intervention"]),
342
+ payload.get("lessons"),
343
+ payload.get("receipt_digest"),
344
+ self._json(payload.get("artifact_digests", [])),
345
+ digest,
346
+ _now(),
347
+ )
348
+
349
+ def _get_experience_conn(
350
+ self, conn: sqlite3.Connection, profile_id: str, experience_id: str
351
+ ) -> dict[str, Any] | None:
352
+ row = conn.execute(
353
+ "SELECT * FROM agent_experiences WHERE profile_id=? AND experience_id=?",
354
+ (profile_id, experience_id),
355
+ ).fetchone()
356
+ if row is None:
357
+ return None
358
+ result = {
359
+ "experience_id": row["experience_id"],
360
+ "profile_id": row["profile_id"],
361
+ "occurred_at": row["occurred_at"],
362
+ "task_class": row["task_class"],
363
+ "project_scope": row["project_scope"],
364
+ "route": json.loads(row["route_json"]),
365
+ "verification": {
366
+ "authority": row["verification_authority"],
367
+ "evidence_digest": row["verification_digest"],
368
+ },
369
+ "producer_claim": row["producer_claim"],
370
+ "terminal_status": row["terminal_status"],
371
+ }
372
+ if row["verification_reference"] is not None:
373
+ result["verification"]["reference"] = row["verification_reference"]
374
+ for key in ("failure_class", "lessons", "receipt_digest"):
375
+ if row[key] is not None:
376
+ result[key] = row[key]
377
+ if row["human_intervention"] is not None:
378
+ result["human_intervention"] = bool(row["human_intervention"])
379
+ artifacts = json.loads(row["artifact_digests_json"])
380
+ if artifacts:
381
+ result["artifact_digests"] = artifacts
382
+ return result
383
+
384
+ def _get_turn_conn(
385
+ self, conn: sqlite3.Connection, profile_id: str, receipt_id: str
386
+ ) -> dict[str, Any] | None:
387
+ row = conn.execute(
388
+ "SELECT * FROM cognitive_turn_receipts WHERE profile_id=? AND receipt_id=?",
389
+ (profile_id, receipt_id),
390
+ ).fetchone()
391
+ if row is None:
392
+ return None
393
+ result = {
394
+ "receipt_id": row["receipt_id"],
395
+ "task_id": row["task_id"],
396
+ "profile_id": row["profile_id"],
397
+ "project_scope": row["project_scope"],
398
+ "query_digest": row["query_digest"],
399
+ "fact_decisions": json.loads(row["fact_decisions_json"]),
400
+ "state": row["state"],
401
+ }
402
+ if row["outcome_json"] is not None:
403
+ result["outcome"] = json.loads(row["outcome_json"])
404
+ return result
405
+
406
+
407
+ def purge_profile_receipts(
408
+ learning_db_path: str | Path, profile_id: str, *, close_profile: bool = True
409
+ ) -> int:
410
+ """Purge M040 evidence for a profile before its memory profile is deleted.
411
+
412
+ A database from an older release simply has no receipt tables and is
413
+ already clean. A half-present schema is corruption and must stop profile
414
+ deletion rather than silently strand unerasable evidence.
415
+ """
416
+ path = Path(learning_db_path)
417
+ if not path.exists():
418
+ return 0
419
+ with sqlite3.connect(path) as conn:
420
+ tables = {
421
+ row[0]
422
+ for row in conn.execute(
423
+ "SELECT name FROM sqlite_master WHERE type='table' "
424
+ "AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
425
+ "'agent_receipt_profile_closures')"
426
+ )
427
+ }
428
+ if not tables:
429
+ return 0
430
+ expected = {
431
+ "agent_experiences", "cognitive_turn_receipts", "agent_receipt_profile_closures"
432
+ }
433
+ if tables != expected:
434
+ raise sqlite3.OperationalError("incomplete Agent Experience receipt schema")
435
+ return AgentExperienceStore(
436
+ path, is_profile_active=lambda _: True
437
+ ).erase_profile(profile_id, close_profile=close_profile)
438
+
439
+
440
+ def get_profile_receipt_summary(
441
+ learning_db_path: str | Path, profile_id: str
442
+ ) -> dict[str, Any]:
443
+ """Return the small, read-only receipt view used by every host surface.
444
+
445
+ This intentionally performs only indexed aggregates against ``learning.db``.
446
+ It is safe to call from MCP, CLI, HTTP, and the dashboard without opening a
447
+ memory engine or entering the recall/remember writer domains.
448
+ """
449
+ unavailable: dict[str, Any] = {
450
+ "is_real": False,
451
+ "availability": "unavailable",
452
+ "experiences_total": 0,
453
+ "turns_total": 0,
454
+ "turns_by_state": {},
455
+ "claimed_evidence_experiences": 0,
456
+ "source": "learning.db:agent_experiences,cognitive_turn_receipts",
457
+ }
458
+ path = Path(learning_db_path)
459
+ if not path.exists():
460
+ return unavailable
461
+ try:
462
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=0.5)
463
+ try:
464
+ experience = conn.execute(
465
+ "SELECT COUNT(*) FROM agent_experiences WHERE profile_id=?", (profile_id,)
466
+ ).fetchone()
467
+ claimed = conn.execute(
468
+ "SELECT COUNT(*) FROM agent_experiences "
469
+ "WHERE profile_id=? AND verification_authority != 'bounded_loop_receipt'",
470
+ (profile_id,),
471
+ ).fetchone()
472
+ rows = conn.execute(
473
+ "SELECT state, COUNT(*) FROM cognitive_turn_receipts "
474
+ "WHERE profile_id=? GROUP BY state",
475
+ (profile_id,),
476
+ ).fetchall()
477
+ finally:
478
+ conn.close()
479
+ except sqlite3.Error:
480
+ return unavailable
481
+ turns_by_state = {str(state): int(count) for state, count in rows}
482
+ return {
483
+ **unavailable,
484
+ "is_real": True,
485
+ "availability": "available",
486
+ "experiences_total": int(experience[0]) if experience else 0,
487
+ "claimed_evidence_experiences": int(claimed[0]) if claimed else 0,
488
+ "turns_total": sum(turns_by_state.values()),
489
+ "turns_by_state": turns_by_state,
490
+ }