superlocalmemory 3.8.0 → 3.8.1

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 (118) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +134 -7
  34. package/src/superlocalmemory/cli/daemon.py +7 -0
  35. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  36. package/src/superlocalmemory/cli/main.py +10 -4
  37. package/src/superlocalmemory/cli/version_banner.py +17 -3
  38. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  39. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  40. package/src/superlocalmemory/core/embeddings.py +119 -45
  41. package/src/superlocalmemory/core/engine.py +24 -21
  42. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  43. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  44. package/src/superlocalmemory/core/injection.py +12 -7
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  46. package/src/superlocalmemory/core/recall_pipeline.py +5 -3
  47. package/src/superlocalmemory/core/store_pipeline.py +153 -20
  48. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  49. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  50. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  51. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  52. package/src/superlocalmemory/learning/reward.py +50 -0
  53. package/src/superlocalmemory/learning/source_quality.py +523 -1
  54. package/src/superlocalmemory/loops/ledger.py +25 -5
  55. package/src/superlocalmemory/mcp/server.py +11 -30
  56. package/src/superlocalmemory/mcp/tools_core.py +4 -3
  57. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  58. package/src/superlocalmemory/retrieval/engine.py +53 -21
  59. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  60. package/src/superlocalmemory/server/config_file.py +90 -0
  61. package/src/superlocalmemory/server/origin.py +50 -0
  62. package/src/superlocalmemory/server/routes/backup.py +293 -70
  63. package/src/superlocalmemory/server/routes/behavioral.py +336 -59
  64. package/src/superlocalmemory/server/routes/brain.py +57 -16
  65. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  66. package/src/superlocalmemory/server/routes/entity.py +100 -23
  67. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  68. package/src/superlocalmemory/server/routes/learning.py +276 -105
  69. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  70. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  71. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  72. package/src/superlocalmemory/server/routes/stats.py +93 -155
  73. package/src/superlocalmemory/server/routes/token.py +3 -13
  74. package/src/superlocalmemory/server/routes/v3_api.py +64 -20
  75. package/src/superlocalmemory/server/unified_daemon.py +467 -40
  76. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  77. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  78. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  79. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  80. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  81. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  82. package/src/superlocalmemory/storage/schema.py +49 -1
  83. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  84. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  85. package/src/superlocalmemory/ui/index.html +3 -6
  86. package/src/superlocalmemory/ui/js/core.js +52 -9
  87. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  88. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  89. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  90. package/src/superlocalmemory/ui/js/od-entities.js +22 -22
  91. package/src/superlocalmemory/ui/js/od-graph.js +11 -4
  92. package/src/superlocalmemory/ui/js/od-memories.js +47 -5
  93. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  94. package/src/superlocalmemory/ui/js/od-settings.js +113 -59
  95. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  96. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  97. package/src/superlocalmemory/ui/js/settings.js +15 -1
  98. package/plugin-src/.mcp.json +0 -12
  99. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  100. package/plugin-src/agents/slm-loop-runner.md +0 -71
  101. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  102. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  103. package/plugin-src/commands/slm-loop.md +0 -31
  104. package/plugin-src/hooks/.gitkeep +0 -0
  105. package/plugin-src/hooks/hooks.json +0 -102
  106. package/plugin-src/manifest.json +0 -30
  107. package/plugin-src/requirements.txt +0 -1
  108. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  109. package/plugin-src/scripts/ensure-venv.bat +0 -122
  110. package/plugin-src/scripts/ensure-venv.sh +0 -105
  111. package/plugin-src/scripts/slm-launch +0 -62
  112. package/plugin-src/scripts/slm-launch.bat +0 -23
  113. package/plugin-src/settings.json +0 -25
  114. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  115. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  116. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  117. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  118. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -24,6 +24,7 @@ from typing import Any, Callable
24
24
  from superlocalmemory.storage.database import DatabaseManager
25
25
 
26
26
  _MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
27
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS = 10
27
28
 
28
29
 
29
30
  def _materialization_lock(operation_id: str) -> threading.RLock:
@@ -51,6 +52,10 @@ class OperationInProgress(RuntimeError):
51
52
  """Another live lease owner is materializing this operation."""
52
53
 
53
54
 
55
+ class LeaseLost(OperationInProgress):
56
+ """The materializer no longer owns its durable operation lease."""
57
+
58
+
54
59
  def _canonical_json(value: Any) -> str:
