superlocalmemory 4.0.0 → 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 (83) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -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 +5 -4
  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/ui.py +7 -2
  69. package/src/superlocalmemory/server/unified_daemon.py +37 -4
  70. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  71. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  72. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  73. package/src/superlocalmemory/storage/database.py +189 -34
  74. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  75. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  76. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/schema.py +4 -0
  79. package/src/superlocalmemory/ui/index.html +2 -2
  80. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  81. package/src/superlocalmemory/ui/js/brain.js +57 -1
  82. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  83. package/src/superlocalmemory/ui/js/od-settings.js +8 -1
@@ -43,7 +43,7 @@ sys.path = [p for p in sys.path if p not in ("", _script_dir)]
43
43
  try:
44
44
  from fastapi import FastAPI
45
45
  from fastapi.staticfiles import StaticFiles
46
- from fastapi.responses import HTMLResponse
46
+ from fastapi.responses import HTMLResponse, RedirectResponse
47
47
  from fastapi.middleware.cors import CORSMiddleware
48
48
  from fastapi.middleware.gzip import GZipMiddleware
49
49
  import uvicorn
@@ -73,7 +73,7 @@ def create_app() -> FastAPI:
73
73
  """Create and configure the FastAPI application."""
74
74
  application = FastAPI(
75
75
  title="SuperLocalMemory V4 UI Server",
76
- description="Memory Dashboard with V4 Engine, Trust, Learning, and Compliance",
76
+ description="Governed memory dashboard with trust, learning, and compliance controls.",
77
77
  version=SLM_VERSION,
78
78
  docs_url="/api/docs",
79
79
  redoc_url="/api/redoc",
@@ -233,6 +233,11 @@ def create_app() -> FastAPI:
233
233
  )
234
234
  return index_path.read_text()
235
235
 
236
+ @application.get("/favicon.ico", include_in_schema=False)
237
+ async def favicon():
238
+ """Serve the packaged SVG icon for browsers that request favicon.ico."""
239
+ return RedirectResponse(url="/static/favicon.svg", status_code=307)
240
+
236
241
  @application.get("/health")
237
242
  async def health_check():
238
243
  """Health check endpoint."""
@@ -3467,7 +3467,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
3467
3467
  _data_io_mod.ws_manager = ws_manager
3468
3468
 
3469
3469
  # Root page
3470
- from fastapi.responses import HTMLResponse, JSONResponse
3470
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
3471
3471
 
3472
3472
  # v3.4.23: /api/version — dashboard polls this to detect daemon upgrades
3473
3473
  # and auto-reload stale tabs (see ui/js/core.js::checkVersionFingerprint).
@@ -3531,6 +3531,11 @@ def _register_dashboard_routes(application: FastAPI) -> None:
3531
3531
  html = index_path.read_text()
3532
3532
  return html.replace("__SLM_VERSION__", _SLM_VERSION)
3533
3533
 
3534
+ @application.get("/favicon.ico", include_in_schema=False)
3535
+ async def favicon():
3536
+ """Serve the packaged SVG icon for browsers that request favicon.ico."""
3537
+ return RedirectResponse(url="/static/favicon.svg", status_code=307)
3538
+
3534
3539
  def _register_daemon_routes(application: FastAPI) -> None:
3535
3540
  """Add daemon-specific routes for CLI integration."""
3536
3541
  global _last_activity
@@ -3695,7 +3700,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
3695
3700
  "active_profile": profile_snapshot.profile_id,
3696
3701
  "profile_generation": profile_snapshot.generation,
3697
3702
  # Wave-3: operational failure counts (visible to all team members)
3698
- **_ops_failure_counts(engine),
3703
+ **_ops_failure_counts(engine, application),
3699
3704
  # issue #107: does this daemon's *imported* code still match the
3700
3705
  # installed distribution? ``version`` above reports what this
3701
3706
  # process loaded, which is self-consistent and therefore cannot
@@ -3724,6 +3729,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
3724
3729
  include_shared: bool | None = None,
3725
3730
  window: str = "",
3726
3731
  as_of: str = "",
3732
+ known_as_of: str = "",
3733
+ valid_at: str = "",
3734
+ include_unknown: bool = False,
3727
3735
  ):
3728
3736
  _update_activity()
3729
3737
  search_query = q or query # Accept both ?q= and ?query= for compatibility
@@ -3749,6 +3757,25 @@ def _register_daemon_routes(application: FastAPI) -> None:
3749
3757
  as_of = _as_of_norm
3750
3758
  else:
3751
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
3752
3779
  # v3.8.2: resolve the client-driven-agentic default now so the concrete
3753
3780
  # bool drives BOTH the full-recall semaphore below and engine.recall().
3754
3781
  from superlocalmemory.core.recall_pipeline import resolve_hot_path_fast
@@ -3808,6 +3835,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
3808
3835
  include_shared=include_shared,
3809
3836
  window=window or None,
3810
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,
3811
3841
  ),
3812
3842
  )
3813
3843
  _budget = _recall_budget_s()
@@ -4271,7 +4301,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4271
4301
  # "Optimizing memory…" line from this. Defaults to idle before start.
4272
4302
  "self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
4273
4303
  # Wave-3: operational failure counts (dead-letter, degraded, stalled)
4274
- **_ops_failure_counts(engine),
4304
+ **_ops_failure_counts(engine, application),
4275
4305
  }
4276
4306
 
4277
4307
  @application.get("/api/v3/components")
@@ -4870,7 +4900,7 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
4870
4900
  )
4871
4901
 
4872
4902
 
4873
- def _ops_failure_counts(engine) -> dict:
4903
+ def _ops_failure_counts(engine, application) -> dict:
4874
4904
  """Return Wave-3 operational failure counts for /status and /health.
4875
4905
 
4876
4906
  Always returns a dict (never raises). Counts default to 0 on any error.
@@ -4885,6 +4915,9 @@ def _ops_failure_counts(engine) -> dict:
4885
4915
  "writer_stalled_op_id": None,
4886
4916
  "writer_stalled_age_s": None,
4887
4917
  }
4918
+ # ``application`` is explicitly passed by the route handlers. There is no
4919
+ # module-global FastAPI app; relying on one silently returned all-zero
4920
+ # counters whenever this helper caught the resulting NameError.
4888
4921
  # Writer stall info from canonical coordinator
4889
4922
  try:
4890
4923
  writer_runtime = getattr(application.state, "canonical_remember_runtime", None)
@@ -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
+ }