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.
- package/CHANGELOG.md +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -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 +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- 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
|
-
|
|
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):
|
|
1654
|
-
real
|
|
1655
|
-
|
|
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
|
-
(
|
|
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
|
|
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
|
|
79
|
-
B = "b" # Smart Local
|
|
80
|
-
C = "c" #
|
|
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
|
+
]
|