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.
- package/CHANGELOG.md +24 -0
- package/README.md +10 -11
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +60 -1
- package/src/superlocalmemory/cli/main.py +24 -1
- package/src/superlocalmemory/compliance/gdpr.py +104 -73
- package/src/superlocalmemory/contracts/__init__.py +1 -0
- package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
- package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
- package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
- package/src/superlocalmemory/contracts/v402.py +62 -0
- package/src/superlocalmemory/core/engine.py +10 -0
- package/src/superlocalmemory/core/recall_pipeline.py +6 -0
- package/src/superlocalmemory/core/recall_worker.py +12 -0
- package/src/superlocalmemory/core/worker_pool.py +12 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
- package/src/superlocalmemory/hooks/session_registry.py +136 -3
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
- package/src/superlocalmemory/integrations/__init__.py +1 -0
- package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
- package/src/superlocalmemory/learning/database.py +21 -14
- package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
- package/src/superlocalmemory/mcp/server.py +5 -0
- package/src/superlocalmemory/mcp/tools_brain.py +132 -0
- package/src/superlocalmemory/mcp/tools_core.py +25 -6
- package/src/superlocalmemory/mcp/tools_v3.py +16 -2
- package/src/superlocalmemory/retrieval/engine.py +43 -1
- package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
- package/src/superlocalmemory/server/routes/brain.py +206 -1
- package/src/superlocalmemory/server/routes/helpers.py +53 -35
- package/src/superlocalmemory/server/routes/v3_api.py +118 -11
- package/src/superlocalmemory/server/unified_daemon.py +25 -0
- 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 +490 -0
- package/src/superlocalmemory/storage/database.py +189 -34
- package/src/superlocalmemory/storage/migration_runner.py +8 -0
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
- package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/schema.py +4 -0
- package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
- package/src/superlocalmemory/ui/js/brain.js +57 -1
- package/src/superlocalmemory/ui/js/od-brain.js +114 -40
- 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 (
|
|
24
|
+
#: of the latest migration (M040). Increment when adding new migrations or
|
|
25
25
|
#: table-level breaking changes.
|
|
26
|
-
SUPPORTED_SCHEMA_VERSION: int =
|
|
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
|
+
}
|