superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  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 +1 -1
  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 +1 -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 +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -12,19 +12,34 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- import json, logging, os, sqlite3, threading, time
15
+ import json
16
+ import logging
17
+ import os
18
+ import sqlite3
19
+ import threading
20
+ import time
16
21
  from contextlib import contextmanager
17
22
  from pathlib import Path
18
23
  from types import ModuleType
19
24
  from typing import Any, Generator
20
25
 
21
- from superlocalmemory.storage.write_lock import get_write_lock
22
26
  from superlocalmemory.storage.models import (
23
- AtomicFact, CanonicalEntity, ConsolidationAction, ConsolidationActionType,
24
- EdgeType, EntityAlias, EntityProfile, FactType, GraphEdge,
25
- MemoryLifecycle, MemoryRecord, MemoryScene, SignalType, TemporalEvent,
27
+ AtomicFact,
28
+ CanonicalEntity,
29
+ ConsolidationAction,
30
+ EdgeType,
31
+ EntityAlias,
32
+ EntityProfile,
33
+ FactType,
34
+ GraphEdge,
35
+ MemoryLifecycle,
36
+ MemoryRecord,
37
+ MemoryScene,
38
+ SignalType,
39
+ TemporalEvent,
26
40
  TrustScore,
27
41
  )
42
+ from superlocalmemory.storage.write_lock import get_write_lock
28
43
 
29
44
  logger = logging.getLogger(__name__)
30
45
 
@@ -559,6 +574,24 @@ class DatabaseManager:
559
574
  )
560
575
  return [self._row_to_fact(r) for r in rows]
561
576
 
577
+ def _has_archive_status(self) -> bool:
578
+ """Whether atomic_facts carries the M011 ``archive_status`` column.
579
+
580
+ M011 is a DEFERRED migration, so the column is absent until it runs;
581
+ callers must not filter on a column that may not exist. Cached once True
582
+ (a column never disappears); re-checked while absent so a later deferred
583
+ migration is picked up.
584
+ """
585
+ if getattr(self, "_archive_col_present", False):
586
+ return True
587
+ present = any(
588
+ dict(row).get("name") == "archive_status"
589
+ for row in self.execute("PRAGMA table_info(atomic_facts)")
590
+ )
591
+ if present:
592
+ self._archive_col_present = True
593
+ return present
594
+
562
595
  def get_all_facts(
563
596
  self, profile_id: str, limit: int | None = None,
564
597
  *,
@@ -581,8 +614,14 @@ class DatabaseManager:
581
614
  # hard, env-tunable ceiling even when the caller passes limit=None.
582
615
  if limit is None:
583
616
  limit = _unbounded_facts_ceiling()
617
+ # Archived facts are not live; never surface them in direct reads.
618
+ archive_clause = (
619
+ " AND COALESCE(archive_status, 'live') != 'archived'"
620
+ if self._has_archive_status()
621
+ else ""
622
+ )
584
623
  rows = self.execute(
585
- f"SELECT * FROM atomic_facts WHERE {where} "
624
+ f"SELECT * FROM atomic_facts WHERE {where}{archive_clause} "
586
625
  "ORDER BY created_at DESC LIMIT ?",
587
626
  (*params, int(limit)),
588
627
  )
@@ -610,9 +649,14 @@ class DatabaseManager:
610
649
  include_global=include_global,
611
650
  include_shared=include_shared,
612
651
  )
652
+ archive_clause = (
653
+ " AND COALESCE(archive_status, 'live') != 'archived'"
654
+ if self._has_archive_status()
655
+ else ""
656
+ )
613
657
  rows = self.execute(
614
- f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ? "
615
- "ORDER BY created_at DESC",
658
+ f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ?"
659
+ f"{archive_clause} ORDER BY created_at DESC",
616
660
  (*params, profile_id),
617
661
  )
618
662
  return [self._row_to_fact(r) for r in rows]
@@ -1039,10 +1083,16 @@ class DatabaseManager:
1039
1083
  include_shared=include_shared,
1040
1084
  prefix="f",
1041
1085
  )
1086
+ # Archived facts must not surface via full-text search either.
1087
+ archive_clause = (
1088
+ " AND COALESCE(f.archive_status, 'live') != 'archived'"
1089
+ if self._has_archive_status()
1090
+ else ""
1091
+ )
1042
1092
  rows = self.execute(
1043
1093
  f"""SELECT f.* FROM atomic_facts_fts AS fts
1044
1094
  JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
1045
- WHERE fts.atomic_facts_fts MATCH ? AND {where}
1095
+ WHERE fts.atomic_facts_fts MATCH ? AND {where}{archive_clause}
1046
1096
  ORDER BY fts.rank LIMIT ?""",
1047
1097
  (match_expr, *params, limit),
1048
1098
  )
