superlocalmemory 3.2.1 → 3.2.3

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 (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +113 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. package/src/superlocalmemory/storage/v2_migrator.py +24 -2
@@ -0,0 +1,169 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """Access log for fact retrieval events.
6
+
7
+ Tracks when facts are accessed (recall, auto_invoke, search).
8
+ Used by Phase 2 auto-invoke for recency scoring (H1 fix).
9
+ All SQL parameterized (Rule 11). Silent errors (Rule 19).
10
+
11
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
+ License: MIT
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import uuid
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from superlocalmemory.storage.database import DatabaseManager
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _new_log_id() -> str:
28
+ """Generate a short unique log ID."""
29
+ return uuid.uuid4().hex[:16]
30
+
31
+
32
+ class AccessLog:
33
+ """CRUD operations for fact_access_log table."""
34
+
35
+ def __init__(self, db: DatabaseManager) -> None:
36
+ self._db = db
37
+
38
+ def store_access(
39
+ self,
40
+ fact_id: str,
41
+ profile_id: str,
42
+ access_type: str = "recall",
43
+ session_id: str = "",
44
+ ) -> str:
45
+ """Record a fact access event. Returns log_id.
46
+
47
+ Validates access_type against allowed values.
48
+ Returns empty string on failure (Rule 19).
49
+ """
50
+ allowed = ("recall", "auto_invoke", "search", "consolidation")
51
+ if access_type not in allowed:
52
+ access_type = "recall"
53
+
54
+ log_id = _new_log_id()
55
+ try:
56
+ self._db.execute(
57
+ "INSERT INTO fact_access_log "
58
+ "(log_id, fact_id, profile_id, access_type, session_id) "
59
+ "VALUES (?, ?, ?, ?, ?)",
60
+ (log_id, fact_id, profile_id, access_type, session_id),
61
+ )
62
+ return log_id
63
+ except Exception as exc:
64
+ logger.debug("store_access failed for fact_id=%s: %s", fact_id, exc)
65
+ return ""
66
+
67
+ def store_access_batch(
68
+ self,
69
+ fact_ids: list[str],
70
+ profile_id: str,
71
+ access_type: str = "recall",
72
+ session_id: str = "",
73
+ ) -> int:
74
+ """Record access events for multiple facts. Returns count stored.
75
+
76
+ Catches exceptions per-fact (Rule 19: silent errors).
77
+ """
78
+ count = 0
79
+ for fact_id in fact_ids:
80
+ result = self.store_access(
81
+ fact_id, profile_id, access_type, session_id,
82
+ )
83
+ if result:
84
+ count += 1
85
+ return count
86
+
87
+ def get_latest_access_time(
88
+ self, fact_id: str, profile_id: str,
89
+ ) -> str | None:
90
+ """Most recent access timestamp for a fact.
91
+
92
+ Returns ISO datetime string or None if never accessed.
93
+ """
94
+ try:
95
+ rows = self._db.execute(
96
+ "SELECT MAX(accessed_at) AS latest "
97
+ "FROM fact_access_log "
98
+ "WHERE fact_id = ? AND profile_id = ?",
99
+ (fact_id, profile_id),
100
+ )
101
+ if rows and rows[0]["latest"] is not None:
102
+ return str(rows[0]["latest"])
103
+ return None
104
+ except Exception as exc:
105
+ logger.debug("get_latest_access_time failed: %s", exc)
106
+ return None
107
+
108
+ def get_access_count(
109
+ self, fact_id: str, profile_id: str,
110
+ ) -> int:
111
+ """Total access count for a fact."""
112
+ try:
113
+ rows = self._db.execute(
114
+ "SELECT COUNT(*) AS c "
115
+ "FROM fact_access_log "
116
+ "WHERE fact_id = ? AND profile_id = ?",
117
+ (fact_id, profile_id),
118
+ )
119
+ if rows:
120
+ return int(rows[0]["c"])
121
+ return 0
122
+ except Exception as exc:
123
+ logger.debug("get_access_count failed: %s", exc)
124
+ return 0
125
+
126
+ def get_all_access_times(
127
+ self, profile_id: str, limit: int = 1000,
128
+ ) -> dict[str, str]:
129
+ """Latest access time for all facts in a profile.
130
+
131
+ Returns {fact_id: latest_accessed_at} dict.
132
+ """
133
+ try:
134
+ rows = self._db.execute(
135
+ "SELECT fact_id, MAX(accessed_at) AS latest "
136
+ "FROM fact_access_log "
137
+ "WHERE profile_id = ? "
138
+ "GROUP BY fact_id "
139
+ "ORDER BY latest DESC "
140
+ "LIMIT ?",
141
+ (profile_id, limit),
142
+ )
143
+ return {str(r["fact_id"]): str(r["latest"]) for r in rows}
144
+ except Exception as exc:
145
+ logger.debug("get_all_access_times failed: %s", exc)
146
+ return {}
147
+
148
+ def get_frequently_accessed(
149
+ self, profile_id: str, min_count: int = 3, limit: int = 100,
150
+ ) -> list[tuple[str, int]]:
151
+ """Facts accessed at least min_count times.
152
+
153
+ Returns [(fact_id, access_count)] sorted by count desc.
154
+ """
155
+ try:
156
+ rows = self._db.execute(
157
+ "SELECT fact_id, COUNT(*) AS cnt "
158
+ "FROM fact_access_log "
159
+ "WHERE profile_id = ? "
160
+ "GROUP BY fact_id "
161
+ "HAVING cnt >= ? "
162
+ "ORDER BY cnt DESC "
163
+ "LIMIT ?",
164
+ (profile_id, min_count, limit),
165
+ )
166
+ return [(str(r["fact_id"]), int(r["cnt"])) for r in rows]
167
+ except Exception as exc:
168
+ logger.debug("get_frequently_accessed failed: %s", exc)
169
+ return []
@@ -674,3 +674,291 @@ class DatabaseManager:
674
674
  )
675
675
  for r in rows
676
676
  ]
677
+
678
+ # ------------------------------------------------------------------
679
+ # Phase 2: fact_context CRUD (Auto-Invoke Engine)
680
+ # ------------------------------------------------------------------
681
+
682
+ def store_fact_context(
683
+ self,
684
+ fact_id: str,
685
+ profile_id: str,
686
+ contextual_description: str,
687
+ keywords: str,
688
+ generated_by: str = "rules",
689
+ ) -> None:
690
+ """Store or replace contextual description for a fact."""
691
+ self.execute(
692
+ "INSERT OR REPLACE INTO fact_context "
693
+ "(fact_id, profile_id, contextual_description, keywords, generated_by) "
694
+ "VALUES (?, ?, ?, ?, ?)",
695
+ (fact_id, profile_id, contextual_description, keywords, generated_by),
696
+ )
697
+
698
+ def get_fact_context(self, fact_id: str) -> dict | None:
699
+ """Get contextual description for a fact."""
700
+ rows = self.execute(
701
+ "SELECT * FROM fact_context WHERE fact_id = ?", (fact_id,),
702
+ )
703
+ return dict(rows[0]) if rows else None
704
+
705
+ def get_all_fact_contexts(self, profile_id: str) -> list[dict]:
706
+ """Get all contextual descriptions for a profile."""
707
+ rows = self.execute(
708
+ "SELECT * FROM fact_context WHERE profile_id = ?", (profile_id,),
709
+ )
710
+ return [dict(r) for r in rows]
711
+
712
+ def delete_fact_context(self, fact_id: str) -> None:
713
+ """Delete contextual description for a fact."""
714
+ self.execute("DELETE FROM fact_context WHERE fact_id = ?", (fact_id,))
715
+
716
+ # ------------------------------------------------------------------
717
+ # Phase 3: Association Graph CRUD (Rule 15)
718
+ # ------------------------------------------------------------------
719
+
720
+ def store_association_edge(self, edge: dict) -> None:
721
+ """Persist an association edge."""
722
+ self.execute(
723
+ "INSERT OR IGNORE INTO association_edges "
724
+ "(edge_id, profile_id, source_fact_id, target_fact_id, "
725
+ " association_type, weight, co_access_count, created_at) "
726
+ "VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
727
+ (edge["edge_id"], edge["profile_id"],
728
+ edge["source_fact_id"], edge["target_fact_id"],
729
+ edge["association_type"], edge["weight"],
730
+ edge.get("co_access_count", 0)),
731
+ )
732
+
733
+ def get_association_edges(
734
+ self, fact_id: str, profile_id: str,
735
+ ) -> list[dict]:
736
+ """All association edges where fact_id is source or target."""
737
+ rows = self.execute(
738
+ "SELECT * FROM association_edges WHERE profile_id = ? "
739
+ "AND (source_fact_id = ? OR target_fact_id = ?)",
740
+ (profile_id, fact_id, fact_id),
741
+ )
742
+ return [dict(r) for r in rows]
743
+
744
+ def get_all_association_edges(self, profile_id: str) -> list[dict]:
745
+ """All association edges for a profile."""
746
+ rows = self.execute(
747
+ "SELECT * FROM association_edges WHERE profile_id = ?",
748
+ (profile_id,),
749
+ )
750
+ return [dict(r) for r in rows]
751
+
752
+ def delete_association_edges(self, profile_id: str) -> int:
753
+ """Delete all association edges for a profile. Returns count."""
754
+ before = self.execute(
755
+ "SELECT COUNT(*) AS c FROM association_edges WHERE profile_id = ?",
756
+ (profile_id,),
757
+ )
758
+ count = int(before[0]["c"]) if before else 0
759
+ self.execute(
760
+ "DELETE FROM association_edges WHERE profile_id = ?",
761
+ (profile_id,),
762
+ )
763
+ return count
764
+
765
+ def store_activation_cache(self, entry: dict) -> None:
766
+ """Persist an activation cache entry."""
767
+ self.execute(
768
+ "INSERT OR REPLACE INTO activation_cache "
769
+ "(cache_id, profile_id, query_hash, node_id, activation_value, "
770
+ " iteration, created_at, expires_at) "
771
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))",
772
+ (entry["cache_id"], entry["profile_id"],
773
+ entry["query_hash"], entry["node_id"],
774
+ entry["activation_value"], entry["iteration"]),
775
+ )
776
+
777
+ def get_activation_cache(
778
+ self, query_hash: str, profile_id: str,
779
+ ) -> list[dict]:
780
+ """Get cached activation results (non-expired)."""
781
+ rows = self.execute(
782
+ "SELECT node_id, activation_value FROM activation_cache "
783
+ "WHERE profile_id = ? AND query_hash = ? "
784
+ "AND expires_at > datetime('now') "
785
+ "ORDER BY activation_value DESC",
786
+ (profile_id, query_hash),
787
+ )
788
+ return [dict(r) for r in rows]
789
+
790
+ def cleanup_activation_cache(self) -> int:
791
+ """Delete expired cache entries. Returns count deleted."""
792
+ before = self.execute(
793
+ "SELECT COUNT(*) AS c FROM activation_cache "
794
+ "WHERE expires_at < datetime('now')"
795
+ )
796
+ count = int(before[0]["c"]) if before else 0
797
+ self.execute(
798
+ "DELETE FROM activation_cache WHERE expires_at < datetime('now')"
799
+ )
800
+ return count
801
+
802
+ def store_fact_importance(self, entry: dict) -> None:
803
+ """Persist fact importance scores."""
804
+ self.execute(
805
+ "INSERT OR REPLACE INTO fact_importance "
806
+ "(fact_id, profile_id, pagerank_score, community_id, "
807
+ " degree_centrality, computed_at) "
808
+ "VALUES (?, ?, ?, ?, ?, datetime('now'))",
809
+ (entry["fact_id"], entry["profile_id"],
810
+ entry["pagerank_score"], entry.get("community_id"),
811
+ entry.get("degree_centrality", 0.0)),
812
+ )
813
+
814
+ def get_fact_importance(
815
+ self, fact_id: str, profile_id: str,
816
+ ) -> dict | None:
817
+ """Get importance scores for a fact."""
818
+ rows = self.execute(
819
+ "SELECT * FROM fact_importance "
820
+ "WHERE fact_id = ? AND profile_id = ?",
821
+ (fact_id, profile_id),
822
+ )
823
+ return dict(rows[0]) if rows else None
824
+
825
+ def get_top_facts_by_pagerank(
826
+ self, profile_id: str, top_k: int = 20,
827
+ ) -> list[dict]:
828
+ """Top facts by PageRank score."""
829
+ rows = self.execute(
830
+ "SELECT * FROM fact_importance "
831
+ "WHERE profile_id = ? "
832
+ "ORDER BY pagerank_score DESC LIMIT ?",
833
+ (profile_id, top_k),
834
+ )
835
+ return [dict(r) for r in rows]
836
+
837
+ # ------------------------------------------------------------------
838
+ # Phase 4: Temporal Intelligence CRUD (Rule 15)
839
+ # ------------------------------------------------------------------
840
+
841
+ def store_temporal_validity(
842
+ self, fact_id: str, profile_id: str,
843
+ valid_from: str | None = None,
844
+ valid_until: str | None = None,
845
+ ) -> None:
846
+ """Create temporal validity record for a fact."""
847
+ self.execute(
848
+ "INSERT OR IGNORE INTO fact_temporal_validity "
849
+ "(fact_id, profile_id, valid_from, valid_until) "
850
+ "VALUES (?, ?, ?, ?)",
851
+ (fact_id, profile_id, valid_from, valid_until),
852
+ )
853
+
854
+ def get_temporal_validity(self, fact_id: str) -> dict | None:
855
+ """Get temporal validity record for a fact."""
856
+ rows = self.execute(
857
+ "SELECT * FROM fact_temporal_validity WHERE fact_id = ?",
858
+ (fact_id,),
859
+ )
860
+ return dict(rows[0]) if rows else None
861
+
862
+ def get_all_temporal_validity(self, profile_id: str) -> list[dict]:
863
+ """Get all temporal validity records for a profile."""
864
+ rows = self.execute(
865
+ "SELECT * FROM fact_temporal_validity WHERE profile_id = ?",
866
+ (profile_id,),
867
+ )
868
+ return [dict(r) for r in rows]
869
+
870
+ def invalidate_fact_temporal(
871
+ self, fact_id: str, invalidated_by: str,
872
+ invalidation_reason: str,
873
+ ) -> None:
874
+ """Set valid_until and system_expired_at for a fact.
875
+
876
+ BOTH timestamps set atomically (BI-TEMPORAL INTEGRITY).
877
+ Never deletes the fact (Rule 17: immutability).
878
+ """
879
+ from datetime import UTC, datetime as _dt
880
+ now = _dt.now(UTC).isoformat()
881
+ self.execute(
882
+ "UPDATE fact_temporal_validity "
883
+ "SET valid_until = ?, system_expired_at = ?, "
884
+ " invalidated_by = ?, invalidation_reason = ? "
885
+ "WHERE fact_id = ?",
886
+ (now, now, invalidated_by, invalidation_reason, fact_id),
887
+ )
888
+
889
+ def get_valid_facts(self, profile_id: str) -> list[str]:
890
+ """Get fact_ids that are currently valid (not expired).
891
+
892
+ Returns facts that either have no temporal record (assumed valid)
893
+ or have valid_until IS NULL and system_expired_at IS NULL.
894
+ """
895
+ rows = self.execute(
896
+ "SELECT f.fact_id FROM atomic_facts f "
897
+ "LEFT JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
898
+ "WHERE f.profile_id = ? "
899
+ " AND (tv.fact_id IS NULL OR tv.valid_until IS NULL) "
900
+ " AND (tv.fact_id IS NULL OR tv.system_expired_at IS NULL)",
901
+ (profile_id,),
902
+ )
903
+ return [dict(r)["fact_id"] for r in rows]
904
+
905
+ def delete_temporal_validity(self, fact_id: str) -> None:
906
+ """Delete temporal validity record (for testing/rollback only)."""
907
+ self.execute(
908
+ "DELETE FROM fact_temporal_validity WHERE fact_id = ?",
909
+ (fact_id,),
910
+ )
911
+
912
+ # ------------------------------------------------------------------
913
+ # Phase 5: Core Memory Blocks CRUD (Rule 15)
914
+ # ------------------------------------------------------------------
915
+
916
+ def store_core_block(
917
+ self,
918
+ block_id: str,
919
+ profile_id: str,
920
+ block_type: str,
921
+ content: str,
922
+ source_fact_ids: str = "[]",
923
+ char_count: int = 0,
924
+ version: int = 1,
925
+ compiled_by: str = "rules",
926
+ ) -> None:
927
+ """Store or replace a Core Memory block.
928
+
929
+ Uses INSERT OR REPLACE on UNIQUE(profile_id, block_type)
930
+ to guarantee idempotency (L18).
931
+ """
932
+ self.execute(
933
+ "INSERT OR REPLACE INTO core_memory_blocks "
934
+ "(block_id, profile_id, block_type, content, source_fact_ids, "
935
+ " char_count, version, compiled_by, created_at, updated_at) "
936
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
937
+ (block_id, profile_id, block_type, content,
938
+ source_fact_ids, char_count, version, compiled_by),
939
+ )
940
+
941
+ def get_core_blocks(self, profile_id: str) -> list[dict]:
942
+ """Get all Core Memory blocks for a profile."""
943
+ rows = self.execute(
944
+ "SELECT * FROM core_memory_blocks "
945
+ "WHERE profile_id = ? ORDER BY block_type",
946
+ (profile_id,),
947
+ )
948
+ return [dict(r) for r in rows]
949
+
950
+ def get_core_block(self, profile_id: str, block_type: str) -> dict | None:
951
+ """Get a single Core Memory block by profile and type."""
952
+ rows = self.execute(
953
+ "SELECT * FROM core_memory_blocks "
954
+ "WHERE profile_id = ? AND block_type = ?",
955
+ (profile_id, block_type),
956
+ )
957
+ return dict(rows[0]) if rows else None
958
+
959
+ def delete_core_blocks(self, profile_id: str) -> None:
960
+ """Delete all Core Memory blocks for a profile."""
961
+ self.execute(
962
+ "DELETE FROM core_memory_blocks WHERE profile_id = ?",
963
+ (profile_id,),
964
+ )
@@ -705,6 +705,11 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
705
705
  for ddl in _DDL_ORDERED:
706
706
  conn.executescript(ddl)
707
707
 
708
+ # --- V3.2 schema extension (additive only) ---
709
+ from superlocalmemory.storage.schema_v32 import V32_DDL
710
+ for ddl in V32_DDL:
711
+ conn.executescript(ddl)
712
+
708
713
  # Seed schema version on first run.
709
714
  existing = conn.execute(
710
715
  "SELECT COUNT(*) AS n FROM schema_version"
@@ -728,6 +733,11 @@ def drop_all_tables(conn: sqlite3.Connection) -> None:
728
733
  Args:
729
734
  conn: An open SQLite connection. Caller manages commit.
730
735
  """
736
+ # V32 tables first (they may FK to base tables)
737
+ from superlocalmemory.storage.schema_v32 import V32_ROLLBACK
738
+ for sql in V32_ROLLBACK:
739
+ conn.execute(sql)
740
+
731
741
  # FTS + triggers first (depend on atomic_facts).
732
742
  for fts in _FTS_TABLES:
733
743
  conn.execute(f"DROP TABLE IF EXISTS {fts}")