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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -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 +5 -4
- 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/ui.py +7 -2
- package/src/superlocalmemory/server/unified_daemon.py +37 -4
- 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/index.html +2 -2
- 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
|
@@ -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="
|
|
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 (
|
|
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
|
+
}
|