55
60
  return json.dumps(value, sort_keys=True, separators=(",", ":"))
56
61
 
@@ -284,12 +289,19 @@ class IngestionOperationRepository:
284
289
  self._from_row(row)
285
290
  for row in self.db.execute(
286
291
  "SELECT * FROM ingestion_operations "
287
- "WHERE (state='queryable' AND created_at <= "
292
+ "WHERE attempt_count < ? AND ("
293
+ "(state='queryable' AND created_at <= "
288
294
  "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) "
289
295
  "OR (state='failed' AND next_retry_at <= ?) "
290
- "OR (state='enriching' AND lease_expires_at <= ?) "
296
+ "OR (state='enriching' AND lease_expires_at <= ?)) "
291
297
  "ORDER BY created_at, rowid LIMIT ?",
292
- (grace_modifier, now, now, max(1, int(limit))),
298
+ (
299
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS,
300
+ grace_modifier,
301
+ now,
302
+ now,
303
+ max(1, int(limit)),
304
+ ),
293
305
  )
294
306
  ]
295
307
 
@@ -415,6 +427,27 @@ class IngestionOperationRepository:
415
427
  )
416
428
  return operation
417
429
 
430
+ def renew_enriching_lease(
431
+ self,
432
+ operation_id: str,
433
+ *,
434
+ owner: str,
435
+ lease_seconds: float,
436
+ ) -> bool:
437
+ """Extend a live lease only while the same owner still holds it."""
438
+ rows = self.db.execute(
439
+ "UPDATE ingestion_operations SET lease_expires_at=?, "
440
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
441
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
442
+ "RETURNING operation_id",
443
+ (
444
+ time.time() + max(1.0, float(lease_seconds)),
445
+ operation_id,
446
+ owner,
447
+ ),
448
+ )
449
+ return bool(rows)
450
+
418
451
  def finish_enriching(
419
452
  self,
420
453
  operation_id: str,
@@ -469,6 +502,7 @@ class MaterializationResult:
469
502
 
470
503
  fact_ids: tuple[str, ...]
471
504
  derivation_state: dict[str, bool]
505
+ last_error: str = ""
472
506
 
473
507
 
474
508
  QueryableWriter = Callable[[IngestionRequest, str], list[str]]
@@ -500,6 +534,47 @@ class IngestionCommand:
500
534
  self._lease_seconds = max(1.0, float(lease_seconds))
501
535
  self._owner = f"ingestion-worker:{uuid.uuid4().hex}"
502
536
 
537
+ def _run_with_lease_heartbeat(
538
+ self,
539
+ operation_id: str,
540
+ callback: Callable[[], Any],
541
+ ) -> Any:
542
+ """Run slow work while periodically renewing its owner-bound lease."""
543
+ stop = threading.Event()
544
+ lost = threading.Event()
545
+ interval = min(30.0, max(0.1, self._lease_seconds / 3.0))
546
+
547
+ def heartbeat() -> None:
548
+ while not stop.wait(interval):
549
+ try:
550
+ renewed = self.repository.renew_enriching_lease(
551
+ operation_id,
552
+ owner=self._owner,
553
+ lease_seconds=self._lease_seconds,
554
+ )
555
+ except sqlite3.Error:
556
+ continue
557
+ if not renewed:
558
+ lost.set()
559
+ return
560
+
561
+ thread = threading.Thread(
562
+ target=heartbeat,
563
+ name=f"ingestion-lease-heartbeat:{operation_id[:8]}",
564
+ daemon=True,
565
+ )
566
+ thread.start()
567
+ try:
568
+ result = callback()
569
+ finally:
570
+ stop.set()
571
+ thread.join(timeout=max(1.0, interval * 2))
572
+ if lost.is_set():
573
+ raise LeaseLost(
574
+ f"ingestion lease lost for operation {operation_id}"
575
+ )
576
+ return result
577
+
503
578
  def submit(self, request: IngestionRequest) -> IngestionOperation:
504
579
  operation, _created = self.submit_with_status(request)
505
580
  return operation
@@ -548,27 +623,41 @@ class IngestionCommand:
548
623
  )
549
624
  if enriching.state is IngestionState.COMPLETE:
550
625
  return enriching
