superlocalmemory 4.0.4 → 4.0.6

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 (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import json
16
16
  import logging
17
17
  import os
18
+ import platform
18
19
  import sqlite3
19
20
  import threading
20
21
  import time
@@ -85,6 +86,10 @@ _BUSY_TIMEOUT_MS = _env_int("SLM_DB_BUSY_TIMEOUT_MS", 10_000) # wait for write
85
86
  _MAX_RETRIES = _env_int("SLM_DB_MAX_RETRIES", 5) # retry on SQLITE_BUSY
86
87
  _RETRY_BASE_DELAY = _env_float("SLM_DB_RETRY_BASE_DELAY", 0.1) # backoff base (s)
87
88
 
89
+ # Warn once per process, not once per connection, when the WAL close-path
90
+ # deadlock guard cannot be installed (Python < 3.12).
91
+ _NO_CKPT_WARNED = False
92
+
88
93
 
89
94
  def _unbounded_facts_ceiling() -> int:
90
95
  """Hard upper bound applied when a fact fetch is called with limit=None, so
@@ -231,6 +236,37 @@ class DatabaseManager:
231
236
  conn.row_factory = sqlite3.Row
232
237
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
233
238
  conn.execute("PRAGMA foreign_keys=ON")
239
+ # wal_autocheckpoint is a PER-CONNECTION pragma and is NOT persisted in
240
+ # the database file (unlike journal_mode=WAL). Setting it only on the
241
+ # short-lived initialisation connection left every working connection
242
+ # on SQLite's default of 1000 frames. With checkpoint-on-close
243
+ # disabled below, autocheckpoint is the ONLY remaining checkpoint path,
244
+ # so the intended value must be set where the writes actually happen.
245
+ conn.execute("PRAGMA wal_autocheckpoint=400")
246
+ # Deadlock hardening (postmortem 2026-08-13, Option B): WAL close
247
+ # triggers a checkpoint that can wait indefinitely on reader marks
248
+ # pinned by another process/thread — while holding SQLite's
249
+ # process-global VFS mutex, which convoys every later connect().
250
+ # busy_timeout does NOT apply to the close path. NO_CKPT_ON_CLOSE
251
+ # makes close() checkpoint-free so it can never block; normal
252
+ # checkpointing continues via the wal_autocheckpoint set above.
253
+ # Available since Python 3.12 / SQLite 3.31; guarded for portability.
254
+ try:
255
+ conn.setconfig(sqlite3.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, 1) # type: ignore[attr-defined]
256
+ except (AttributeError, sqlite3.OperationalError):
257
+ # Silent degradation would hide an inactive deadlock guard on a
258
+ # supported interpreter (requires-python allows 3.11, which
259
+ # predates Connection.setconfig). Warn once, not per connection.
260
+ global _NO_CKPT_WARNED
261
+ if not _NO_CKPT_WARNED:
262
+ _NO_CKPT_WARNED = True
263
+ logger.warning(
264
+ "SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE unavailable (Python %s, "
265
+ "SQLite %s); WAL close-path deadlock hardening is INACTIVE. "
266
+ "Python 3.12+ is required for this protection.",
267
+ platform.python_version(),
268
+ sqlite3.sqlite_version,
269
+ )
234
270
  return conn
235
271
 
236
272
  @contextmanager
@@ -569,6 +605,62 @@ class DatabaseManager:
569
605
  created_at=d["created_at"],
570
606
  )
571
607
 
608
+ def insert_fact_immutable(self, fact: AtomicFact) -> str:
609
+ """Insert one known-new fact without content deduplication or replacement.
610
+
611
+ Reviewed correction successors require a caller-chosen immutable
612
+ identity. Unlike normal remember ingestion, equal content must not
613
+ reinforce an existing row, and an occupied ID must abort the enclosing
614
+ transaction rather than overwrite history.
615
+ """
616
+ scope = getattr(fact, "scope", None) or "personal"
617
+ shared = _jd(getattr(fact, "shared_with", None))
618
+ self.execute(
619
+ """INSERT INTO atomic_facts
620
+ (fact_id, memory_id, profile_id, content, fact_type,
621
+ entities_json, canonical_entities_json,
622
+ observation_date, referenced_date, interval_start, interval_end,
623
+ confidence, importance, evidence_count, access_count,
624
+ source_turn_ids_json, session_id,
625
+ embedding, fisher_mean, fisher_variance,
626
+ lifecycle, langevin_position,
627
+ emotional_valence, emotional_arousal, signal_type, created_at,
628
+ scope, shared_with)
629
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
630
+ (
631
+ fact.fact_id,
632
+ fact.memory_id,
633
+ fact.profile_id,
634
+ fact.content,
635
+ fact.fact_type.value,
636
+ json.dumps(fact.entities),
637
+ json.dumps(fact.canonical_entities),
638
+ fact.observation_date,
639
+ fact.referenced_date,
640
+ fact.interval_start,
641
+ fact.interval_end,
642
+ fact.confidence,
643
+ fact.importance,
644
+ fact.evidence_count,
645
+ fact.access_count,
646
+ json.dumps(fact.source_turn_ids),
647
+ fact.session_id,
648
+ _jd(fact.embedding),
649
+ _jd(fact.fisher_mean),
650
+ _jd(fact.fisher_variance),
651
+ fact.lifecycle.value,
652
+ _jd(fact.langevin_position),
653
+ fact.emotional_valence,
654
+ fact.emotional_arousal,
655
+ fact.signal_type.value,
656
+ fact.created_at,
657
+ scope,
658
+ shared,
659
+ ),
660
+ )
661
+ self.store_temporal_validity(fact.fact_id, fact.profile_id)
662
+ return fact.fact_id
663
+
572
664
  def set_pinned(self, fact_id: str, pinned: bool) -> None:
573
665
  """Set or clear the pinned flag on a fact (v3.4.65 core-memory)."""
574
666
  self.execute(
@@ -604,7 +696,23 @@ class DatabaseManager:
604
696
  "ORDER BY importance DESC",
605
697
  (*params,),
606
698
  )
607
- return [self._row_to_fact(r) for r in rows]
699
+ facts = [self._row_to_fact(r) for r in rows]
700
+ if not facts:
701
+ return facts
702
+ try:
703
+ blocked = self.get_nonapplied_correction_successor_ids(
704
+ [fact.fact_id for fact in facts],
705
+ profile_id,
706
+ include_global=include_global,
707
+ include_shared=include_shared,
708
+ )
709
+ except Exception as exc:
710
+ logger.warning("Pinned correction admission lookup failed: %s", exc)
711
+ return []
712
+ if not isinstance(blocked, set):
713
+ logger.warning("Pinned correction admission returned malformed data")
714
+ return []
715
+ return [fact for fact in facts if fact.fact_id not in blocked]
608
716
 
609
717
  def _has_archive_status(self) -> bool:
610
718
  """Whether atomic_facts carries the M011 ``archive_status`` column.
@@ -1647,12 +1755,14 @@ class DatabaseManager:
1647
1755
  def invalidate_fact_temporal(
1648
1756
  self, fact_id: str, invalidated_by: str,
1649
1757
  invalidation_reason: str,
1758
+ *,
1759
+ event_valid_until: str | None = None,
1650
1760
  ) -> None:
1651
1761
  """Mark a fact as invalidated, preserving bi-temporal independence.
1652
1762
 
1653
- - valid_until (event-time): when the fact ceased to be true in the
1654
- real world. Sourced from the fact's referenced_date so the
1655
- real-world boundary is preserved, not overwritten with wall-clock time.
1763
+ - valid_until (event-time): changed only when a reviewer supplies an
1764
+ independently validated real-world boundary. Review time, source
1765
+ timestamps, and a detector's conclusion are not a valid substitute.
1656
1766
  - system_expired_at (transaction-time): when the system learned the
1657
1767
  fact was invalid — always set to now.
1658
1768
 
@@ -1666,29 +1776,12 @@ class DatabaseManager:
1666
1776
  from datetime import datetime as _dt
1667
1777
  now = _dt.now(UTC).isoformat()
1668
1778
 
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
1779
  self.execute(
1687
1780
  "UPDATE fact_temporal_validity "
1688
- "SET valid_until = ?, system_expired_at = ?, "
1781
+ "SET valid_until = COALESCE(?, valid_until), system_expired_at = ?, "
1689
1782
  " invalidated_by = ?, invalidation_reason = ? "
1690
- "WHERE fact_id = ?",
1691
- (valid_until, now, invalidated_by, invalidation_reason, fact_id),
1783
+ "WHERE fact_id = ? AND system_expired_at IS NULL",
1784
+ (event_valid_until, now, invalidated_by, invalidation_reason, fact_id),
1692
1785
  )
