superlocalmemory 4.0.3 → 4.0.5
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 +55 -0
- package/README.md +19 -13
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- 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 +3 -2
- 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 +5 -4
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +7 -6
- 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 +3 -2
- 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 +5 -4
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +348 -0
- package/src/superlocalmemory/cli/commands.py +82 -25
- package/src/superlocalmemory/cli/main.py +12 -0
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +185 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/mcp/profiles.py +25 -7
- package/src/superlocalmemory/mcp/server.py +7 -2
- package/src/superlocalmemory/mcp/tools_brain.py +138 -9
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +21 -1
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/storage/_migration_internals.py +8 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +26 -4
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +194 -24
- package/src/superlocalmemory/storage/external_evidence.py +359 -0
- package/src/superlocalmemory/storage/migration_runner.py +12 -0
- package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-brain.js +44 -19
|
@@ -569,6 +569,62 @@ class DatabaseManager:
|
|
|
569
569
|
created_at=d["created_at"],
|
|
570
570
|
)
|
|
571
571
|
|
|
572
|
+
def insert_fact_immutable(self, fact: AtomicFact) -> str:
|
|
573
|
+
"""Insert one known-new fact without content deduplication or replacement.
|
|
574
|
+
|
|
575
|
+
Reviewed correction successors require a caller-chosen immutable
|
|
576
|
+
identity. Unlike normal remember ingestion, equal content must not
|
|
577
|
+
reinforce an existing row, and an occupied ID must abort the enclosing
|
|
578
|
+
transaction rather than overwrite history.
|
|
579
|
+
"""
|
|
580
|
+
scope = getattr(fact, "scope", None) or "personal"
|
|
581
|
+
shared = _jd(getattr(fact, "shared_with", None))
|
|
582
|
+
self.execute(
|
|
583
|
+
"""INSERT INTO atomic_facts
|
|
584
|
+
(fact_id, memory_id, profile_id, content, fact_type,
|
|
585
|
+
entities_json, canonical_entities_json,
|
|
586
|
+
observation_date, referenced_date, interval_start, interval_end,
|
|
587
|
+
confidence, importance, evidence_count, access_count,
|
|
588
|
+
source_turn_ids_json, session_id,
|
|
589
|
+
embedding, fisher_mean, fisher_variance,
|
|
590
|
+
lifecycle, langevin_position,
|
|
591
|
+
emotional_valence, emotional_arousal, signal_type, created_at,
|
|
592
|
+
scope, shared_with)
|
|
593
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
594
|
+
(
|
|
595
|
+
fact.fact_id,
|
|
596
|
+
fact.memory_id,
|
|
597
|
+
fact.profile_id,
|
|
598
|
+
fact.content,
|
|
599
|
+
fact.fact_type.value,
|
|
600
|
+
json.dumps(fact.entities),
|
|
601
|
+
json.dumps(fact.canonical_entities),
|
|
602
|
+
fact.observation_date,
|
|
603
|
+
fact.referenced_date,
|
|
604
|
+
fact.interval_start,
|
|
605
|
+
fact.interval_end,
|
|
606
|
+
fact.confidence,
|
|
607
|
+
fact.importance,
|
|
608
|
+
fact.evidence_count,
|
|
609
|
+
fact.access_count,
|
|
610
|
+
json.dumps(fact.source_turn_ids),
|
|
611
|
+
fact.session_id,
|
|
612
|
+
_jd(fact.embedding),
|
|
613
|
+
_jd(fact.fisher_mean),
|
|
614
|
+
_jd(fact.fisher_variance),
|
|
615
|
+
fact.lifecycle.value,
|
|
616
|
+
_jd(fact.langevin_position),
|
|
617
|
+
fact.emotional_valence,
|
|
618
|
+
fact.emotional_arousal,
|
|
619
|
+
fact.signal_type.value,
|
|
620
|
+
fact.created_at,
|
|
621
|
+
scope,
|
|
622
|
+
shared,
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
626
|
+
return fact.fact_id
|
|
627
|
+
|
|
572
628
|
def set_pinned(self, fact_id: str, pinned: bool) -> None:
|
|
573
629
|
"""Set or clear the pinned flag on a fact (v3.4.65 core-memory)."""
|
|
574
630
|
self.execute(
|
|
@@ -604,7 +660,23 @@ class DatabaseManager:
|
|
|
604
660
|
"ORDER BY importance DESC",
|
|
605
661
|
(*params,),
|
|
606
662
|
)
|
|
607
|
-
|
|
663
|
+
facts = [self._row_to_fact(r) for r in rows]
|
|
664
|
+
if not facts:
|
|
665
|
+
return facts
|
|
666
|
+
try:
|
|
667
|
+
blocked = self.get_nonapplied_correction_successor_ids(
|
|
668
|
+
[fact.fact_id for fact in facts],
|
|
669
|
+
profile_id,
|
|
670
|
+
include_global=include_global,
|
|
671
|
+
include_shared=include_shared,
|
|
672
|
+
)
|
|
673
|
+
except Exception as exc:
|
|
674
|
+
logger.warning("Pinned correction admission lookup failed: %s", exc)
|
|
675
|
+
return []
|
|
676
|
+
if not isinstance(blocked, set):
|
|
677
|
+
logger.warning("Pinned correction admission returned malformed data")
|
|
678
|
+
return []
|
|
679
|
+
return [fact for fact in facts if fact.fact_id not in blocked]
|
|
608
680
|
|
|
609
681
|
def _has_archive_status(self) -> bool:
|
|
610
682
|
"""Whether atomic_facts carries the M011 ``archive_status`` column.
|
|
@@ -1647,12 +1719,14 @@ class DatabaseManager:
|
|
|
1647
1719
|
def invalidate_fact_temporal(
|
|
1648
1720
|
self, fact_id: str, invalidated_by: str,
|
|
1649
1721
|
invalidation_reason: str,
|
|
1722
|
+
*,
|
|
1723
|
+
event_valid_until: str | None = None,
|
|
1650
1724
|
) -> None:
|
|
1651
1725
|
"""Mark a fact as invalidated, preserving bi-temporal independence.
|
|
1652
1726
|
|
|
1653
|
-
- valid_until (event-time):
|
|
1654
|
-
real
|
|
1655
|
-
|
|
1727
|
+
- valid_until (event-time): changed only when a reviewer supplies an
|
|
1728
|
+
independently validated real-world boundary. Review time, source
|
|
1729
|
+
timestamps, and a detector's conclusion are not a valid substitute.
|
|
1656
1730
|
- system_expired_at (transaction-time): when the system learned the
|
|
1657
1731
|
fact was invalid — always set to now.
|
|
1658
1732
|
|
|
@@ -1666,29 +1740,12 @@ class DatabaseManager:
|
|
|
1666
1740
|
from datetime import datetime as _dt
|
|
1667
1741
|
now = _dt.now(UTC).isoformat()
|
|
1668
1742
|
|
|
1669
|
-
# Resolve the event-time boundary from the fact's referenced_date.
|
|
1670
|
-
# Falls back to the current valid_until (which may already be set),
|
|
1671
|
-
# and ultimately to now if neither is available.
|
|
1672
|
-
fact_rows = self.execute(
|
|
1673
|
-
"SELECT referenced_date FROM atomic_facts WHERE fact_id = ?",
|
|
1674
|
-
(fact_id,),
|
|
1675
|
-
)
|
|
1676
|
-
referenced_date = dict(fact_rows[0]).get("referenced_date") if fact_rows else None
|
|
1677
|
-
|
|
1678
|
-
tv_rows = self.execute(
|
|
1679
|
-
"SELECT valid_until FROM fact_temporal_validity WHERE fact_id = ?",
|
|
1680
|
-
(fact_id,),
|
|
1681
|
-
)
|
|
1682
|
-
existing_valid_until = dict(tv_rows[0]).get("valid_until") if tv_rows else None
|
|
1683
|
-
|
|
1684
|
-
valid_until = referenced_date or existing_valid_until or now
|
|
1685
|
-
|
|
1686
1743
|
self.execute(
|
|
1687
1744
|
"UPDATE fact_temporal_validity "
|
|
1688
|
-
"SET valid_until = ?, system_expired_at = ?, "
|
|
1745
|
+
"SET valid_until = COALESCE(?, valid_until), system_expired_at = ?, "
|
|
1689
1746
|
" invalidated_by = ?, invalidation_reason = ? "
|
|
1690
|
-
"WHERE fact_id = ?",
|
|
1691
|
-
(
|
|
1747
|
+
"WHERE fact_id = ? AND system_expired_at IS NULL",
|
|
1748
|
+
(event_valid_until, now, invalidated_by, invalidation_reason, fact_id),
|
|
1692
1749
|
)
|
|
1693
1750
|
|
|
1694
1751
|
def get_valid_facts(self, profile_id: str) -> list[str]:
|
|
@@ -1804,6 +1861,119 @@ class DatabaseManager:
|
|
|
1804
1861
|
invalid.add(dict(r)["fact_id"])
|
|
1805
1862
|
return invalid
|
|
1806
1863
|
|
|
1864
|
+
def get_nonapplied_correction_successor_ids(
|
|
1865
|
+
self,
|
|
1866
|
+
fact_ids: list[str],
|
|
1867
|
+
profile_id: str,
|
|
1868
|
+
*,
|
|
1869
|
+
include_global: bool = False,
|
|
1870
|
+
include_shared: bool = False,
|
|
1871
|
+
) -> set[str]:
|
|
1872
|
+
"""Return candidate successors that are not current review truth.
|
|
1873
|
+
|
|
1874
|
+
M042 is optional for older databases. Its absence is safe because
|
|
1875
|
+
canonical proposal never commits a successor unless the same
|
|
1876
|
+
transaction also writes M042. Once present, a read failure must reach
|
|
1877
|
+
the retrieval fail-closed boundary rather than be converted to empty.
|
|
1878
|
+
"""
|
|
1879
|
+
if not fact_ids:
|
|
1880
|
+
return set()
|
|
1881
|
+
present = self.execute(
|
|
1882
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
|
|
1883
|
+
)
|
|
1884
|
+
if not present:
|
|
1885
|
+
return set()
|
|
1886
|
+
scope_where, scope_params = _scope_where(
|
|
1887
|
+
profile_id,
|
|
1888
|
+
include_global=include_global,
|
|
1889
|
+
include_shared=include_shared,
|
|
1890
|
+
prefix="f",
|
|
1891
|
+
)
|
|
1892
|
+
inadmissible: set[str] = set()
|
|
1893
|
+
for start in range(0, len(fact_ids), 900):
|
|
1894
|
+
batch = fact_ids[start:start + 900]
|
|
1895
|
+
placeholders = ",".join("?" for _ in batch)
|
|
1896
|
+
rows = self.execute(
|
|
1897
|
+
"SELECT c.successor_fact_id FROM correction_cases c "
|
|
1898
|
+
"JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
|
|
1899
|
+
f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
|
|
1900
|
+
"AND c.profile_id=f.profile_id "
|
|
1901
|
+
"AND c.status IN ('proposed', 'rejected', 'rolled_back')",
|
|
1902
|
+
(*batch, *scope_params),
|
|
1903
|
+
)
|
|
1904
|
+
inadmissible.update(str(row["successor_fact_id"]) for row in rows)
|
|
1905
|
+
return inadmissible
|
|
1906
|
+
|
|
1907
|
+
def get_correction_inadmissible_fact_ids(
|
|
1908
|
+
self,
|
|
1909
|
+
fact_ids: list[str],
|
|
1910
|
+
profile_id: str,
|
|
1911
|
+
as_of: str | None = None,
|
|
1912
|
+
*,
|
|
1913
|
+
include_global: bool = False,
|
|
1914
|
+
include_shared: bool = False,
|
|
1915
|
+
) -> set[str]:
|
|
1916
|
+
"""Return current-lifecycle exclusions with one bounded SQLite read.
|
|
1917
|
+
|
|
1918
|
+
Recall needs both sides of reviewed correction truth: an expired
|
|
1919
|
+
predecessor and a successor whose case is not applied. The older
|
|
1920
|
+
public helpers preserve their focused contracts, but invoking them
|
|
1921
|
+
consecutively opened two SQLite connections on every candidate stage.
|
|
1922
|
+
This read-model helper uses one connection and one UNION query while
|
|
1923
|
+
retaining the same profile/scope and historical ``as_of`` semantics.
|
|
1924
|
+
It is intentionally read-only and does not cache lifecycle state.
|
|
1925
|
+
"""
|
|
1926
|
+
if not fact_ids:
|
|
1927
|
+
return set()
|
|
1928
|
+
scope_where, scope_params = _scope_where(
|
|
1929
|
+
profile_id,
|
|
1930
|
+
include_global=include_global,
|
|
1931
|
+
include_shared=include_shared,
|
|
1932
|
+
prefix="f",
|
|
1933
|
+
)
|
|
1934
|
+
inadmissible: set[str] = set()
|
|
1935
|
+
with self.raw_connection() as conn:
|
|
1936
|
+
correction_table = conn.execute(
|
|
1937
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
|
|
1938
|
+
).fetchone()
|
|
1939
|
+
for start in range(0, len(fact_ids), 900):
|
|
1940
|
+
batch = fact_ids[start:start + 900]
|
|
1941
|
+
placeholders = ",".join("?" for _ in batch)
|
|
1942
|
+
temporal_conditions = (
|
|
1943
|
+
"AND tv.system_expired_at IS NOT NULL "
|
|
1944
|
+
+ ("AND tv.system_expired_at <= ?" if as_of is not None else "")
|
|
1945
|
+
)
|
|
1946
|
+
temporal_sql = (
|
|
1947
|
+
"SELECT tv.fact_id AS fact_id "
|
|
1948
|
+
"FROM fact_temporal_validity tv "
|
|
1949
|
+
"JOIN atomic_facts f ON f.fact_id=tv.fact_id "
|
|
1950
|
+
f"WHERE tv.fact_id IN ({placeholders}) AND {scope_where} "
|
|
1951
|
+
"AND tv.profile_id=f.profile_id "
|
|
1952
|
+
f"{temporal_conditions}"
|
|
1953
|
+
)
|
|
1954
|
+
temporal_params: tuple[Any, ...] = (
|
|
1955
|
+
*batch,
|
|
1956
|
+
*scope_params,
|
|
1957
|
+
*((as_of,) if as_of is not None else ()),
|
|
1958
|
+
)
|
|
1959
|
+
if correction_table is None:
|
|
1960
|
+
rows = conn.execute(temporal_sql, temporal_params).fetchall()
|
|
1961
|
+
else:
|
|
1962
|
+
correction_sql = (
|
|
1963
|
+
"SELECT c.successor_fact_id AS fact_id "
|
|
1964
|
+
"FROM correction_cases c "
|
|
1965
|
+
"JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
|
|
1966
|
+
f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
|
|
1967
|
+
"AND c.profile_id=f.profile_id "
|
|
1968
|
+
"AND c.status IN ('proposed', 'rejected', 'rolled_back')"
|
|
1969
|
+
)
|
|
1970
|
+
rows = conn.execute(
|
|
1971
|
+
f"{temporal_sql} UNION {correction_sql}",
|
|
1972
|
+
(*temporal_params, *batch, *scope_params),
|
|
1973
|
+
).fetchall()
|
|
1974
|
+
inadmissible.update(str(row["fact_id"]) for row in rows)
|
|
1975
|
+
return inadmissible
|
|
1976
|
+
|
|
1807
1977
|
def get_strict_temporal_inadmissible_fact_ids(
|
|
1808
1978
|
self,
|
|
1809
1979
|
fact_ids: list[str],
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Typed storage for versioned, observation-only MCP evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from superlocalmemory.storage.agent_experience import (
|
|
16
|
+
_PROCESS_LOCKS,
|
|
17
|
+
_PROCESS_LOCKS_GUARD,
|
|
18
|
+
_PROFILE_GATES,
|
|
19
|
+
LearningWriteBusyError,
|
|
20
|
+
ProfileAdmissionError,
|
|
21
|
+
_ProfileAdmissionGate,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_CONTRACT = "bounded-loops.dev/slm-bridge/v1"
|
|
25
|
+
_SHA256 = re.compile(r"\Asha256:[a-f0-9]{64}\Z")
|
|
26
|
+
_IDENTIFIER = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
27
|
+
_RUN_STATES = frozenset({"SUCCEEDED", "FAILED", "HALTED", "CANCELLED", "EXPIRED"})
|
|
28
|
+
_OUTCOMES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
|
|
29
|
+
_MAX_NODES = 256
|
|
30
|
+
_MAX_ARTIFACTS_PER_NODE = 64
|
|
31
|
+
_MAX_ARTIFACTS_TOTAL = 2_048
|
|
32
|
+
_MAX_NODES_JSON_BYTES = 64 * 1024
|
|
33
|
+
_MAX_TIMESTAMP_BYTES = 128
|
|
34
|
+
_MAX_RECEIPT_SEQUENCE = (1 << 63) - 1
|
|
35
|
+
_INSERT = (
|
|
36
|
+
"INSERT INTO external_evidence_receipts (profile_id, contract_id, workspace_id, "
|
|
37
|
+
"run_ref, run_id, outcome, run_state, demonstration, "
|
|
38
|
+
"eligible_for_learning, terminal_at, graph_digest, plan_digest, "
|
|
39
|
+
"policy_digest, receipt_sequence, receipt_head_digest, receipt_trust, "
|
|
40
|
+
"nodes_json, artifact_digests_json, payload_sha256, observed_at) "
|
|
41
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
42
|
+
"ON CONFLICT(profile_id, contract_id, workspace_id, run_ref) DO NOTHING"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExternalEvidenceConflictError(ValueError):
|
|
47
|
+
"""A stable external run address produced a different terminal receipt head."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExternalEvidenceValidationError(ValueError):
|
|
51
|
+
"""An external evidence document does not satisfy the public v1 contract."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExternalEvidenceStore:
|
|
55
|
+
"""Persist evidence without entering SLM's memory/recall lock domain."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str | Path, *, is_profile_active: Callable[[str], bool]) -> None:
|
|
58
|
+
self._path = Path(path)
|
|
59
|
+
self._is_profile_active = is_profile_active
|
|
60
|
+
resolved = str(self._path.resolve())
|
|
61
|
+
with _PROCESS_LOCKS_GUARD:
|
|
62
|
+
self._lock = _PROCESS_LOCKS.setdefault(resolved, threading.Lock())
|
|
63
|
+
self._gate = _PROFILE_GATES.setdefault(resolved, _ProfileAdmissionGate())
|
|
64
|
+
|
|
65
|
+
def record(self, payload: dict[str, Any]) -> bool:
|
|
66
|
+
_validate(payload)
|
|
67
|
+
profile_id = payload["profile_id"]
|
|
68
|
+
self._gate.admit(profile_id, self._is_profile_active)
|
|
69
|
+
digest = _payload_digest(payload)
|
|
70
|
+
deadline = time.monotonic() + 0.90
|
|
71
|
+
if not self._lock.acquire(timeout=0.90):
|
|
72
|
+
self._gate.release(profile_id)
|
|
73
|
+
raise LearningWriteBusyError("external evidence write deadline exceeded")
|
|
74
|
+
try:
|
|
75
|
+
while True:
|
|
76
|
+
conn: sqlite3.Connection | None = None
|
|
77
|
+
try:
|
|
78
|
+
conn = sqlite3.connect(str(self._path), timeout=0, isolation_level=None)
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
81
|
+
conn.execute("PRAGMA busy_timeout=0")
|
|
82
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
83
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
84
|
+
_assert_profile_open(conn, profile_id)
|
|
85
|
+
row = _row(payload, digest)
|
|
86
|
+
cursor = conn.execute(_INSERT, row)
|
|
87
|
+
if cursor.rowcount:
|
|
88
|
+
conn.execute("COMMIT")
|
|
89
|
+
return True
|
|
90
|
+
existing = _get_conn(
|
|
91
|
+
conn,
|
|
92
|
+
profile_id,
|
|
93
|
+
payload["contract"],
|
|
94
|
+
payload["workspace_id"],
|
|
95
|
+
payload["run_ref"],
|
|
96
|
+
)
|
|
97
|
+
conn.execute("ROLLBACK")
|
|
98
|
+
if _payload_digest(existing) == digest:
|
|
99
|
+
return False
|
|
100
|
+
raise ExternalEvidenceConflictError(
|
|
101
|
+
"external run address has a different receipt head"
|
|
102
|
+
)
|
|
103
|
+
except sqlite3.OperationalError as exc:
|
|
104
|
+
if conn is not None and conn.in_transaction:
|
|
105
|
+
conn.execute("ROLLBACK")
|
|
106
|
+
busy = "locked" in str(exc).lower() or "busy" in str(exc).lower()
|
|
107
|
+
if not busy or time.monotonic() >= deadline:
|
|
108
|
+
if busy:
|
|
109
|
+
raise LearningWriteBusyError(
|
|
110
|
+
"external evidence write deadline exceeded"
|
|
111
|
+
) from exc
|
|
112
|
+
raise
|
|
113
|
+
time.sleep(0.02)
|
|
114
|
+
finally:
|
|
115
|
+
if conn is not None:
|
|
116
|
+
conn.close()
|
|
117
|
+
finally:
|
|
118
|
+
self._lock.release()
|
|
119
|
+
self._gate.release(profile_id)
|
|
120
|
+
|
|
121
|
+
def get(self, profile_id: str, workspace_id: str, run_ref: str) -> dict[str, Any] | None:
|
|
122
|
+
conn = sqlite3.connect(f"{self._path.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
|
|
123
|
+
conn.row_factory = sqlite3.Row
|
|
124
|
+
try:
|
|
125
|
+
return _get_conn(conn, profile_id, _CONTRACT, workspace_id, run_ref)
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_profile_external_evidence_summary(path: str | Path, profile_id: str) -> dict[str, Any]:
|
|
131
|
+
"""Return indexed Living Brain totals without opening SLM's memory database."""
|
|
132
|
+
empty = {
|
|
133
|
+
"is_real": False,
|
|
134
|
+
"availability": "unavailable",
|
|
135
|
+
"total": 0,
|
|
136
|
+
"by_run_state": {},
|
|
137
|
+
"demonstrations": 0,
|
|
138
|
+
}
|
|
139
|
+
target = Path(path)
|
|
140
|
+
if not target.exists():
|
|
141
|
+
return empty
|
|
142
|
+
conn: sqlite3.Connection | None = None
|
|
143
|
+
try:
|
|
144
|
+
conn = sqlite3.connect(f"{target.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
|
|
145
|
+
total = conn.execute(
|
|
146
|
+
"SELECT COUNT(*) FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
|
|
147
|
+
).fetchone()[0]
|
|
148
|
+
demo = conn.execute(
|
|
149
|
+
"SELECT COUNT(*) FROM external_evidence_receipts "
|
|
150
|
+
"WHERE profile_id=? AND demonstration=1",
|
|
151
|
+
(profile_id,),
|
|
152
|
+
).fetchone()[0]
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
"SELECT run_state, COUNT(*) FROM external_evidence_receipts "
|
|
155
|
+
"WHERE profile_id=? GROUP BY run_state",
|
|
156
|
+
(profile_id,),
|
|
157
|
+
).fetchall()
|
|
158
|
+
except sqlite3.Error:
|
|
159
|
+
return empty
|
|
160
|
+
finally:
|
|
161
|
+
if conn is not None:
|
|
162
|
+
conn.close()
|
|
163
|
+
return {
|
|
164
|
+
"is_real": True,
|
|
165
|
+
"availability": "available",
|
|
166
|
+
"total": int(total),
|
|
167
|
+
"by_run_state": {str(k): int(v) for k, v in rows},
|
|
168
|
+
"demonstrations": int(demo),
|
|
169
|
+
"control_plane": "observation_only",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate(payload: dict[str, Any]) -> None:
|
|
174
|
+
required = {
|
|
175
|
+
"contract",
|
|
176
|
+
"profile_id",
|
|
177
|
+
"workspace_id",
|
|
178
|
+
"run_ref",
|
|
179
|
+
"run_id",
|
|
180
|
+
"outcome",
|
|
181
|
+
"run_state",
|
|
182
|
+
"demonstration",
|
|
183
|
+
"eligible_for_learning",
|
|
184
|
+
"terminal_at",
|
|
185
|
+
"graph_digest",
|
|
186
|
+
"plan_digest",
|
|
187
|
+
"policy_digest",
|
|
188
|
+
"receipt",
|
|
189
|
+
"nodes",
|
|
190
|
+
}
|
|
191
|
+
if set(payload) != required:
|
|
192
|
+
raise ExternalEvidenceValidationError("external evidence fields do not match v1")
|
|
193
|
+
if payload["contract"] != _CONTRACT:
|
|
194
|
+
raise ExternalEvidenceValidationError("unsupported external evidence contract")
|
|
195
|
+
for name in ("profile_id", "run_ref", "run_id"):
|
|
196
|
+
if not isinstance(payload[name], str) or not _IDENTIFIER.match(payload[name]):
|
|
197
|
+
raise ExternalEvidenceValidationError(f"{name} must be a safe identifier")
|
|
198
|
+
for name in ("workspace_id", "graph_digest", "plan_digest", "policy_digest"):
|
|
199
|
+
if not isinstance(payload[name], str) or not _SHA256.match(payload[name]):
|
|
200
|
+
raise ExternalEvidenceValidationError(f"{name} must be a sha256 digest")
|
|
201
|
+
if payload["outcome"] not in _OUTCOMES or payload["run_state"] not in _RUN_STATES:
|
|
202
|
+
raise ExternalEvidenceValidationError("outcome or run_state is unsupported")
|
|
203
|
+
if payload["run_state"] == "SUCCEEDED" and payload["outcome"] != "SUCCEEDED":
|
|
204
|
+
raise ExternalEvidenceValidationError("SUCCEEDED run_state must keep its outcome")
|
|
205
|
+
if (
|
|
206
|
+
not isinstance(payload["terminal_at"], str)
|
|
207
|
+
or len(payload["terminal_at"].encode("utf-8")) > _MAX_TIMESTAMP_BYTES
|
|
208
|
+
):
|
|
209
|
+
raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp")
|
|
210
|
+
try:
|
|
211
|
+
datetime.fromisoformat(payload["terminal_at"].replace("Z", "+00:00"))
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp") from exc
|
|
214
|
+
if (
|
|
215
|
+
not isinstance(payload["demonstration"], bool)
|
|
216
|
+
or payload["eligible_for_learning"] is not False
|
|
217
|
+
):
|
|
218
|
+
raise ExternalEvidenceValidationError("v1 evidence is observation-only")
|
|
219
|
+
receipt = payload["receipt"]
|
|
220
|
+
if not isinstance(receipt, dict) or set(receipt) != {
|
|
221
|
+
"sequence",
|
|
222
|
+
"head_digest",
|
|
223
|
+
"trust",
|
|
224
|
+
}:
|
|
225
|
+
raise ExternalEvidenceValidationError("receipt shape is invalid")
|
|
226
|
+
if (
|
|
227
|
+
not isinstance(receipt["sequence"], int)
|
|
228
|
+
or receipt["sequence"] < 1
|
|
229
|
+
or receipt["sequence"] > _MAX_RECEIPT_SEQUENCE
|
|
230
|
+
or receipt["trust"] != "local_hash_chain_only"
|
|
231
|
+
):
|
|
232
|
+
raise ExternalEvidenceValidationError("receipt metadata is invalid")
|
|
233
|
+
if not isinstance(receipt["head_digest"], str) or not _SHA256.match(receipt["head_digest"]):
|
|
234
|
+
raise ExternalEvidenceValidationError("receipt head digest is invalid")
|
|
235
|
+
if not isinstance(payload["nodes"], list):
|
|
236
|
+
raise ExternalEvidenceValidationError("nodes must be a list")
|
|
237
|
+
if len(payload["nodes"]) > _MAX_NODES:
|
|
238
|
+
raise ExternalEvidenceValidationError("node count exceeds v1 safety limit")
|
|
239
|
+
artifact_count = 0
|
|
240
|
+
for node in payload["nodes"]:
|
|
241
|
+
if not isinstance(node, dict) or set(node) != {
|
|
242
|
+
"node_id",
|
|
243
|
+
"state",
|
|
244
|
+
"gate_passed",
|
|
245
|
+
"attempts",
|
|
246
|
+
"artifact_digests",
|
|
247
|
+
}:
|
|
248
|
+
raise ExternalEvidenceValidationError("node shape is invalid")
|
|
249
|
+
valid_node = _IDENTIFIER.match(str(node["node_id"])) and _IDENTIFIER.match(
|
|
250
|
+
str(node["state"])
|
|
251
|
+
)
|
|
252
|
+
if not valid_node:
|
|
253
|
+
raise ExternalEvidenceValidationError("node identifiers are invalid")
|
|
254
|
+
if (
|
|
255
|
+
node["gate_passed"] not in (True, False, None)
|
|
256
|
+
or not isinstance(node["attempts"], int)
|
|
257
|
+
or node["attempts"] < 1
|
|
258
|
+
):
|
|
259
|
+
raise ExternalEvidenceValidationError("node gate metadata is invalid")
|
|
260
|
+
if not isinstance(node["artifact_digests"], list) or any(
|
|
261
|
+
not isinstance(item, str) or not _SHA256.match(item)
|
|
262
|
+
for item in node["artifact_digests"]
|
|
263
|
+
):
|
|
264
|
+
raise ExternalEvidenceValidationError("node artifact digests are invalid")
|
|
265
|
+
if len(node["artifact_digests"]) > _MAX_ARTIFACTS_PER_NODE:
|
|
266
|
+
raise ExternalEvidenceValidationError("node artifact count exceeds v1 safety limit")
|
|
267
|
+
artifact_count += len(node["artifact_digests"])
|
|
268
|
+
if artifact_count > _MAX_ARTIFACTS_TOTAL:
|
|
269
|
+
raise ExternalEvidenceValidationError("artifact count exceeds v1 safety limit")
|
|
270
|
+
if len(_json(payload["nodes"]).encode("utf-8")) > _MAX_NODES_JSON_BYTES:
|
|
271
|
+
raise ExternalEvidenceValidationError("node evidence exceeds v1 size limit")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _row(payload: dict[str, Any], digest: str) -> tuple[Any, ...]:
|
|
275
|
+
artifacts = sorted({item for node in payload["nodes"] for item in node["artifact_digests"]})
|
|
276
|
+
receipt = payload["receipt"]
|
|
277
|
+
return (
|
|
278
|
+
payload["profile_id"],
|
|
279
|
+
payload["contract"],
|
|
280
|
+
payload["workspace_id"],
|
|
281
|
+
payload["run_ref"],
|
|
282
|
+
payload["run_id"],
|
|
283
|
+
payload["outcome"],
|
|
284
|
+
payload["run_state"],
|
|
285
|
+
int(payload["demonstration"]),
|
|
286
|
+
0,
|
|
287
|
+
payload["terminal_at"],
|
|
288
|
+
payload["graph_digest"],
|
|
289
|
+
payload["plan_digest"],
|
|
290
|
+
payload["policy_digest"],
|
|
291
|
+
receipt["sequence"],
|
|
292
|
+
receipt["head_digest"],
|
|
293
|
+
receipt["trust"],
|
|
294
|
+
_json(payload["nodes"]),
|
|
295
|
+
_json(artifacts),
|
|
296
|
+
digest,
|
|
297
|
+
datetime.now(timezone.utc).isoformat(),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _assert_profile_open(conn: sqlite3.Connection, profile_id: str) -> None:
|
|
302
|
+
"""Use M040's durable tombstone inside this writer transaction."""
|
|
303
|
+
table = conn.execute(
|
|
304
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
305
|
+
"AND name='agent_receipt_profile_closures'"
|
|
306
|
+
).fetchone()
|
|
307
|
+
if (
|
|
308
|
+
table is not None
|
|
309
|
+
and conn.execute(
|
|
310
|
+
"SELECT 1 FROM agent_receipt_profile_closures WHERE profile_id=?", (profile_id,)
|
|
311
|
+
).fetchone()
|
|
312
|
+
is not None
|
|
313
|
+
):
|
|
314
|
+
raise ProfileAdmissionError("profile is inactive or closing for erasure")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _get_conn(
|
|
318
|
+
conn: sqlite3.Connection,
|
|
319
|
+
profile_id: str,
|
|
320
|
+
contract_id: str,
|
|
321
|
+
workspace_id: str,
|
|
322
|
+
run_ref: str,
|
|
323
|
+
) -> dict[str, Any] | None:
|
|
324
|
+
row = conn.execute(
|
|
325
|
+
"SELECT * FROM external_evidence_receipts WHERE profile_id=? AND contract_id=? "
|
|
326
|
+
"AND workspace_id=? AND run_ref=?",
|
|
327
|
+
(profile_id, contract_id, workspace_id, run_ref),
|
|
328
|
+
).fetchone()
|
|
329
|
+
if row is None:
|
|
330
|
+
return None
|
|
331
|
+
return {
|
|
332
|
+
"contract": row["contract_id"],
|
|
333
|
+
"profile_id": row["profile_id"],
|
|
334
|
+
"workspace_id": row["workspace_id"],
|
|
335
|
+
"run_ref": row["run_ref"],
|
|
336
|
+
"run_id": row["run_id"],
|
|
337
|
+
"outcome": row["outcome"],
|
|
338
|
+
"run_state": row["run_state"],
|
|
339
|
+
"demonstration": bool(row["demonstration"]),
|
|
340
|
+
"eligible_for_learning": bool(row["eligible_for_learning"]),
|
|
341
|
+
"terminal_at": row["terminal_at"],
|
|
342
|
+
"graph_digest": row["graph_digest"],
|
|
343
|
+
"plan_digest": row["plan_digest"],
|
|
344
|
+
"policy_digest": row["policy_digest"],
|
|
345
|
+
"receipt": {
|
|
346
|
+
"sequence": row["receipt_sequence"],
|
|
347
|
+
"head_digest": row["receipt_head_digest"],
|
|
348
|
+
"trust": row["receipt_trust"],
|
|
349
|
+
},
|
|
350
|
+
"nodes": json.loads(row["nodes_json"]),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _payload_digest(payload: dict[str, Any]) -> str:
|
|
355
|
+
return hashlib.sha256(_json(payload).encode("utf-8")).hexdigest()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _json(value: Any) -> str:
|
|
359
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
@@ -154,6 +154,12 @@ from superlocalmemory.storage.migrations import (
|
|
|
154
154
|
from superlocalmemory.storage.migrations import (
|
|
155
155
|
M040_agent_experience_receipts as _M040,
|
|
156
156
|
)
|
|
157
|
+
from superlocalmemory.storage.migrations import (
|
|
158
|
+
M041_external_evidence_receipts as _M041,
|
|
159
|
+
)
|
|
160
|
+
from superlocalmemory.storage.migrations import (
|
|
161
|
+
M042_correction_case_ledger as _M042,
|
|
162
|
+
)
|
|
157
163
|
from superlocalmemory.storage._schema_version import (
|
|
158
164
|
SUPPORTED_SCHEMA_VERSION,
|
|
159
165
|
SchemaVersionError,
|
|
@@ -234,6 +240,12 @@ MIGRATIONS: list[Migration] = [
|
|
|
234
240
|
# lifecycle performs explicit cross-store erasure rather than an FK.
|
|
235
241
|
Migration(name=_M040.NAME, db_target="learning", ddl=_M040.DDL,
|
|
236
242
|
dependencies=(_M003.NAME,)),
|
|
243
|
+
Migration(name=_M041.NAME, db_target="learning", ddl=_M041.DDL,
|
|
244
|
+
dependencies=(_M040.NAME,)),
|
|
245
|
+
# Review-gated correction metadata is self-contained in memory.db. It
|
|
246
|
+
# contains identifiers only and does not alter temporal fact state.
|
|
247
|
+
Migration(name=_M042.NAME, db_target="memory", ddl=_M042.DDL,
|
|
248
|
+
dependencies=(_M032.NAME,)),
|
|
237
249
|
# M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
|
|
238
250
|
]
|
|
239
251
|
|