551
- if enriching.final_fact_ids:
626
+ if (
627
+ enriching.final_fact_ids
628
+ and all(enriching.derivation_state.values())
629
+ ):
552
630
  return self._project_and_complete(enriching)
553
631
  try:
632
+ # Materialization is a durable saga, not one long SQLite
633
+ # transaction. Extractors, embedders, and local model calls can
634
+ # take minutes on a mature installation; keeping a write
635
+ # transaction open across that work blocks every interactive
636
+ # remember/update/delete and makes the dashboard appear dead.
637
+ #
638
+ # The materializer commits its relational checkpoints in short
639
+ # database operations. The operation lease and derivation state
640
+ # remain the recovery boundary, and only the final state-machine
641
+ # checkpoint is grouped atomically below.
642
+ materialized = self._run_with_lease_heartbeat(
643
+ operation_id,
644
+ lambda: self._materializer(enriching),
645
+ )
646
+ if isinstance(materialized, MaterializationResult):
647
+ fact_ids = tuple(materialized.fact_ids)
648
+ derivation_state = dict(materialized.derivation_state)
649
+ materialization_error = materialized.last_error
650
+ else:
651
+ fact_ids = tuple(materialized)
652
+ derivation_state = {"materializer": True}
653
+ materialization_error = ""
654
+ if not fact_ids:
655
+ raise RuntimeError("materialization produced no final facts")
656
+ incomplete = sorted(
657
+ name for name, complete in derivation_state.items()
658
+ if not complete
659
+ )
554
660
  with self.repository.db.transaction():
555
- materialized = self._materializer(enriching)
556
- if isinstance(materialized, MaterializationResult):
557
- fact_ids = tuple(materialized.fact_ids)
558
- derivation_state = dict(materialized.derivation_state)
559
- else:
560
- fact_ids = tuple(materialized)
561
- derivation_state = {"materializer": True}
562
- if not fact_ids:
563
- raise RuntimeError("materialization produced no final facts")
564
- incomplete = sorted(
565
- name for name, complete in derivation_state.items()
566
- if not complete
567
- )
568
- if incomplete:
569
- raise RuntimeError(
570
- "incomplete derivation stages: " + ", ".join(incomplete)
571
- )
572
661
  checkpointed = self.repository.checkpoint_enriching(
573
662
  operation_id,
574
663
  final_fact_ids=fact_ids,
@@ -577,6 +666,21 @@ class IngestionCommand:
577
666
  lease_owner=self._owner,
578
667
  lease_seconds=self._lease_seconds,
579
668
  )
669
+ if materialization_error or incomplete:
670
+ error = materialization_error or (
671
+ "incomplete derivation stages: " + ", ".join(incomplete)
672
+ )
673
+ return self.repository.finish_enriching(
674
+ operation_id,
675
+ owner=self._owner,
676
+ target=IngestionState.FAILED,
677
+ final_fact_ids=fact_ids,
678
+ derivation_version=self._derivation_version,
679
+ derivation_state=derivation_state,
680
+ last_error=error,
681
+ )
682
+ except LeaseLost:
683
+ raise
580
684
  except Exception as exc:
581
685
  return self.repository.finish_enriching(
582
686
  operation_id,
@@ -598,10 +702,13 @@ class IngestionCommand:
598
702
  owner=self._owner,
599
703
  lease_seconds=self._lease_seconds,
600
704
  )
