superlocalmemory 4.0.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +10 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  36. package/pyproject.toml +3 -2
  37. package/src/superlocalmemory/__init__.py +1 -1
  38. package/src/superlocalmemory/cli/commands.py +60 -1
  39. package/src/superlocalmemory/cli/main.py +24 -1
  40. package/src/superlocalmemory/compliance/gdpr.py +104 -73
  41. package/src/superlocalmemory/contracts/__init__.py +1 -0
  42. package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
  43. package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
  44. package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
  45. package/src/superlocalmemory/contracts/v402.py +62 -0
  46. package/src/superlocalmemory/core/engine.py +10 -0
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -0
  48. package/src/superlocalmemory/core/recall_worker.py +12 -0
  49. package/src/superlocalmemory/core/worker_pool.py +12 -0
  50. package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
  51. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
  52. package/src/superlocalmemory/hooks/session_registry.py +136 -3
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
  54. package/src/superlocalmemory/integrations/__init__.py +1 -0
  55. package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
  56. package/src/superlocalmemory/learning/database.py +21 -14
  57. package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
  58. package/src/superlocalmemory/mcp/server.py +5 -0
  59. package/src/superlocalmemory/mcp/tools_brain.py +132 -0
  60. package/src/superlocalmemory/mcp/tools_core.py +25 -6
  61. package/src/superlocalmemory/mcp/tools_v3.py +16 -2
  62. package/src/superlocalmemory/retrieval/engine.py +43 -1
  63. package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
  64. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
  65. package/src/superlocalmemory/server/routes/brain.py +206 -1
  66. package/src/superlocalmemory/server/routes/helpers.py +53 -35
  67. package/src/superlocalmemory/server/routes/v3_api.py +118 -11
  68. package/src/superlocalmemory/server/unified_daemon.py +25 -0
  69. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  70. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  71. package/src/superlocalmemory/storage/agent_experience.py +490 -0
  72. package/src/superlocalmemory/storage/database.py +189 -34
  73. package/src/superlocalmemory/storage/migration_runner.py +8 -0
  74. package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
  75. package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
  76. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  77. package/src/superlocalmemory/storage/schema.py +4 -0
  78. package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
  79. package/src/superlocalmemory/ui/js/brain.js +57 -1
  80. package/src/superlocalmemory/ui/js/od-brain.js +114 -40
  81. 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
- self.execute(
496
- """INSERT OR REPLACE INTO atomic_facts
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
- (fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
508
- fact.fact_type.value,
509
- json.dumps(fact.entities), json.dumps(fact.canonical_entities),
510
- fact.observation_date, fact.referenced_date,
511
- fact.interval_start, fact.interval_end,
512
- fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
513
- json.dumps(fact.source_turn_ids), fact.session_id,
514
- _jd(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
515
- fact.lifecycle.value, _jd(fact.langevin_position),
516
- fact.emotional_valence, fact.emotional_arousal,
517
- fact.signal_type.value, fact.created_at, _scope, _shared),
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 all pinned facts for a profile, highest-importance first."""
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 * FROM atomic_facts WHERE {where} AND pinned = 1 "
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 validity record for a fact."""
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. Facts with no
1704
- temporal record or a record whose ``system_expired_at`` is NULL are
1705
- NOT returned (treated as valid), so existing DBs need no backfill.
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"WHERE fact_id IN ({placeholders}) "
1726
- f" AND profile_id = ? "
1727
- f" AND system_expired_at IS NOT NULL "
1728
- f" AND system_expired_at <= ?",
1729
- (*batch, profile_id, as_of),
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"WHERE fact_id IN ({placeholders}) "
1735
- f" AND profile_id = ? "
1736
- f" AND system_expired_at IS NOT NULL",
1737
- (*batch, profile_id),
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