@@ -1104,10 +1154,15 @@ class DatabaseManager:
1104
1154
  include_global=include_global,
1105
1155
  include_shared=include_shared,
1106
1156
  )
1157
+ archive_clause = (
1158
+ " AND COALESCE(archive_status, 'live') != 'archived'"
1159
+ if self._has_archive_status()
1160
+ else ""
1161
+ )
1107
1162
  placeholders = ",".join("?" for _ in fact_ids)
1108
1163
  rows = self.execute(
1109
1164
  f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
1110
- f"AND {where} ORDER BY created_at DESC",
1165
+ f"AND {where}{archive_clause} ORDER BY created_at DESC",
1111
1166
  (*fact_ids, *params),
1112
1167
  )
1113
1168
  return [self._row_to_fact(r) for r in rows]
@@ -1545,19 +1600,47 @@ class DatabaseManager:
1545
1600
  self, fact_id: str, invalidated_by: str,
1546
1601
  invalidation_reason: str,
1547
1602
  ) -> None:
1548
- """Set valid_until and system_expired_at for a fact.
1603
+ """Mark a fact as invalidated, preserving bi-temporal independence.
1604
+
1605
+ - valid_until (event-time): when the fact ceased to be true in the
1606
+ real world. Sourced from the fact's referenced_date so the
1607
+ real-world boundary is preserved, not overwritten with wall-clock time.
1608
+ - system_expired_at (transaction-time): when the system learned the
1609
+ fact was invalid — always set to now.
1610
+
1611
+ The two dimensions must remain independent: a fact can be true until
1612
+ 2020-06-30 in the real world (valid_until) while the system only
1613
+ discovers this in 2024 (system_expired_at).
1549
1614
 
1550
- BOTH timestamps set atomically (BI-TEMPORAL INTEGRITY).
1551
- Never deletes the fact (Rule 17: immutability).
1615
+ Never deletes the fact (immutability).
1552
1616
  """
1553
- from datetime import UTC, datetime as _dt
1617
+ from datetime import UTC
1618
+ from datetime import datetime as _dt
1554
1619
  now = _dt.now(UTC).isoformat()
1620
+
1621
+ # Resolve the event-time boundary from the fact's referenced_date.
1622
+ # Falls back to the current valid_until (which may already be set),
1623
+ # and ultimately to now if neither is available.
1624
+ fact_rows = self.execute(
1625
+ "SELECT referenced_date FROM atomic_facts WHERE fact_id = ?",
1626
+ (fact_id,),
1627
+ )
1628
+ referenced_date = dict(fact_rows[0]).get("referenced_date") if fact_rows else None
1629
+
1630
+ tv_rows = self.execute(
1631
+ "SELECT valid_until FROM fact_temporal_validity WHERE fact_id = ?",
1632
+ (fact_id,),
1633
+ )
1634
+ existing_valid_until = dict(tv_rows[0]).get("valid_until") if tv_rows else None
1635
+
1636
+ valid_until = referenced_date or existing_valid_until or now
1637
+
1555
1638
  self.execute(
1556
1639
  "UPDATE fact_temporal_validity "
1557
1640
  "SET valid_until = ?, system_expired_at = ?, "
1558
1641
  " invalidated_by = ?, invalidation_reason = ? "
1559
1642
  "WHERE fact_id = ?",
1560
- (now, now, invalidated_by, invalidation_reason, fact_id),
1643
+ (valid_until, now, invalidated_by, invalidation_reason, fact_id),
1561
1644
  )
1562
1645
 
1563
1646
  def get_valid_facts(self, profile_id: str) -> list[str]:
@@ -1587,7 +1670,10 @@ class DatabaseManager:
1587
1670
  return [dict(r)["fact_id"] for r in rows]
1588
1671
 
1589
1672
  def get_invalidated_fact_ids(
1590
- self, fact_ids: list[str], profile_id: str,
1673
+ self,
1674
+ fact_ids: list[str],
1675
+ profile_id: str,
1676
+ as_of: str | None = None,
1591
1677
  ) -> set[str]:
1592
1678
  """Return the subset of ``fact_ids`` that are system-invalidated.
1593
1679
 