1693
1786
 
1694
1787
  def get_valid_facts(self, profile_id: str) -> list[str]:
@@ -1804,6 +1897,119 @@ class DatabaseManager:
1804
1897
  invalid.add(dict(r)["fact_id"])
1805
1898
  return invalid
1806
1899
 
1900
+ def get_nonapplied_correction_successor_ids(
1901
+ self,
1902
+ fact_ids: list[str],
1903
+ profile_id: str,
1904
+ *,
1905
+ include_global: bool = False,
1906
+ include_shared: bool = False,
1907
+ ) -> set[str]:
1908
+ """Return candidate successors that are not current review truth.
1909
+
1910
+ M042 is optional for older databases. Its absence is safe because
1911
+ canonical proposal never commits a successor unless the same
1912
+ transaction also writes M042. Once present, a read failure must reach
1913
+ the retrieval fail-closed boundary rather than be converted to empty.
1914
+ """
1915
+ if not fact_ids:
1916
+ return set()
1917
+ present = self.execute(
1918
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
1919
+ )
1920
+ if not present:
1921
+ return set()
1922
+ scope_where, scope_params = _scope_where(
1923
+ profile_id,
1924
+ include_global=include_global,
1925
+ include_shared=include_shared,
1926
+ prefix="f",
1927
+ )
1928
+ inadmissible: set[str] = set()
1929
+ for start in range(0, len(fact_ids), 900):
1930
+ batch = fact_ids[start:start + 900]
1931
+ placeholders = ",".join("?" for _ in batch)
1932
+ rows = self.execute(
1933
+ "SELECT c.successor_fact_id FROM correction_cases c "
1934
+ "JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
1935
+ f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
1936
+ "AND c.profile_id=f.profile_id "
1937
+ "AND c.status IN ('proposed', 'rejected', 'rolled_back')",
1938
+ (*batch, *scope_params),
1939
+ )
1940
+ inadmissible.update(str(row["successor_fact_id"]) for row in rows)
1941
+ return inadmissible
1942
+
1943
+ def get_correction_inadmissible_fact_ids(
1944
+ self,
1945
+ fact_ids: list[str],
1946
+ profile_id: str,
1947
+ as_of: str | None = None,
1948
+ *,
1949
+ include_global: bool = False,
1950
+ include_shared: bool = False,
1951
+ ) -> set[str]:
1952
+ """Return current-lifecycle exclusions with one bounded SQLite read.
1953
+
1954
+ Recall needs both sides of reviewed correction truth: an expired
1955
+ predecessor and a successor whose case is not applied. The older
1956
+ public helpers preserve their focused contracts, but invoking them
1957
+ consecutively opened two SQLite connections on every candidate stage.
1958
+ This read-model helper uses one connection and one UNION query while
1959
+ retaining the same profile/scope and historical ``as_of`` semantics.
1960
+ It is intentionally read-only and does not cache lifecycle state.
1961
+ """
1962
+ if not fact_ids:
1963
+ return set()
1964
+ scope_where, scope_params = _scope_where(
1965
+ profile_id,
1966
+ include_global=include_global,
1967
+ include_shared=include_shared,
1968
+ prefix="f",
1969
+ )
1970
+ inadmissible: set[str] = set()
1971
+ with self.raw_connection() as conn:
1972
+ correction_table = conn.execute(
1973
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
1974
+ ).fetchone()
1975
+ for start in range(0, len(fact_ids), 900):
1976
+ batch = fact_ids[start:start + 900]
1977
+ placeholders = ",".join("?" for _ in batch)
1978
+ temporal_conditions = (
1979
+ "AND tv.system_expired_at IS NOT NULL "
1980
+ + ("AND tv.system_expired_at <= ?" if as_of is not None else "")
1981
+ )
1982
+ temporal_sql = (
1983
+ "SELECT tv.fact_id AS fact_id "
1984
+ "FROM fact_temporal_validity tv "
1985
+ "JOIN atomic_facts f ON f.fact_id=tv.fact_id "
1986
+ f"WHERE tv.fact_id IN ({placeholders}) AND {scope_where} "
1987
+ "AND tv.profile_id=f.profile_id "
1988
+ f"{temporal_conditions}"
1989
+ )
1990
+ temporal_params: tuple[Any, ...] = (
1991
+ *batch,
1992
+ *scope_params,
1993
+ *((as_of,) if as_of is not None else ()),
1994
+ )
1995
+ if correction_table is None:
1996
+ rows = conn.execute(temporal_sql, temporal_params).fetchall()
1997
+ else:
1998
+ correction_sql = (
1999
+ "SELECT c.successor_fact_id AS fact_id "
2000
+ "FROM correction_cases c "
2001
+ "JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
2002
+ f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
2003
+ "AND c.profile_id=f.profile_id "
2004
+ "AND c.status IN ('proposed', 'rejected', 'rolled_back')"
2005
+ )
2006
+ rows = conn.execute(
2007
+ f"{temporal_sql} UNION {correction_sql}",
2008
+ (*temporal_params, *batch, *scope_params),
2009
+ ).fetchall()
2010
+ inadmissible.update(str(row["fact_id"]) for row in rows)
2011
+ return inadmissible
2012
+
1807
2013
  def get_strict_temporal_inadmissible_fact_ids(
1808
2014
  self,
1809
2015
  fact_ids: list[str],
@@ -157,6 +157,9 @@ from superlocalmemory.storage.migrations import (
157
157
  from superlocalmemory.storage.migrations import (
158
158
  M041_external_evidence_receipts as _M041,
159
159
  )
160
+ from superlocalmemory.storage.migrations import (
161
+ M042_correction_case_ledger as _M042,
162
+ )
160
163
  from superlocalmemory.storage._schema_version import (
161
164
  SUPPORTED_SCHEMA_VERSION,
162
165
  SchemaVersionError,
@@ -239,6 +242,10 @@ MIGRATIONS: list[Migration] = [
239
242
  dependencies=(_M003.NAME,)),
240
243
  Migration(name=_M041.NAME, db_target="learning", ddl=_M041.DDL,
241
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,)),
242
249
  # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
243
250
  ]
244
251
 
@@ -0,0 +1,245 @@
1
+ """M042 — review-gated correction cases in ``memory.db``.
2
+
3
+ The ledger contains identifiers and lifecycle metadata only. It never stores
4
+ fact text. The saved predecessor temporal tuple lets a reviewed rollback
5
+ restore the exact lifecycle state without deleting fact history.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+
12
+ NAME = "M042_correction_case_ledger"
13
+ DB_TARGET = "memory"
14
+
15
+ DDL = """
16
+ BEGIN IMMEDIATE;
17
+ CREATE TABLE IF NOT EXISTS correction_cases (
18
+ case_id TEXT PRIMARY KEY,
19
+ profile_id TEXT NOT NULL,
20
+ scope TEXT NOT NULL CHECK (scope IN ('personal', 'project', 'shared', 'global')),
21
+ predecessor_fact_id TEXT NOT NULL,
22
+ successor_fact_id TEXT NOT NULL,
23
+ reason_code TEXT NOT NULL,
24
+ status TEXT NOT NULL CHECK (status IN ('proposed', 'applied', 'rejected', 'rolled_back')),
25
+ version INTEGER NOT NULL CHECK (version >= 0),
26
+ idempotency_key TEXT NOT NULL,
27
+ proposed_by_actor_id TEXT NOT NULL,
28
+ proposed_by_actor_kind TEXT NOT NULL,
29
+ proposed_by_trust_tier TEXT NOT NULL,
30
+ created_at TEXT NOT NULL,
31
+ updated_at TEXT NOT NULL,
32
+ reviewed_by_actor_id TEXT,
33
+ reviewed_at TEXT,
34
+ applied_at TEXT,
35
+ system_effective_at TEXT,
36
+ event_valid_from TEXT,
37
+ event_valid_until TEXT,
38
+ predecessor_temporal_existed INTEGER,
39
+ predecessor_valid_from TEXT,
40
+ predecessor_valid_until TEXT,
41
+ predecessor_system_created_at TEXT,
42
+ predecessor_system_expired_at TEXT,
43
+ predecessor_invalidated_by TEXT,
44
+ predecessor_invalidation_reason TEXT,
45
+ UNIQUE (profile_id, idempotency_key),
46
+ FOREIGN KEY (predecessor_fact_id) REFERENCES atomic_facts(fact_id) ON DELETE RESTRICT,
47
+ FOREIGN KEY (successor_fact_id) REFERENCES atomic_facts(fact_id) ON DELETE RESTRICT
48
+ );
49
+ CREATE TABLE IF NOT EXISTS correction_events (
50
+ event_id TEXT PRIMARY KEY,
51
+ case_id TEXT NOT NULL,
52
+ profile_id TEXT NOT NULL,
53
+ scope TEXT NOT NULL CHECK (scope IN ('personal', 'project', 'shared', 'global')),
54
+ event_type TEXT NOT NULL
55
+ CHECK (event_type IN ('proposed', 'applied', 'rejected', 'rolled_back')),
56
+ operation_id TEXT NOT NULL,
57
+ actor_id TEXT NOT NULL,
58
+ actor_kind TEXT NOT NULL,
59
+ actor_trust_tier TEXT NOT NULL,
60
+ expected_version INTEGER,
61
+ resulting_version INTEGER NOT NULL CHECK (resulting_version >= 0),
62
+ system_occurred_at TEXT NOT NULL,
63
+ event_valid_from TEXT,
64
+ event_valid_until TEXT,
65
+ UNIQUE (case_id, operation_id),
66
+ FOREIGN KEY (case_id) REFERENCES correction_cases(case_id) ON DELETE RESTRICT
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_correction_cases_profile_status
69
+ ON correction_cases (profile_id, status, updated_at DESC);
70
+ CREATE INDEX IF NOT EXISTS idx_correction_events_case_sequence
71
+ ON correction_events (case_id, system_occurred_at ASC);
72
+ CREATE INDEX IF NOT EXISTS idx_correction_cases_successor_admission
73
+ ON correction_cases (profile_id, successor_fact_id, status);
74
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_correction_cases_active_predecessor
75
+ ON correction_cases (profile_id, predecessor_fact_id)
76
+ WHERE status IN ('proposed', 'applied');
77
+ COMMIT;
78
+ """
79
+
80
+ _TABLES = frozenset({"correction_cases", "correction_events"})
81
+ _FORBIDDEN_RAW_COLUMNS = frozenset({"content", "fact_text", "raw_text", "query"})
82
+ _REQUIRED_CASE_COLUMNS = frozenset(
83
+ {
84
+ "case_id",
85
+ "profile_id",
86
+ "scope",
87
+ "predecessor_fact_id",
88
+ "successor_fact_id",
89
+ "reason_code",
90
+ "status",
91
+ "version",
92
+ "idempotency_key",
93
+ "proposed_by_actor_id",
94
+ "proposed_by_actor_kind",
95
+ "proposed_by_trust_tier",
96
+ "created_at",
97
+ "updated_at",
98
+ "reviewed_by_actor_id",
99
+ "reviewed_at",
100
+ "applied_at",
101
+ "system_effective_at",
102
+ "event_valid_from",
103
+ "event_valid_until",
104
+ "predecessor_temporal_existed",
105
+ "predecessor_valid_from",
106
+ "predecessor_valid_until",
107
+ "predecessor_system_created_at",
108
+ "predecessor_system_expired_at",
109
+ "predecessor_invalidated_by",
110
+ "predecessor_invalidation_reason",
111
+ }
112
+ )
113
+ _REQUIRED_EVENT_COLUMNS = frozenset(
114
+ {
115
+ "event_id",
116
+ "case_id",
117
+ "profile_id",
118
+ "scope",
119
+ "event_type",
120
+ "operation_id",
121
+ "actor_id",
122
+ "actor_kind",
123
+ "actor_trust_tier",
124
+ "expected_version",
125
+ "resulting_version",
126
+ "system_occurred_at",
127
+ "event_valid_from",
128
+ "event_valid_until",
129
+ }
130
+ )
131
+ _INDEX_SPECS = {
132
+ "idx_correction_cases_profile_status": (
133
+ "correction_cases",
134
+ ("profile_id", "status", "updated_at"),
135
+ False,
136
+ None,
137
+ ),
138
+ "idx_correction_events_case_sequence": (
139
+ "correction_events",
140
+ ("case_id", "system_occurred_at"),
141
+ False,
142
+ None,
143
+ ),
144
+ "idx_correction_cases_successor_admission": (
145
+ "correction_cases",
146
+ ("profile_id", "successor_fact_id", "status"),
147
+ False,
148
+ None,
149
+ ),
150
+ "uq_correction_cases_active_predecessor": (
151
+ "correction_cases",
152
+ ("profile_id", "predecessor_fact_id"),
153
+ True,
154
+ "wherestatusin('proposed','applied')",
155
+ ),
156
+ }
157
+
158
+
159
+ def apply(conn: sqlite3.Connection) -> None:
160
+ """Install the additive review ledger atomically and idempotently."""
161
+ if any(_table_exists(conn, table) for table in _TABLES) and not verify(conn):
162
+ raise sqlite3.OperationalError("M042 correction ledger is malformed; refusing rebuild")
163
+ conn.executescript(DDL)
164
+ if not verify(conn):
165
+ raise sqlite3.OperationalError("M042 correction ledger did not reach its end-state")
166
+
167
+
168
+ def verify(conn: sqlite3.Connection) -> bool:
169
+ if not all(_table_exists(conn, table) for table in _TABLES):
170
+ return False
171
+ case_columns = _columns(conn, "correction_cases")
172
+ event_columns = _columns(conn, "correction_events")
173
+ if (
174
+ set(case_columns) != _REQUIRED_CASE_COLUMNS
175
+ or set(event_columns) != _REQUIRED_EVENT_COLUMNS
176
+ or _FORBIDDEN_RAW_COLUMNS & (set(case_columns) | set(event_columns))
177
+ ):
178
+ return False
179
+ if not _required_checks_present(conn):
180
+ return False
181
+ return all(_index_matches(conn, name, *spec) for name, spec in _INDEX_SPECS.items())
182
+
183
+
184
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
185
+ return (
186
+ conn.execute(
187
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
188
+ ).fetchone()
189
+ is not None
190
+ )
191
+
192
+
193
+ def _columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]:
194
+ return tuple(row[1] for row in conn.execute(f"PRAGMA table_info({table})"))
195
+
196
+
197
+ def _required_checks_present(conn: sqlite3.Connection) -> bool:
198
+ statements: dict[str, str] = {}
199
+ for table in ("correction_cases", "correction_events"):
200
+ row = conn.execute(
201
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
202
+ ).fetchone()
203
+ if row is None or row[0] is None:
204
+ return False
205
+ statements[table] = "".join(str(row[0]).lower().split())
206
+ case_sql = statements["correction_cases"]
207
+ event_sql = statements["correction_events"]
208
+ scope_check = "check(scopein('personal','project','shared','global'))"
209
+ return (
210
+ scope_check in case_sql
211
+ and scope_check in event_sql
212
+ and "check(statusin('proposed','applied','rejected','rolled_back'))" in case_sql
213
+ and "check(event_typein('proposed','applied','rejected','rolled_back'))" in event_sql
214
+ and "unique(profile_id,idempotency_key)" in case_sql
215
+ and "unique(case_id,operation_id)" in event_sql
216
+ )
217
+
218
+
219
+ def _index_matches(
220
+ conn: sqlite3.Connection,
221
+ name: str,
222
+ table: str,
223
+ columns: tuple[str, ...],
224
+ unique: bool,
225
+ where_clause: str | None,
226
+ ) -> bool:
227
+ """Verify an index's table, ordered columns, uniqueness, and partial predicate."""
228
+ row = conn.execute(
229
+ "SELECT tbl_name, sql FROM sqlite_master WHERE type='index' AND name=?", (name,)
230
+ ).fetchone()
231
+ if row is None or row[0] != table:
232
+ return False
233
+ index_rows = conn.execute(f"PRAGMA index_xinfo({name})").fetchall()
234
+ indexed_columns = tuple(
235
+ entry[2] for entry in index_rows if entry[5] == 1 and entry[2] is not None
236
+ )
237
+ if indexed_columns != columns:
238
+ return False
239
+ index_list = conn.execute(f"PRAGMA index_list({table})").fetchall()
240
+ indexed = next((entry for entry in index_list if entry[1] == name), None)
241
+ if indexed is None or bool(indexed[2]) is not unique:
242
+ return False
243
+ if where_clause is None:
244
+ return True
245
+ return row[1] is not None and where_clause in "".join(str(row[1]).lower().split())
@@ -32,6 +32,7 @@ from . import (
32
32
  M039_scene_fact_members,
33
33
  M040_agent_experience_receipts,
34
34
  M041_external_evidence_receipts,
35
+ M042_correction_case_ledger,
35
36
  )
36
37
 
37
38
  # ---------------------------------------------------------------------------
@@ -89,6 +90,7 @@ __all__ = (
89
90
  "M039_scene_fact_members",
90
91
  "M040_agent_experience_receipts",
91
92
  "M041_external_evidence_receipts",
93
+ "M042_correction_case_ledger",
92
94
  # Legacy re-exports (backward compat):
93
95
  "CURRENT_SCHEMA_VERSION",
94
96
  "get_schema_version",
@@ -73,11 +73,19 @@ class SignalType(str, Enum):
73
73
 
74
74
 
75
75
  class Mode(str, Enum):
76
- """Operating modes — EU AI Act alignment."""
76
+ """Operating modes.
77
+
78
+ A — All data stays on this device. No AI language model runs anywhere.
79
+ Fastest and most private.
80
+ B — All data stays on this device. Uses a local Ollama AI model to
81
+ improve recall quality. Requires Ollama to be installed and running.
82
+ C — Uses a cloud AI provider (OpenAI, Anthropic, …) for the best recall
83
+ quality. Queries leave this device; an API key is required.
84
+ """
77
85
 
78
- A = "a" # Local Guardian: zero LLM, EU AI Act FULL
79
- B = "b" # Smart Local: local Ollama LLM, EU AI Act FULL
80
- C = "c" # Full Power: UNRESTRICTED, best models, 90%+ target
86
+ A = "a" # Local Guardian on-device only, no LLM
87
+ B = "b" # Smart Local — on-device + local Ollama LLM, no cloud
88
+ C = "c" # Cloud LLM best accuracy, queries leave device, API key needed
81
89
 
82
90
 
83
91
  # ---------------------------------------------------------------------------
@@ -96,6 +96,10 @@ class CommandKind(StrEnum):
96
96
  ADMISSION = "admission"
97
97
  DELETE_FACT = "delete_fact"
98
98
  UPDATE_FACT = "update_fact"
99
+ PROPOSE_CORRECTION = "propose_correction"
100
+ APPLY_CORRECTION = "apply_correction"
101
+ REJECT_CORRECTION = "reject_correction"
102
+ ROLLBACK_CORRECTION = "rollback_correction"
99
103
  ARCHIVE_FACT = "archive_fact"
100
104
  MERGE_FACT = "merge_fact"
101
105
  SET_FACT_SCOPE = "set_fact_scope"
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SuperLocalMemory issue #113 — Personal Memory Views.
6
+
7
+ Three bounded, profile-scoped, traceable summaries:
8
+
9
+ Session Summary — what happened in a specific session (data is sparse:
10
+ only ~3.9% of facts carry a session_id on a real store;
11
+ coverage is always disclosed explicitly).
12
+ Daily Reflection — what was recorded on a specific date.
13
+ Project Work Log — what tool events and facts belong to a project,
14
+ scoped by tool_events.project_path (NOT by
15
+ entity_profiles.project_name, which has one distinct
16
+ value across 1,148 rows and is useless for scoping).
17
+
18
+ Every SummaryResult carries:
19
+ - source_fact_ids for traceability (maintainer's binding constraint)
20
+ - profile_id — cross-profile access is not permitted
21
+ - coverage — honest assessment; never silently partial
22
+
23
+ Deferred to 4.0.7: prompt-driven custom views. They are non-deterministic,
24
+ hard to make traceable, and a prompt-injection surface.
25
+ """
26
+
27
+ from .base import SummaryResult
28
+ from .daily_reflection import generate_daily_reflection
29
+ from .project_work_log import generate_project_work_log
30
+ from .session_summary import generate_session_summary
31
+
32
+ __all__ = [
33
+ "SummaryResult",
34
+ "generate_session_summary",
35
+ "generate_daily_reflection",
36
+ "generate_project_work_log",
37
+ ]