601
- projection_state = (
602
- dict(self._projector(operation))
603
- if self._projector is not None
604
- else {}
705
+ projection_state = self._run_with_lease_heartbeat(
706
+ operation.operation_id,
707
+ lambda: (
708
+ dict(self._projector(operation))
709
+ if self._projector is not None
710
+ else {}
711
+ ),
605
712
  )
606
713
  combined = {**operation.derivation_state, **projection_state}
607
714
  incomplete = sorted(
@@ -619,6 +726,8 @@ class IngestionCommand:
619
726
  derivation_version=self._derivation_version,
620
727
  derivation_state=combined,
621
728
  )
729
+ except LeaseLost:
730
+ raise
622
731
  except Exception as exc:
623
732
  return self.repository.finish_enriching(
624
733
  operation.operation_id,
@@ -128,20 +128,25 @@ def _load_injection_config():
128
128
 
129
129
 
130
130
  def estimate_tokens(text: str) -> int:
131
- """chars/4 heuristic. Optional tiktoken if installed (best-effort).
132
-
133
- tiktoken is an optional dependency (pip install superlocalmemory[injection]).
134
- Falls back to chars/4 if tiktoken is not installed or raises any error.
135
- All exception types (MemoryError, SystemError etc.) are subclasses of
136
- Exception in Python 3.11, so the bare except Exception covers all paths.
131
+ """Estimate context tokens without loading native code by default.
132
+
133
+ The stable default is the deterministic ``chars / 4`` heuristic. Loading
134
+ the optional native ``tiktoken`` extension in a long-lived daemon that also
135
+ hosts torch/scipy workers can terminate the whole process on a native fault;
136
+ Python exception handling cannot recover from SIGBUS/SIGSEGV. Users who
137
+ explicitly need exact OpenAI token counts can opt in with
138
+ ``SLM_INJECTION_EXACT_TOKENS=1``.
137
139
  """
138
140
  if not text:
139
141
  return 0
142
+ heuristic = max(1, len(text) // 4)
143
+ if os.environ.get("SLM_INJECTION_EXACT_TOKENS", "0") != "1":
144
+ return heuristic
140
145
  try:
141
146
  import tiktoken
142
147
  return len(tiktoken.get_encoding("cl100k_base").encode(text))
143
148
  except Exception:
144
- return max(1, len(text) // 4)
149
+ return heuristic
145
150
 
146
151
 
147
152
  def resolve_budget(mode: str, cfg) -> int:
@@ -8,7 +8,8 @@ V3.3.13: Periodically triggers Langevin/Ebbinghaus/Sheaf maintenance
8
8
  so users don't need to call run_maintenance manually.
9
9
 
10
10
  Configurable interval via ForgettingConfig.scheduler_interval_minutes.
11
- Defaults to 30 min. Disabled during benchmarks (no config.forgetting.enabled).
11
+ Defaults to 30 min. Optional forgetting/math work follows
12
+ ``config.forgetting.enabled``; tier evaluation and bounded housekeeping do not.
12
13
 
13
14
  Part of Qualixar | Author: Varun Pratap Bhardwaj
14
15
  License: AGPL-3.0-or-later
@@ -79,12 +80,21 @@ class MaintenanceScheduler:
79
80
  if not self._running:
80
81
  return
81
82
  for profile_id in self._profile_ids():
82
- try:
83
- from superlocalmemory.core.maintenance import run_maintenance
84
- counts = run_maintenance(self._db, self._config, profile_id)
85
- logger.info("Scheduled maintenance complete for %s: %s", profile_id, counts)
86
- except Exception as exc:
87
- logger.warning("Scheduled maintenance failed for %s: %s", profile_id, exc)
83
+ if self._config.forgetting.enabled:
84
+ try:
85
+ from superlocalmemory.core.maintenance import run_maintenance
86
+ counts = run_maintenance(self._db, self._config, profile_id)
87
+ logger.info(
88
+ "Scheduled maintenance complete for %s: %s",
89
+ profile_id,
90
+ counts,
91
+ )
92
+ except Exception as exc:
93
+ logger.warning(
94
+ "Scheduled maintenance failed for %s: %s",
95
+ profile_id,
96
+ exc,
97
+ )
88
98
 
89
99
  # V3.4.11: Graph pruning (remove orphan edges)
90
100
  try:
@@ -632,8 +632,8 @@ def run_recall(
632
632
 
633
633
  Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.
634
634
 
635
- ``fast=True`` skips spreading activation and remote agentic verification,
636
- retaining the bounded single-pass retrieval channels.
635
+ ``fast=True`` skips remote agentic verification while retaining the six
636
+ local retrieval channels.
637
637
  """
638
638
  # Pre-operation hooks
639
639
  hook_ctx = {
@@ -658,7 +658,9 @@ def run_recall(
658
658
  logger.warning("[RECALL-TIMING] %-22s %.0f ms",
659
659
  _label, (_time_t.monotonic() - _t0) * 1000.0)
660
660
 
661
- extra_disabled = {"spreading_activation"} if fast else None
661
+ # The interactive path must retain the complete local retrieval contract.
662
+ # Only agentic verification can invoke an unbounded model round.
663
+ extra_disabled = None
662
664
  response = retrieval_engine.recall(
663
665
  query, profile_id, m, limit,
664
666
  extra_disabled_channels=extra_disabled,
@@ -11,8 +11,10 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import logging
14
+ import hashlib
15
15
  import json
16
+ import logging
17
+ import uuid
16
18
  from typing import TYPE_CHECKING, Any
17
19
 
18
20
  if TYPE_CHECKING:
@@ -21,7 +23,9 @@ if TYPE_CHECKING:
21
23
  from superlocalmemory.storage.database import DatabaseManager
22
24
 
23
25
  from superlocalmemory.storage.models import (
24
- AtomicFact, FactType, MemoryRecord,
26
+ AtomicFact,
27
+ FactType,
28
+ MemoryRecord,
25
29
  )
26
30
 
27
31
  logger = logging.getLogger(__name__)
@@ -30,6 +34,63 @@ logger = logging.getLogger(__name__)
30
34
  _INIT_LANGEVIN_RADIUS = 0.05
31
35
 
32
36
 
37
+ def _ingestion_effect_id(operation_id: str, *parts: object) -> str:
38
+ """Return a stable ID for a relational effect owned by one ingestion."""
39
+ if not operation_id:
40
+ return uuid.uuid4().hex
41
+ payload = "\0".join((operation_id, *(str(part) for part in parts)))
42
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
43
+
44
+
45
+ def _record_fact_entity_association(
46
+ db: DatabaseManager,
47
+ *,
48
+ operation_id: str,
49
+ profile_id: str,
50
+ fact_id: str,
51
+ entity_id: str,
52
+ ) -> None:
53
+ """Apply one fact/entity count effect in O(1), exactly once."""
54
+ if not operation_id:
55
+ db.increment_entity_fact_count(entity_id, profile_id)
56
+ return
57
+ with db.transaction():
58
+ claimed = db.execute(
59
+ "INSERT INTO fact_entity_associations "
60
+ "(profile_id,fact_id,entity_id,first_operation_id,count_applied) "
61
+ "SELECT ?,?,?,?,"
62
+ "CASE WHEN fact.rowid > repair.target_fact_rowid THEN 1 ELSE 0 END "
63
+ "FROM canonical_entities AS entity "
64
+ "JOIN atomic_facts AS fact "
65
+ "ON fact.fact_id=? AND fact.profile_id=? "
66
+ "JOIN fact_entity_association_repair_state AS repair "
67
+ "ON repair.repair_key='historical-backfill' "
68
+ "WHERE entity.entity_id=? AND entity.profile_id=? "
69
+ "ON CONFLICT(profile_id,fact_id,entity_id) DO UPDATE SET "
70
+ "count_applied=excluded.count_applied,"
71
+ "first_operation_id=excluded.first_operation_id "
72
+ "WHERE fact_entity_associations.count_applied=0 "
73
+ "AND excluded.count_applied=1 "
74
+ "RETURNING count_applied",
75
+ (
76
+ profile_id,
77
+ fact_id,
78
+ entity_id,
79
+ operation_id,
80
+ fact_id,
81
+ profile_id,
82
+ entity_id,
83
+ profile_id,
84
+ ),
85
+ )
86
+ if claimed and int(claimed[0]["count_applied"]) == 1:
87
+ db.execute(
88
+ "UPDATE canonical_entities SET fact_count=fact_count+1 "
89
+ "WHERE entity_id=? AND profile_id=?",
90
+ (entity_id, profile_id),
91
+ )
92
+
93
+
33
94
  def _init_langevin_position(dim: int = 8) -> list[float]:
34
95
  """Initialize Langevin position near origin for a new fact.
35
96
 
@@ -60,7 +121,7 @@ def enrich_fact(
60
121
  temporal_parser: Any,
61
122
  ) -> AtomicFact:
62
123
  """Enrich fact with embeddings, entities, temporal, emotional data."""
63
- from superlocalmemory.encoding.emotional import tag_emotion, emotional_importance_boost
124
+ from superlocalmemory.encoding.emotional import emotional_importance_boost, tag_emotion
64
125
  from superlocalmemory.encoding.signal_inference import infer_signal
65
126
 
66
127
  embedding = embedder.embed(fact.content) if embedder else None
@@ -189,6 +250,9 @@ def run_store(
189
250
  ingestion_source_type: str = "store",
190
251
  ingestion_operation_id: str = "",
191
252
  derivation_report: dict[str, bool] | None = None,
253
+ precompleted_derivation_stages: frozenset[str] = frozenset(),
254
+ materialization_progress: dict[str, Any] | None = None,
255
+ materialization_checkpoint: Any = None,
192
256
  ) -> list[str]:
193
257
  """Store content and extract structured facts. Returns fact_ids.
194
258
 
@@ -286,10 +350,19 @@ def run_store(
286
350
  )
287
351
  db.store_memory(record)
288
352
 
289
- extraction_complete = False
290
- consolidation_complete = consolidator is not None
291
- canonicalization_complete = entity_resolver is not None
292
- graph_complete = graph_builder is not None
353
+ extraction_complete = "extraction" in precompleted_derivation_stages
354
+ consolidation_complete = (
355
+ "consolidation" in precompleted_derivation_stages
356
+ or consolidator is not None
357
+ )
358
+ canonicalization_complete = (
359
+ "canonicalization" in precompleted_derivation_stages
360
+ or entity_resolver is not None
361
+ )
362
+ graph_complete = (
363
+ "graph" in precompleted_derivation_stages
364
+ or graph_builder is not None
365
+ )
293
366
  temporal_complete = True
294
367
  provenance_complete = provenance is not None
295
368
 
@@ -330,7 +403,6 @@ def run_store(
330
403
  and content.strip()
331
404
  and len(content.strip()) >= 40
332
405
  and len(content.strip().split()) >= _MIN_VERBATIM_WORDS):
333
- import uuid
334
406
  import re as _re
335
407
  _verbatim_text = content.strip()
336
408
  # Extract entities using the same regex as fact_extractor
@@ -374,7 +446,6 @@ def run_store(
374
446
  # their data should NEVER be silently dropped. The min-length and min-word filters
375
447
  # are designed for automatic conversation extraction, not explicit user storage.
376
448
  if not facts and content.strip():
377
- import uuid
378
449
  facts = [AtomicFact(
379
450
  fact_id=uuid.uuid4().hex[:16],
380
451
  content=content.strip(),
@@ -403,6 +474,22 @@ def run_store(
403
474
  temporal_parser=temporal_parser,
404
475
  )
405
476
 
477
+ if (
478
+ materialization_progress is not None
479
+ and not materialization_progress.get("relational_started", False)
480
+ ):
481
+ if materialization_checkpoint is not None:
482
+ materialization_checkpoint(
483
+ "relational_started",
484
+ (),
485
+ {
486
+ "pipeline_started": True,
487
+ "relational_started": True,
488
+ "pipeline": False,
489
+ },
490
+ )
491
+ materialization_progress["relational_started"] = True
492
+
406
493
  is_queryable_promotion = fact.fact_id in queryable_ids
407
494
  if is_queryable_promotion:
408
495
  db.update_fact(fact.fact_id, {
@@ -515,6 +602,8 @@ def run_store(
515
602
 
516
603
  if fact.fact_id not in stored_ids:
517
604
  stored_ids.append(fact.fact_id)
605
+ if materialization_progress is not None:
606
+ materialization_progress["fact_ids"] = tuple(stored_ids)
518
607
 
519
608
  # Dual-write embedding to ANN index + vector store (embed on-demand if
520
609
  # a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
@@ -611,12 +700,17 @@ def run_store(
611
700
  for eid in fact.canonical_entities:
612
701
  observation_builder.update_profile(eid, fact, profile_id)
613
702
 
614
- # Increment fact_count for each linked canonical entity (scoped to profile).
703
+ # The normalized association key makes this O(1) and exactly-once
704
+ # across retries, crashes, and separate operations that consolidate to
705
+ # the same fact.
615
706
  for eid in fact.canonical_entities:
616
- try:
617
- db.increment_entity_fact_count(eid, profile_id)
618
- except Exception:
619
- pass # Non-critical — entity may have been deleted
707
+ _record_fact_entity_association(
708
+ db,
709
+ operation_id=ingestion_operation_id,
710
+ profile_id=profile_id,
711
+ fact_id=fact.fact_id,
712
+ entity_id=eid,
713
+ )
620
714
  if scene_builder:
621
715
  scene_builder.assign_to_scene(fact, profile_id)
622
716
 
@@ -627,6 +721,13 @@ def run_store(
627
721
  from superlocalmemory.storage.models import TemporalEvent
628
722
  for eid in fact.canonical_entities:
629
723
  event = TemporalEvent(
724
+ event_id=_ingestion_effect_id(
725
+ ingestion_operation_id,
726
+ "temporal",
727
+ fact.fact_id,
728
+ eid,
729
+ "observed",
730
+ ),
630
731
  profile_id=profile_id, entity_id=eid,
631
732
  fact_id=fact.fact_id,
632
733
  scope=fact.scope,
@@ -644,8 +745,16 @@ def run_store(
644
745
  from superlocalmemory.encoding.foresight import extract_foresight_signals
645
746
  from superlocalmemory.storage.models import TemporalEvent as _TE
646
747
  foresight_signals = extract_foresight_signals(fact)
647
- for sig in foresight_signals:
748
+ for signal_index, sig in enumerate(foresight_signals):
648
749
  f_event = _TE(
750
+ event_id=_ingestion_effect_id(
751
+ ingestion_operation_id,
752
+ "temporal",
753
+ fact.fact_id,
754
+ sig.get("entity_id", ""),
755
+ "foresight",
756
+ signal_index,
757
+ ),
649
758
  profile_id=profile_id,
650
759
  entity_id=sig.get("entity_id", ""),
651
760
  fact_id=fact.fact_id,
@@ -679,11 +788,6 @@ def run_store(
679
788
 
680
789
  logger.info("Stored %d facts (session=%s)", len(stored_ids), session_id)
681
790
 
682
- # Post-operation hooks (audit, trust signal, event bus)
683
- hook_ctx["fact_ids"] = stored_ids
684
- hook_ctx["fact_count"] = len(stored_ids)
685
- hooks.run_post("store", hook_ctx)
686
-
687
791
  if derivation_report is not None:
688
792
  derivation_report.update({
689
793
  "extraction": extraction_complete,
@@ -694,6 +798,35 @@ def run_store(
694
798
  "provenance": provenance_complete,
695
799
  })
696
800
 
801
+ # Stage observations and emitted fact IDs are recorded before optional
802
+ # post-hooks. A hook failure must not erase the durable retry boundary and
803
+ # cause extraction/consolidation to run again.
804
+ if materialization_progress is not None:
805
+ materialization_progress["fact_ids"] = tuple(stored_ids)
806
+ materialization_progress["relational_complete"] = True
807
+ materialization_progress["post_hooks"] = False
808
+ if materialization_checkpoint is not None and stored_ids:
809
+ materialization_checkpoint(
810
+ "relational_complete",
811
+ tuple(stored_ids),
812
+ {
813
+ "pipeline_started": True,
814
+ "relational_started": True,
815
+ "pipeline": True,
816
+ "post_hooks": False,
817
+ "relational": True,
818
+ **dict(derivation_report or {}),
819
+ },
820
+ )
821
+
822
+ # Post-operation hooks (audit, trust signal, event bus)
823
+ hook_ctx["ingestion_operation_id"] = ingestion_operation_id
824
+ hook_ctx["fact_ids"] = stored_ids
825
+ hook_ctx["fact_count"] = len(stored_ids)
826
+ hooks.run_post("store", hook_ctx)
827
+ if materialization_progress is not None:
828
+ materialization_progress["post_hooks"] = True
829
+
697
830
  # Phase 5: Step-count trigger for lightweight consolidation (L7)
698
831
  if consolidation_engine is not None:
699
832
  try:
@@ -26,6 +26,8 @@ import urllib.error
26
26
  import urllib.request
27
27
  from pathlib import Path
28
28
 
29
+ from superlocalmemory import __version__
30
+
29
31
  # ---------------------------------------------------------------------------
30
32
  # Cross-platform temp paths
31
33
  # ---------------------------------------------------------------------------
@@ -221,7 +223,10 @@ def _codex_mcp_session_init(project_dir: str, payload: dict) -> dict:
221
223
  proc.stdin.write(json.dumps({
222
224
  "jsonrpc": "2.0", "id": 1, "method": "initialize",
223
225
  "params": {"protocolVersion": "2024-11-05", "capabilities": {},
224
- "clientInfo": {"name": "superlocalmemory-codex-hook", "version": "3.7"}},
226
+ "clientInfo": {
227
+ "name": "superlocalmemory-codex-hook",
228
+ "version": __version__,
229
+ }},
225
230
  }) + "\n")
226
231
  proc.stdin.flush()
227
232
  proc.stdout.readline()