@@ -1596,6 +1682,21 @@ class DatabaseManager:
1596
1682
  ``invalidate_fact_temporal``). Such facts are wrong/outdated and must be
1597
1683
  excluded from default retrieval (T1, Phase 4).
1598
1684
 
1685
+ Phase 4b — bi-temporal as_of:
1686
+ When ``as_of`` is None (default): returns ALL facts with
1687
+ ``system_expired_at IS NOT NULL`` — existing behaviour, no regression.
1688
+
1689
+ When ``as_of`` is set (UTC ISO 8601, "+00:00" suffix): returns only
1690
+ facts where ``system_expired_at <= as_of`` (transaction-time boundary
1691
+ inclusive). This means supersessions that occurred AFTER ``as_of`` are
1692
+ excluded — at the historical query point the fact was still valid.
1693
+
1694
+ ``as_of`` MUST be UTC-normalized via ``normalize_as_of()`` before this
1695
+ call. The stored ``system_expired_at`` values use Python's
1696
+ ``datetime.now(UTC).isoformat()`` format ("...+00:00") so the
1697
+ ``normalize_as_of()`` "+00:00" output produces correct lexicographic
1698
+ SQL comparisons.
1699
+
1599
1700
  Bounded + indexed: only the supplied candidate ids are queried (never a
1600
1701
  full-table scan), keyed on the ``fact_id`` PK with the
1601
1702
  ``idx_temporal_system_expired`` index covering the predicate. Chunked to
@@ -1615,17 +1716,118 @@ class DatabaseManager:
1615
1716
  for start in range(0, len(fact_ids), chunk):
1616
1717
  batch = fact_ids[start:start + chunk]
1617
1718
  placeholders = ",".join("?" for _ in batch)
1618
- rows = self.execute(
1619
- f"SELECT fact_id FROM fact_temporal_validity "
1620
- f"WHERE fact_id IN ({placeholders}) "
1621
- f" AND profile_id = ? "
1622
- f" AND system_expired_at IS NOT NULL",
1623
- (*batch, profile_id),
1624
- )
1719
+ if as_of is not None:
1720
+ # Transaction-time point-in-time: only supersessions that
1721
+ # occurred AT OR BEFORE as_of contribute to invalidation.
1722
+ # Supersessions after as_of are invisible at this query point.
1723
+ 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),
1730
+ )
1731
+ else:
1732
+ 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),
1738
+ )
1625
1739
  for r in rows:
1626
1740
  invalid.add(dict(r)["fact_id"])
1627
1741
  return invalid
1628
1742
 
