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
|
@@ -489,11 +489,19 @@ class DatabaseManager:
|
|
|
489
489
|
# writes target the canonical fact (idempotent), not an
|
|
490
490
|
# orphaned id that was never inserted.
|
|
491
491
|
fact.fact_id = canonical_id
|
|
492
|
+
# Repair the only recoverable interrupted-write state from an
|
|
493
|
+
# early 4.0.2 attempt: a durable fact without its mandatory
|
|
494
|
+
# knowledge-time anchor. Do not manufacture a historical
|
|
495
|
+
# timestamp from ``created_at``; this retry is the earliest
|
|
496
|
+
# trustworthy observation that the anchor was missing.
|
|
497
|
+
if self.get_temporal_validity(canonical_id, fact.profile_id) is None:
|
|
498
|
+
self.store_temporal_validity(canonical_id, fact.profile_id)
|
|
492
499
|
return canonical_id
|
|
493
500
|
_scope = getattr(fact, 'scope', None) or 'personal'
|
|
494
501
|
_shared = _jd(getattr(fact, 'shared_with', None))
|
|
495
|
-
|
|
496
|
-
|
|
502
|
+
def _insert_with_knowledge_anchor() -> None:
|
|
503
|
+
self.execute(
|
|
504
|
+
"""INSERT OR REPLACE INTO atomic_facts
|
|
497
505
|
(fact_id, memory_id, profile_id, content, fact_type,
|
|
498
506
|
entities_json, canonical_entities_json,
|
|
499
507
|
observation_date, referenced_date, interval_start, interval_end,
|
|
@@ -504,18 +512,30 @@ class DatabaseManager:
|
|
|
504
512
|
emotional_valence, emotional_arousal, signal_type, created_at,
|
|
505
513
|
scope, shared_with)
|
|
506
514
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
515
|
+
(fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
|
|
516
|
+
fact.fact_type.value,
|
|
517
|
+
json.dumps(fact.entities), json.dumps(fact.canonical_entities),
|
|
518
|
+
fact.observation_date, fact.referenced_date,
|
|
519
|
+
fact.interval_start, fact.interval_end,
|
|
520
|
+
fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
|
|
521
|
+
json.dumps(fact.source_turn_ids), fact.session_id,
|
|
522
|
+
_jd(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
|
|
523
|
+
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
524
|
+
fact.emotional_valence, fact.emotional_arousal,
|
|
525
|
+
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
526
|
+
)
|
|
527
|
+
# Every fact written after 4.0.2 has an explicit transaction-time
|
|
528
|
+
# anchor. Absence deliberately represents pre-4.0.2
|
|
529
|
+
# ``legacy_unknown``; never backfill it from ``created_at``.
|
|
530
|
+
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
531
|
+
|
|
532
|
+
# The fact and its transaction-time anchor are one logical write. Do
|
|
533
|
+
# not open a nested transaction when an owner already holds one.
|
|
534
|
+
if getattr(self._txn_state, "conn", None) is not None:
|
|
535
|
+
_insert_with_knowledge_anchor()
|
|
536
|
+
else:
|
|
537
|
+
with self.transaction():
|
|
538
|
+
_insert_with_knowledge_anchor()
|
|
519
539
|
return fact.fact_id
|
|
520
540
|
|
|
521
541
|
def _row_to_fact(self, row: sqlite3.Row) -> AtomicFact:
|
|
@@ -561,14 +581,26 @@ class DatabaseManager:
|
|
|
561
581
|
include_global: bool = False,
|
|
562
582
|
include_shared: bool = False,
|
|
563
583
|
) -> list[AtomicFact]:
|
|
564
|
-
"""Return
|
|
584
|
+
"""Return currently admissible pinned facts, highest-importance first.
|
|
585
|
+
|
|
586
|
+
Pins are injection priority, not an override for a later correction.
|
|
587
|
+
A system-invalidated fact remains durable and historically queryable,
|
|
588
|
+
but it must not be inserted into the current session context.
|
|
589
|
+
"""
|
|
565
590
|
where, params = _scope_where(
|
|
566
591
|
profile_id,
|
|
567
592
|
include_global=include_global,
|
|
568
593
|
include_shared=include_shared,
|
|
594
|
+
prefix="f",
|
|
569
595
|
)
|
|
570
596
|
rows = self.execute(
|
|
571
|
-
f"SELECT
|
|
597
|
+
f"SELECT f.* FROM atomic_facts f WHERE {where} AND f.pinned = 1 "
|
|
598
|
+
"AND NOT EXISTS ("
|
|
599
|
+
" SELECT 1 FROM fact_temporal_validity tv "
|
|
600
|
+
" WHERE tv.fact_id = f.fact_id "
|
|
601
|
+
" AND tv.profile_id = f.profile_id "
|
|
602
|
+
" AND tv.system_expired_at IS NOT NULL"
|
|
603
|
+
") "
|
|
572
604
|
"ORDER BY importance DESC",
|
|
573
605
|
(*params,),
|
|
574
606
|
)
|
|
@@ -1566,13 +1598,29 @@ class DatabaseManager:
|
|
|
1566
1598
|
valid_from: str | None = None,
|
|
1567
1599
|
valid_until: str | None = None,
|
|
1568
1600
|
) -> None:
|
|
1569
|
-
"""Create temporal
|
|
1601
|
+
"""Create or enrich the temporal record for a fact.
|
|
1602
|
+
|
|
1603
|
+
The 4.0.2 fact writer creates the record immediately to anchor
|
|
1604
|
+
transaction time. A later temporal extraction may add event-time bounds;
|
|
1605
|
+
it must not be discarded merely because the anchor already exists.
|
|
1606
|
+
"""
|
|
1607
|
+
from datetime import UTC
|
|
1608
|
+
from datetime import datetime as _dt
|
|
1609
|
+
system_created_at = _dt.now(UTC).isoformat()
|
|
1570
1610
|
self.execute(
|
|
1571
1611
|
"INSERT OR IGNORE INTO fact_temporal_validity "
|
|
1572
|
-
"(fact_id, profile_id, valid_from, valid_until) "
|
|
1573
|
-
"VALUES (?, ?, ?, ?)",
|
|
1574
|
-
(fact_id, profile_id, valid_from, valid_until),
|
|
1612
|
+
"(fact_id, profile_id, valid_from, valid_until, system_created_at) "
|
|
1613
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
1614
|
+
(fact_id, profile_id, valid_from, valid_until, system_created_at),
|
|
1575
1615
|
)
|
|
1616
|
+
if valid_from is not None or valid_until is not None:
|
|
1617
|
+
self.execute(
|
|
1618
|
+
"UPDATE fact_temporal_validity "
|
|
1619
|
+
"SET valid_from = COALESCE(?, valid_from), "
|
|
1620
|
+
" valid_until = COALESCE(?, valid_until) "
|
|
1621
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
1622
|
+
(valid_from, valid_until, fact_id, profile_id),
|
|
1623
|
+
)
|
|
1576
1624
|
|
|
1577
1625
|
def get_temporal_validity(self, fact_id: str, profile_id: str | None = None) -> dict | None:
|
|
1578
1626
|
"""Get temporal validity record for a fact (C4: optionally tenant-scoped)."""
|
|
@@ -1674,6 +1722,9 @@ class DatabaseManager:
|
|
|
1674
1722
|
fact_ids: list[str],
|
|
1675
1723
|
profile_id: str,
|
|
1676
1724
|
as_of: str | None = None,
|
|
1725
|
+
*,
|
|
1726
|
+
include_global: bool = False,
|
|
1727
|
+
include_shared: bool = False,
|
|
1677
1728
|
) -> set[str]:
|
|
1678
1729
|
"""Return the subset of ``fact_ids`` that are system-invalidated.
|
|
1679
1730
|
|
|
@@ -1700,9 +1751,12 @@ class DatabaseManager:
|
|
|
1700
1751
|
Bounded + indexed: only the supplied candidate ids are queried (never a
|
|
1701
1752
|
full-table scan), keyed on the ``fact_id`` PK with the
|
|
1702
1753
|
``idx_temporal_system_expired`` index covering the predicate. Chunked to
|
|
1703
|
-
stay well under SQLite's ~999 bound-parameter limit.
|
|
1704
|
-
|
|
1705
|
-
|
|
1754
|
+
stay well under SQLite's ~999 bound-parameter limit. The visibility
|
|
1755
|
+
predicate is evaluated on the fact owner's row, so an opted-in global or
|
|
1756
|
+
shared fact is checked against *its owner's* temporal record rather than
|
|
1757
|
+
incorrectly against the requesting profile. Facts with no temporal
|
|
1758
|
+
record — or a record whose ``system_expired_at`` is NULL — are NOT
|
|
1759
|
+
returned (treated as valid), so existing DBs need no backfill.
|
|
1706
1760
|
|
|
1707
1761
|
Event-time expiry (``valid_until`` in the past) is intentionally NOT
|
|
1708
1762
|
applied here: it is query-scoped (historical queries legitimately want
|
|
@@ -1712,6 +1766,12 @@ class DatabaseManager:
|
|
|
1712
1766
|
if not fact_ids:
|
|
1713
1767
|
return set()
|
|
1714
1768
|
invalid: set[str] = set()
|
|
1769
|
+
scope_where, scope_params = _scope_where(
|
|
1770
|
+
profile_id,
|
|
1771
|
+
include_global=include_global,
|
|
1772
|
+
include_shared=include_shared,
|
|
1773
|
+
prefix="f",
|
|
1774
|
+
)
|
|
1715
1775
|
chunk = 900
|
|
1716
1776
|
for start in range(0, len(fact_ids), chunk):
|
|
1717
1777
|
batch = fact_ids[start:start + chunk]
|
|
@@ -1721,25 +1781,120 @@ class DatabaseManager:
|
|
|
1721
1781
|
# occurred AT OR BEFORE as_of contribute to invalidation.
|
|
1722
1782
|
# Supersessions after as_of are invisible at this query point.
|
|
1723
1783
|
rows = self.execute(
|
|
1724
|
-
f"SELECT fact_id FROM fact_temporal_validity "
|
|
1725
|
-
f"
|
|
1726
|
-
f"
|
|
1727
|
-
f" AND
|
|
1728
|
-
f" AND
|
|
1729
|
-
|
|
1784
|
+
f"SELECT tv.fact_id FROM fact_temporal_validity tv "
|
|
1785
|
+
f"JOIN atomic_facts f ON f.fact_id = tv.fact_id "
|
|
1786
|
+
f"WHERE tv.fact_id IN ({placeholders}) "
|
|
1787
|
+
f" AND {scope_where} "
|
|
1788
|
+
f" AND tv.profile_id = f.profile_id "
|
|
1789
|
+
f" AND tv.system_expired_at IS NOT NULL "
|
|
1790
|
+
f" AND tv.system_expired_at <= ?",
|
|
1791
|
+
(*batch, *scope_params, as_of),
|
|
1730
1792
|
)
|
|
1731
1793
|
else:
|
|
1732
1794
|
rows = self.execute(
|
|
1733
|
-
f"SELECT fact_id FROM fact_temporal_validity "
|
|
1734
|
-
f"
|
|
1735
|
-
f"
|
|
1736
|
-
f" AND
|
|
1737
|
-
|
|
1795
|
+
f"SELECT tv.fact_id FROM fact_temporal_validity tv "
|
|
1796
|
+
f"JOIN atomic_facts f ON f.fact_id = tv.fact_id "
|
|
1797
|
+
f"WHERE tv.fact_id IN ({placeholders}) "
|
|
1798
|
+
f" AND {scope_where} "
|
|
1799
|
+
f" AND tv.profile_id = f.profile_id "
|
|
1800
|
+
f" AND tv.system_expired_at IS NOT NULL",
|
|
1801
|
+
(*batch, *scope_params),
|
|
1738
1802
|
)
|
|
1739
1803
|
for r in rows:
|
|
1740
1804
|
invalid.add(dict(r)["fact_id"])
|
|
1741
1805
|
return invalid
|
|
1742
1806
|
|
|
1807
|
+
def get_strict_temporal_inadmissible_fact_ids(
|
|
1808
|
+
self,
|
|
1809
|
+
fact_ids: list[str],
|
|
1810
|
+
profile_id: str,
|
|
1811
|
+
*,
|
|
1812
|
+
known_as_of: str | None = None,
|
|
1813
|
+
valid_at: str | None = None,
|
|
1814
|
+
include_unknown: bool = False,
|
|
1815
|
+
include_global: bool = False,
|
|
1816
|
+
include_shared: bool = False,
|
|
1817
|
+
) -> set[str]:
|
|
1818
|
+
"""Return candidates excluded by an explicit two-clock query.
|
|
1819
|
+
|
|
1820
|
+
``known_as_of`` is transaction time: it asks what this SLM instance had
|
|
1821
|
+
learned by a timestamp. ``valid_at`` is event time: it asks what was
|
|
1822
|
+
true at a timestamp according to the requested knowledge state. The
|
|
1823
|
+
axes are independent and may be supplied separately or together.
|
|
1824
|
+
|
|
1825
|
+
A fact with no temporal row predates the 4.0.2 write invariant and is
|
|
1826
|
+
``legacy_unknown``. Strict time-travel excludes it unless the caller
|
|
1827
|
+
explicitly asks to include unknown history. This is intentionally
|
|
1828
|
+
bounded to the already-retrieved candidate pool.
|
|
1829
|
+
"""
|
|
1830
|
+
if not fact_ids or (known_as_of is None and valid_at is None):
|
|
1831
|
+
return set()
|
|
1832
|
+
from datetime import datetime as _dt
|
|
1833
|
+
from superlocalmemory.retrieval.temporal_utils import normalize_as_of
|
|
1834
|
+
|
|
1835
|
+
def _parse_timestamp(value: object) -> _dt | None:
|
|
1836
|
+
normalized = normalize_as_of(value)
|
|
1837
|
+
return _dt.fromisoformat(normalized) if normalized is not None else None
|
|
1838
|
+
|
|
1839
|
+
known_boundary = _parse_timestamp(known_as_of) if known_as_of is not None else None
|
|
1840
|
+
valid_boundary = _parse_timestamp(valid_at) if valid_at is not None else None
|
|
1841
|
+
inadmissible: set[str] = set()
|
|
1842
|
+
scope_where, scope_params = _scope_where(
|
|
1843
|
+
profile_id,
|
|
1844
|
+
include_global=include_global,
|
|
1845
|
+
include_shared=include_shared,
|
|
1846
|
+
prefix="f",
|
|
1847
|
+
)
|
|
1848
|
+
chunk = 900
|
|
1849
|
+
for start in range(0, len(fact_ids), chunk):
|
|
1850
|
+
batch = fact_ids[start:start + chunk]
|
|
1851
|
+
placeholders = ",".join("?" for _ in batch)
|
|
1852
|
+
rows = self.execute(
|
|
1853
|
+
f"SELECT f.fact_id, tv.fact_id AS temporal_fact_id, "
|
|
1854
|
+
f"tv.system_created_at, tv.system_expired_at, "
|
|
1855
|
+
f"tv.valid_from, tv.valid_until FROM atomic_facts f "
|
|
1856
|
+
f"LEFT JOIN fact_temporal_validity tv "
|
|
1857
|
+
f" ON tv.fact_id = f.fact_id AND tv.profile_id = f.profile_id "
|
|
1858
|
+
f"WHERE f.fact_id IN ({placeholders}) "
|
|
1859
|
+
f" AND {scope_where}",
|
|
1860
|
+
(*batch, *scope_params),
|
|
1861
|
+
)
|
|
1862
|
+
for row in rows:
|
|
1863
|
+
values = dict(row)
|
|
1864
|
+
has_temporal_record = values["temporal_fact_id"] is not None
|
|
1865
|
+
if not has_temporal_record:
|
|
1866
|
+
if not include_unknown:
|
|
1867
|
+
inadmissible.add(values["fact_id"])
|
|
1868
|
+
continue
|
|
1869
|
+
unknown = False
|
|
1870
|
+
if known_boundary is not None:
|
|
1871
|
+
created = _parse_timestamp(values["system_created_at"])
|
|
1872
|
+
expired = _parse_timestamp(values["system_expired_at"])
|
|
1873
|
+
if created is None:
|
|
1874
|
+
unknown = True
|
|
1875
|
+
elif created > known_boundary:
|
|
1876
|
+
inadmissible.add(values["fact_id"])
|
|
1877
|
+
continue
|
|
1878
|
+
elif expired is not None and expired <= known_boundary:
|
|
1879
|
+
inadmissible.add(values["fact_id"])
|
|
1880
|
+
continue
|
|
1881
|
+
if valid_boundary is not None:
|
|
1882
|
+
valid_from = _parse_timestamp(values["valid_from"])
|
|
1883
|
+
valid_until = _parse_timestamp(values["valid_until"])
|
|
1884
|
+
if values["valid_from"] is not None and valid_from is None:
|
|
1885
|
+
unknown = True
|
|
1886
|
+
elif valid_from is not None and valid_from > valid_boundary:
|
|
1887
|
+
inadmissible.add(values["fact_id"])
|
|
1888
|
+
continue
|
|
1889
|
+
if values["valid_until"] is not None and valid_until is None:
|
|
1890
|
+
unknown = True
|
|
1891
|
+
elif valid_until is not None and valid_until <= valid_boundary:
|
|
1892
|
+
inadmissible.add(values["fact_id"])
|
|
1893
|
+
continue
|
|
1894
|
+
if unknown and not include_unknown:
|
|
1895
|
+
inadmissible.add(values["fact_id"])
|
|
1896
|
+
return inadmissible
|
|
1897
|
+
|
|
1743
1898
|
def get_event_time_expired_fact_ids(
|
|
1744
1899
|
self,
|
|
1745
1900
|
fact_ids: list[str],
|
|
@@ -151,6 +151,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
151
151
|
from superlocalmemory.storage.migrations import (
|
|
152
152
|
M039_scene_fact_members as _M039,
|
|
153
153
|
)
|
|
154
|
+
from superlocalmemory.storage.migrations import (
|
|
155
|
+
M040_agent_experience_receipts as _M040,
|
|
156
|
+
)
|
|
154
157
|
from superlocalmemory.storage._schema_version import (
|
|
155
158
|
SUPPORTED_SCHEMA_VERSION,
|
|
156
159
|
SchemaVersionError,
|
|
@@ -226,6 +229,11 @@ MIGRATIONS: list[Migration] = [
|
|
|
226
229
|
# channel patterns.
|
|
227
230
|
Migration(name=_M038.NAME, db_target="learning", ddl=_M038.DDL,
|
|
228
231
|
dependencies=(_M003.NAME,)),
|
|
232
|
+
# Receipt writes are a learning-plane concern and must never share the
|
|
233
|
+
# memory.db recall lock domain. The tables are self-contained: profile
|
|
234
|
+
# lifecycle performs explicit cross-store erasure rather than an FK.
|
|
235
|
+
Migration(name=_M040.NAME, db_target="learning", ddl=_M040.DDL,
|
|
236
|
+
dependencies=(_M003.NAME,)),
|
|
229
237
|
# M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
|
|
230
238
|
]
|
|
231
239
|
|
|
@@ -38,3 +38,21 @@ CREATE INDEX IF NOT EXISTS idx_facts_pinned
|
|
|
38
38
|
ON atomic_facts(profile_id, pinned);
|
|
39
39
|
COMMIT;
|
|
40
40
|
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
44
|
+
"""Apply M015 safely when a fresh schema already contains ``pinned``.
|
|
45
|
+
|
|
46
|
+
New installations are created from the current base schema, whereas
|
|
47
|
+
upgrades need the additive ALTER. SQLite has no ``ADD COLUMN IF NOT
|
|
48
|
+
EXISTS``, so the old static migration failed on fresh databases.
|
|
49
|
+
"""
|
|
50
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(atomic_facts)")}
|
|
51
|
+
if "pinned" not in cols:
|
|
52
|
+
conn.execute(
|
|
53
|
+
"ALTER TABLE atomic_facts ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0"
|
|
54
|
+
)
|
|
55
|
+
conn.execute(
|
|
56
|
+
"CREATE INDEX IF NOT EXISTS idx_facts_pinned "
|
|
57
|
+
"ON atomic_facts(profile_id, pinned)"
|
|
58
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""M040 — profile-scoped Agent Experience receipts in ``learning.db``.
|
|
2
|
+
|
|
3
|
+
The receipt plane deliberately has no foreign keys into ``memory.db``. It is
|
|
4
|
+
therefore not part of recall's lock domain: profile lifecycle coordinates its
|
|
5
|
+
cross-store erasure as a retryable saga instead of using ``ATTACH``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
NAME = "M040_agent_experience_receipts"
|
|
13
|
+
DB_TARGET = "learning"
|
|
14
|
+
|
|
15
|
+
DDL = """
|
|
16
|
+
BEGIN IMMEDIATE;
|
|
17
|
+
CREATE TABLE IF NOT EXISTS agent_experiences (
|
|
18
|
+
profile_id TEXT NOT NULL,
|
|
19
|
+
experience_id TEXT NOT NULL,
|
|
20
|
+
occurred_at TEXT NOT NULL,
|
|
21
|
+
task_class TEXT NOT NULL,
|
|
22
|
+
project_scope TEXT NOT NULL,
|
|
23
|
+
route_json TEXT NOT NULL,
|
|
24
|
+
verification_authority TEXT NOT NULL,
|
|
25
|
+
verification_digest TEXT NOT NULL,
|
|
26
|
+
verification_reference TEXT,
|
|
27
|
+
producer_claim TEXT NOT NULL,
|
|
28
|
+
terminal_status TEXT NOT NULL,
|
|
29
|
+
failure_class TEXT,
|
|
30
|
+
human_intervention INTEGER CHECK (human_intervention IN (0, 1)),
|
|
31
|
+
lessons TEXT,
|
|
32
|
+
receipt_digest TEXT,
|
|
33
|
+
artifact_digests_json TEXT NOT NULL,
|
|
34
|
+
payload_sha256 TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
PRIMARY KEY (profile_id, experience_id)
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS cognitive_turn_receipts (
|
|
39
|
+
profile_id TEXT NOT NULL,
|
|
40
|
+
receipt_id TEXT NOT NULL,
|
|
41
|
+
task_id TEXT NOT NULL,
|
|
42
|
+
project_scope TEXT NOT NULL,
|
|
43
|
+
query_digest TEXT NOT NULL,
|
|
44
|
+
fact_decisions_json TEXT NOT NULL,
|
|
45
|
+
state TEXT NOT NULL CHECK (state IN ('open', 'finalized', 'abandoned', 'reconciled')),
|
|
46
|
+
outcome_json TEXT,
|
|
47
|
+
payload_sha256 TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
updated_at TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY (profile_id, receipt_id)
|
|
51
|
+
);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS agent_receipt_profile_closures (
|
|
53
|
+
profile_id TEXT PRIMARY KEY,
|
|
54
|
+
closed_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_agent_experiences_profile_occurred
|
|
57
|
+
ON agent_experiences (profile_id, occurred_at DESC);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_agent_experiences_profile_project_occurred
|
|
59
|
+
ON agent_experiences (profile_id, project_scope, occurred_at DESC);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_cognitive_turns_profile_task_updated
|
|
61
|
+
ON cognitive_turn_receipts (profile_id, task_id, updated_at DESC);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_cognitive_turns_profile_state_updated
|
|
63
|
+
ON cognitive_turn_receipts (profile_id, state, updated_at DESC);
|
|
64
|
+
COMMIT;
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
_TABLE_COLUMNS = {
|
|
68
|
+
"agent_experiences": (
|
|
69
|
+
"profile_id",
|
|
70
|
+
"experience_id",
|
|
71
|
+
"occurred_at",
|
|
72
|
+
"task_class",
|
|
73
|
+
"project_scope",
|
|
74
|
+
"route_json",
|
|
75
|
+
"verification_authority",
|
|
76
|
+
"verification_digest",
|
|
77
|
+
"verification_reference",
|
|
78
|
+
"producer_claim",
|
|
79
|
+
"terminal_status",
|
|
80
|
+
"failure_class",
|
|
81
|
+
"human_intervention",
|
|
82
|
+
"lessons",
|
|
83
|
+
"receipt_digest",
|
|
84
|
+
"artifact_digests_json",
|
|
85
|
+
"payload_sha256",
|
|
86
|
+
"created_at",
|
|
87
|
+
),
|
|
88
|
+
"cognitive_turn_receipts": (
|
|
89
|
+
"profile_id",
|
|
90
|
+
"receipt_id",
|
|
91
|
+
"task_id",
|
|
92
|
+
"project_scope",
|
|
93
|
+
"query_digest",
|
|
94
|
+
"fact_decisions_json",
|
|
95
|
+
"state",
|
|
96
|
+
"outcome_json",
|
|
97
|
+
"payload_sha256",
|
|
98
|
+
"created_at",
|
|
99
|
+
"updated_at",
|
|
100
|
+
),
|
|
101
|
+
"agent_receipt_profile_closures": ("profile_id", "closed_at"),
|
|
102
|
+
}
|
|
103
|
+
_PRIMARY_KEYS = {
|
|
104
|
+
"agent_experiences": ("profile_id", "experience_id"),
|
|
105
|
+
"cognitive_turn_receipts": ("profile_id", "receipt_id"),
|
|
106
|
+
"agent_receipt_profile_closures": ("profile_id",),
|
|
107
|
+
}
|
|
108
|
+
_COLUMN_TYPES = {
|
|
109
|
+
"agent_experiences": (
|
|
110
|
+
"TEXT",
|
|
111
|
+
"TEXT",
|
|
112
|
+
"TEXT",
|
|
113
|
+
"TEXT",
|
|
114
|
+
"TEXT",
|
|
115
|
+
"TEXT",
|
|
116
|
+
"TEXT",
|
|
117
|
+
"TEXT",
|
|
118
|
+
"TEXT",
|
|
119
|
+
"TEXT",
|
|
120
|
+
"TEXT",
|
|
121
|
+
"TEXT",
|
|
122
|
+
"INTEGER",
|
|
123
|
+
"TEXT",
|
|
124
|
+
"TEXT",
|
|
125
|
+
"TEXT",
|
|
126
|
+
"TEXT",
|
|
127
|
+
"TEXT",
|
|
128
|
+
),
|
|
129
|
+
"cognitive_turn_receipts": ("TEXT",) * len(_TABLE_COLUMNS["cognitive_turn_receipts"]),
|
|
130
|
+
"agent_receipt_profile_closures": ("TEXT", "TEXT"),
|
|
131
|
+
}
|
|
132
|
+
_REQUIRED_NOT_NULL = {
|
|
133
|
+
"agent_experiences": frozenset(_TABLE_COLUMNS["agent_experiences"])
|
|
134
|
+
- {
|
|
135
|
+
"verification_reference",
|
|
136
|
+
"failure_class",
|
|
137
|
+
"human_intervention",
|
|
138
|
+
"lessons",
|
|
139
|
+
"receipt_digest",
|
|
140
|
+
},
|
|
141
|
+
"cognitive_turn_receipts": frozenset(_TABLE_COLUMNS["cognitive_turn_receipts"])
|
|
142
|
+
- {"outcome_json"},
|
|
143
|
+
"agent_receipt_profile_closures": frozenset(_TABLE_COLUMNS["agent_receipt_profile_closures"]),
|
|
144
|
+
}
|
|
145
|
+
_INDEXES = {
|
|
146
|
+
"idx_agent_experiences_profile_occurred": ("agent_experiences", ("profile_id", "occurred_at")),
|
|
147
|
+
"idx_agent_experiences_profile_project_occurred": (
|
|
148
|
+
"agent_experiences",
|
|
149
|
+
("profile_id", "project_scope", "occurred_at"),
|
|
150
|
+
),
|
|
151
|
+
"idx_cognitive_turns_profile_task_updated": (
|
|
152
|
+
"cognitive_turn_receipts",
|
|
153
|
+
("profile_id", "task_id", "updated_at"),
|
|
154
|
+
),
|
|
155
|
+
"idx_cognitive_turns_profile_state_updated": (
|
|
156
|
+
"cognitive_turn_receipts",
|
|
157
|
+
("profile_id", "state", "updated_at"),
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
163
|
+
"""Atomically install M040 or leave the learning DB unchanged."""
|
|
164
|
+
if verify(conn):
|
|
165
|
+
return
|
|
166
|
+
if _tables_are_malformed(conn):
|
|
167
|
+
raise sqlite3.OperationalError("M040 receipt tables are malformed; refusing rebuild")
|
|
168
|
+
if all(name in _tables(conn) for name in _TABLE_COLUMNS):
|
|
169
|
+
repair(conn)
|
|
170
|
+
return
|
|
171
|
+
conn.executescript(DDL)
|
|
172
|
+
if not verify(conn):
|
|
173
|
+
# A pre-existing same-named index can make CREATE INDEX IF NOT EXISTS
|
|
174
|
+
# a no-op even though that index belongs to another table. Rebuild the
|
|
175
|
+
# derived index set transactionally after all required tables exist.
|
|
176
|
+
repair(conn)
|
|
177
|
+
if not verify(conn):
|
|
178
|
+
raise sqlite3.OperationalError("M040 schema did not reach its required end-state")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def repair(conn: sqlite3.Connection) -> None:
|
|
182
|
+
"""Restore missing/wrong indexes only; never rebuild user evidence."""
|
|
183
|
+
if _tables_are_malformed(conn):
|
|
184
|
+
raise sqlite3.OperationalError("M040 receipt tables are malformed; refusing rebuild")
|
|
185
|
+
if not all(name in _tables(conn) for name in _TABLE_COLUMNS):
|
|
186
|
+
apply(conn)
|
|
187
|
+
return
|
|
188
|
+
drops = "\n".join(f"DROP INDEX IF EXISTS {name};" for name in _INDEXES)
|
|
189
|
+
creates = "\n".join(
|
|
190
|
+
f"CREATE INDEX {name} ON {table} ({', '.join(columns)});"
|
|
191
|
+
for name, (table, columns) in _INDEXES.items()
|
|
192
|
+
)
|
|
193
|
+
conn.executescript(f"BEGIN IMMEDIATE;\n{drops}\n{creates}\nCOMMIT;")
|
|
194
|
+
if not verify(conn):
|
|
195
|
+
raise sqlite3.OperationalError("M040 index repair did not restore required end-state")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
199
|
+
return not _tables_are_malformed(conn) and all(
|
|
200
|
+
_index_columns(conn, name) == columns for name, (_, columns) in _INDEXES.items()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tables_are_malformed(conn: sqlite3.Connection) -> bool:
|
|
205
|
+
tables = _tables(conn)
|
|
206
|
+
for table, columns in _TABLE_COLUMNS.items():
|
|
207
|
+
if table not in tables:
|
|
208
|
+
continue
|
|
209
|
+
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
210
|
+
actual = tuple(row[1] for row in info)
|
|
211
|
+
types = tuple(str(row[2]).upper() for row in info)
|
|
212
|
+
primary_key = tuple(row[1] for row in sorted(info, key=lambda row: row[5]) if row[5])
|
|
213
|
+
not_null = {row[1] for row in info if row[3] or row[5]}
|
|
214
|
+
if (
|
|
215
|
+
actual != columns
|
|
216
|
+
or types != _COLUMN_TYPES[table]
|
|
217
|
+
or primary_key != _PRIMARY_KEYS[table]
|
|
218
|
+
or not_null != _REQUIRED_NOT_NULL[table]
|
|
219
|
+
or not _required_checks_present(conn, table)
|
|
220
|
+
):
|
|
221
|
+
return True
|
|
222
|
+
if conn.execute(f"PRAGMA foreign_key_list({table})").fetchone() is not None:
|
|
223
|
+
return True
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _tables(conn: sqlite3.Connection) -> set[str]:
|
|
228
|
+
return {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _index_columns(conn: sqlite3.Connection, name: str) -> tuple[str, ...] | None:
|
|
232
|
+
row = conn.execute(
|
|
233
|
+
"SELECT tbl_name FROM sqlite_master WHERE type='index' AND name=?", (name,)
|
|
234
|
+
).fetchone()
|
|
235
|
+
expected = _INDEXES.get(name)
|
|
236
|
+
if row is None or expected is None or row[0] != expected[0]:
|
|
237
|
+
return None
|
|
238
|
+
return tuple(
|
|
239
|
+
row[2]
|
|
240
|
+
for row in conn.execute(f"PRAGMA index_xinfo({name})")
|
|
241
|
+
if row[5] and row[2] is not None
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _required_checks_present(conn: sqlite3.Connection, table: str) -> bool:
|
|
246
|
+
sql_row = conn.execute(
|
|
247
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
248
|
+
).fetchone()
|
|
249
|
+
sql = "" if sql_row is None or sql_row[0] is None else "".join(str(sql_row[0]).lower().split())
|
|
250
|
+
if table == "agent_experiences":
|
|
251
|
+
return "check(human_interventionin(0,1))" in sql
|
|
252
|
+
if table == "cognitive_turn_receipts":
|
|
253
|
+
return "check(statein('open','finalized','abandoned','reconciled'))" in sql
|
|
254
|
+
return True
|
|
@@ -30,6 +30,7 @@ from . import (
|
|
|
30
30
|
M030_entity_explorer_indexes,
|
|
31
31
|
M038_learning_feedback_channel,
|
|
32
32
|
M039_scene_fact_members,
|
|
33
|
+
M040_agent_experience_receipts,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
# ---------------------------------------------------------------------------
|
|
@@ -85,6 +86,7 @@ __all__ = (
|
|
|
85
86
|
"M030_entity_explorer_indexes",
|
|
86
87
|
"M038_learning_feedback_channel",
|
|
87
88
|
"M039_scene_fact_members",
|
|
89
|
+
"M040_agent_experience_receipts",
|
|
88
90
|
# Legacy re-exports (backward compat):
|
|
89
91
|
"CURRENT_SCHEMA_VERSION",
|
|
90
92
|
"get_schema_version",
|
|
@@ -171,6 +171,8 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
|
|
|
171
171
|
importance REAL NOT NULL DEFAULT 0.5,
|
|
172
172
|
evidence_count INTEGER NOT NULL DEFAULT 1,
|
|
173
173
|
access_count INTEGER NOT NULL DEFAULT 0,
|
|
174
|
+
-- Core-memory injection priority (M015 on upgraded databases)
|
|
175
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
174
176
|
|
|
175
177
|
-- Source tracing
|
|
176
178
|
source_turn_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
@@ -213,6 +215,8 @@ CREATE INDEX IF NOT EXISTS idx_facts_type
|
|
|
213
215
|
ON atomic_facts (profile_id, fact_type);
|
|
214
216
|
CREATE INDEX IF NOT EXISTS idx_facts_lifecycle
|
|
215
217
|
ON atomic_facts (profile_id, lifecycle);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_facts_pinned
|
|
219
|
+
ON atomic_facts (profile_id, pinned);
|
|
216
220
|
CREATE INDEX IF NOT EXISTS idx_facts_session
|
|
217
221
|
ON atomic_facts (profile_id, session_id);
|
|
218
222
|
CREATE INDEX IF NOT EXISTS idx_facts_referenced_date
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
compares this to /api/version and hard-reloads + clears localStorage
|
|
8
8
|
on mismatch, so the browser cannot show stale UI after an upgrade. -->
|
|
9
9
|
<meta name="slm-version" content="__SLM_VERSION__">
|
|
10
|
-
<title>SuperLocalMemory
|
|
11
|
-
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
|
10
|
+
<title>SuperLocalMemory V4 — Dashboard</title>
|
|
11
|
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
12
12
|
|
|
13
13
|
<!-- Bootstrap CSS (vendored locally v3.4.21 — no CDN calls, works offline) -->
|
|
14
14
|
<link href="static/vendor/bootstrap.min.css" rel="stylesheet">
|