1743
+ def get_event_time_expired_fact_ids(
1744
+ self,
1745
+ fact_ids: list[str],
1746
+ profile_id: str,
1747
+ as_of: str | None = None,
1748
+ ) -> set[str]:
1749
+ """Return the subset of ``fact_ids`` that are event-time out-of-range.
1750
+
1751
+ Returns fact_ids whose event-time validity window does not encompass
1752
+ ``as_of`` (or the current wall-clock time when ``as_of`` is None):
1753
+
1754
+ 1. **Already expired** — ``valid_until IS NOT NULL AND valid_until <= ref``
1755
+ where ``ref`` is ``as_of`` when provided or the current UTC time.
1756
+ The ``<=`` implements the half-open interval ``[valid_from, valid_until)``:
1757
+ a fact with ``valid_until == as_of`` has expired at that boundary (Phase 4b fix).
1758
+ 2. **Not yet valid** — ``valid_from IS NOT NULL AND valid_from > as_of``
1759
+ (only when ``as_of`` is provided for explicit point-in-time recall).
1760
+
1761
+ Zero-regression guarantee: facts with ``valid_until = NULL`` are
1762
+ assumed open-ended (still valid) and are NEVER returned. Facts with no
1763
+ temporal record at all are NEVER returned (assumed valid). Because almost
1764
+ all existing facts have ``valid_until = NULL``, the default path
1765
+ (``as_of=None``) returns an empty set and causes no demotion.
1766
+
1767
+ Bounded + indexed: only the supplied candidate ids are queried (never a
1768
+ full-table scan), keyed on the ``fact_id`` PK. Chunked to stay under
1769
+ SQLite's ~999 bound-parameter limit (chunk size 900). The
1770
+ ``idx_temporal_valid(profile_id, valid_until)`` index assists the
1771
+ ``valid_until <`` range predicate after the PK IN-lookup. The
1772
+ ``valid_from > as_of`` branch operates on the same bounded PK row set
1773
+ (≤ 900 rows per chunk) — no full-table scan occurs.
1774
+
1775
+ Fail-open: any DB error logs a warning and returns an empty set so
1776
+ retrieval can never break because of a validity lookup failure.
1777
+
1778
+ Args:
1779
+ fact_ids: Candidate fact IDs to check (bounded retrieval pool).
1780
+ profile_id: Current profile — scopes the lookup to one tenant.
1781
+ as_of: Optional ISO 8601 datetime string for point-in-time recall.
1782
+ When set, facts not yet valid at this time are also returned.
1783
+ When None (default), only facts past their ``valid_until`` are
1784
+ returned — the standard current-time path.
1785
+
1786
+ Note:
1787
+ ``as_of`` must be in the same ISO 8601 format as the stored
1788
+ ``valid_until`` / ``valid_from`` values so SQLite's lexicographic
1789
+ string comparison correctly orders the timestamps.
1790
+ """
1791
+ if not fact_ids:
1792
+ return set()
1793
+ expired: set[str] = set()
1794
+ try:
1795
+ chunk = 900
1796
+ for start in range(0, len(fact_ids), chunk):
1797
+ batch = fact_ids[start:start + chunk]
1798
+ placeholders = ",".join("?" for _ in batch)
1799
+ if as_of is not None:
1800
+ # Time-travel: expired-before-as_of OR not-yet-started-at-as_of.
1801
+ rows = self.execute(
1802
+ f"SELECT fact_id FROM fact_temporal_validity "
1803
+ f"WHERE fact_id IN ({placeholders}) "
1804
+ f" AND profile_id = ? "
1805
+ f" AND ("
1806
+ f" (valid_until IS NOT NULL AND valid_until <= ?) "
1807
+ f" OR (valid_from IS NOT NULL AND valid_from > ?)"
1808
+ f" )",
1809
+ (*batch, profile_id, as_of, as_of),
1810
+ )
1811
+ else:
1812
+ # Default path: only facts whose valid_until has passed.
1813
+ # idx_temporal_valid(profile_id, valid_until) assists range scan.
1814
+ rows = self.execute(
1815
+ f"SELECT fact_id FROM fact_temporal_validity "
1816
+ f"WHERE fact_id IN ({placeholders}) "
1817
+ f" AND profile_id = ? "
1818
+ f" AND valid_until IS NOT NULL "
1819
+ f" AND valid_until < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
1820
+ (*batch, profile_id),
1821
+ )
1822
+ for r in rows:
1823
+ expired.add(dict(r)["fact_id"])
1824
+ except Exception as exc:
1825
+ logger.warning(
1826
+ "Event-time expiry lookup failed (fail-open): %s", exc,
1827
+ )
1828
+ return set()
1829
+ return expired
1830
+
1629
1831
  def get_fact_event_times(
1630
1832
  self, fact_ids: list[str], profile_id: str,
1631
1833
  ) -> dict[str, str]:
@@ -1655,7 +1857,8 @@ class DatabaseManager:
1655
1857
  f" tv.valid_from, f.created_at) AS event_time "
1656
1858
  f"FROM atomic_facts f "
1657
1859
  f"LEFT JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
1658
- f"WHERE f.fact_id IN ({placeholders}) AND f.profile_id = ?",
1860
+ f"WHERE f.fact_id IN ({placeholders}) "
1861
+ f"AND (f.profile_id = ? OR f.scope = 'global')",
1659
1862
  (*batch, profile_id),
1660
1863
  )
1661
1864
  for r in rows:
@@ -1696,6 +1899,108 @@ class DatabaseManager:
1696
1899
  except Exception as exc: # pragma: no cover — legacy/missing FTS table
1697
1900
  logger.debug("upsert_fact_expansion skipped for %s: %s", fact_id, exc)
1698
1901
 
1902
+ def reset_fact_expansion(self, fact_id: str, alt_keys: str = "") -> None:
1903
+ """Replace the expansion row unconditionally, keeping it alive with new alt_keys.
1904
+
1905
+ Unlike ``upsert_fact_expansion``, this always inserts (even when
1906
+ ``alt_keys`` is empty) so the row survives as a cleared placeholder.
1907
+ Used by update paths that must guarantee the expansion entry exists but
1908
+ holds no stale tokens. Fail-soft: a missing FTS table is a no-op.
1909
+ """
1910
+ try:
1911
+ self.execute(
1912
+ "DELETE FROM fact_expansion_fts WHERE fact_id = ?", (fact_id,)
1913
+ )
1914
+ self.execute(
1915
+ "INSERT INTO fact_expansion_fts (fact_id, alt_keys) VALUES (?, ?)",
1916
+ (fact_id, alt_keys),
1917
+ )
1918
+ except Exception as exc:
1919
+ logger.debug("reset_fact_expansion skipped for %s: %s", fact_id, exc)
1920
+
1921
+ def update_temporal_event_description(
1922
+ self, fact_id: str, description: str
1923
+ ) -> None:
1924
+ """Update the description column in ``temporal_events`` for a fact.
1925
+
1926
+ Fail-soft: absent table (pre-migration DB) is silently skipped.
1927
+ """
1928
+ try:
1929
+ self.execute(
1930
+ "UPDATE temporal_events SET description = ? WHERE fact_id = ?",
1931
+ (description, fact_id),
1932
+ )
1933
+ except Exception as exc:
1934
+ logger.debug(
1935
+ "update_temporal_event_description skipped for %s: %s", fact_id, exc
1936
+ )
1937
+
1938
+ def delete_bm25_tokens_for_fact(self, fact_id: str) -> None:
1939
+ """Delete persisted BM25 tokens for a fact from the ``bm25_tokens`` table."""
1940
+ try:
1941
+ self.execute(
1942
+ "DELETE FROM bm25_tokens WHERE fact_id = ?", (fact_id,)
1943
+ )
1944
+ except Exception as exc:
1945
+ logger.debug("delete_bm25_tokens_for_fact skipped for %s: %s", fact_id, exc)
1946
+
1947
+ def delete_graph_edges_for_fact(self, fact_id: str) -> None:
1948
+ """Delete all graph edges where this fact is the source or the target."""
1949
+ try:
1950
+ self.execute(
1951
+ "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?",
1952
+ (fact_id, fact_id),
1953
+ )
1954
+ except Exception as exc:
1955
+ logger.debug("delete_graph_edges_for_fact skipped for %s: %s", fact_id, exc)
1956
+
1957
+ def remove_fact_from_scenes(self, fact_id: str, profile_id: str) -> None:
1958
+ """Remove a fact_id from every ``memory_scenes`` JSON array in the profile.
1959
+
1960
+ Scenes that become empty after removal are deleted entirely.
1961
+ Fail-soft: any exception is logged and ignored.
1962
+ """
1963
+ try:
1964
+ scenes = self.get_scenes_for_fact(fact_id, profile_id)
1965
+ for scene in scenes:
1966
+ new_ids = [fid for fid in (scene.fact_ids or []) if fid != fact_id]
1967
+ if new_ids:
1968
+ self.execute(
1969
+ "UPDATE memory_scenes SET fact_ids_json = ? "
1970
+ "WHERE scene_id = ?",
1971
+ (json.dumps(new_ids), scene.scene_id),
1972
+ )
1973
+ else:
1974
+ self.execute(
1975
+ "DELETE FROM memory_scenes WHERE scene_id = ?",
1976
+ (scene.scene_id,),
1977
+ )
1978
+ except Exception as exc:
1979
+ logger.debug("remove_fact_from_scenes skipped for %s: %s", fact_id, exc)
1980
+
1981
+ def delete_memory_for_fact(self, fact_id: str, profile_id: str) -> None:
1982
+ """Delete the raw ``memories`` record that sourced this fact.
1983
+
1984
+ Reads the ``memory_id`` from ``atomic_facts`` before the fact row is
1985
+ gone, then deletes the memory. Fail-soft: any exception is logged.
1986
+ """
1987
+ try:
1988
+ rows = self.execute(
1989
+ "SELECT memory_id FROM atomic_facts "
1990
+ "WHERE fact_id = ? AND profile_id = ? LIMIT 1",
1991
+ (fact_id, profile_id),
1992
+ )
1993
+ if rows:
1994
+ memory_id = dict(rows[0]).get("memory_id") or ""
1995
+ if memory_id:
1996
+ self.execute(
1997
+ "DELETE FROM memories "
1998
+ "WHERE memory_id = ? AND profile_id = ?",
1999
+ (memory_id, profile_id),
2000
+ )
2001
+ except Exception as exc:
2002
+ logger.debug("delete_memory_for_fact skipped for %s: %s", fact_id, exc)
2003
+
1699
2004
  # ------------------------------------------------------------------
1700
2005
  # Phase 5: Core Memory Blocks CRUD (Rule 15)
1701
2006
  # ------------------------------------------------------------------