superlocalmemory 3.8.3 → 3.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -18,15 +18,16 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
18
18
  from __future__ import annotations
19
19
 
20
20
  import logging
21
+ import os
22
+ import threading
21
23
  from pathlib import Path
22
24
  from typing import Any
23
25
 
24
26
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT, SLMConfig
25
27
  from superlocalmemory.core.engine_capabilities import Capabilities, CapabilityError
26
28
  from superlocalmemory.core.modes import get_capabilities
27
- from superlocalmemory.learning.outcome_queue import RecallEvent, enqueue_recall
28
29
  from superlocalmemory.storage.models import (
29
- AtomicFact, MemoryRecord, Mode, RecallResponse,
30
+ AtomicFact, FactType, MemoryRecord, Mode, RecallResponse,
30
31
  )
31
32
 
32
33
  logger = logging.getLogger(__name__)
@@ -47,6 +48,34 @@ def _verify_ingestion_schema(memory_db: Path) -> bool:
47
48
  connection.close()
48
49
 
49
50
 
51
+ # ---------------------------------------------------------------------------
52
+ # Workstream D (3.8.4) — warm-guard sync embed helpers
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _is_remote_embedder(embedder: object) -> bool:
56
+ """Return True if *embedder* makes remote HTTP calls (cloud / OpenAI-compatible).
57
+
58
+ Remote embedders (100–400ms round-trip) must never block the store_fast()
59
+ write path synchronously — they stay on the async materializer path.
60
+
61
+ Local embedders:
62
+ - EmbeddingService (subprocess, local ONNX/sentence-transformers): has
63
+ ``_config`` with ``is_cloud=False`` and ``is_openai_compatible=False``.
64
+ - OllamaEmbedder (localhost HTTP, ~73ms): has NO ``_config`` attribute.
65
+
66
+ Remote embedders:
67
+ - EmbeddingService with ``_config.is_cloud=True`` (Azure, etc.)
68
+ - EmbeddingService with ``_config.is_openai_compatible=True``
69
+ """
70
+ cfg = getattr(embedder, "_config", None)
71
+ if cfg is None:
72
+ return False # OllamaEmbedder — local
73
+ return bool(
74
+ getattr(cfg, "is_cloud", False)
75
+ or getattr(cfg, "is_openai_compatible", False)
76
+ )
77
+
78
+
50
79
  class MemoryEngine:
51
80
  """Main orchestrator for the SuperLocalMemory V3 memory system.
52
81
 
@@ -103,6 +132,11 @@ class MemoryEngine:
103
132
  self._consolidation_engine = None
104
133
  self._maintenance_scheduler = None
105
134
  self._hooks = HookRegistry()
135
+ # Workstream D (3.8.4): single-worker pool reused across store_fast() calls.
136
+ # Lazy-created on first warm-guard attempt; avoids per-call thread churn.
137
+ self._store_fast_embed_pool: object | None = None
138
+ # Lock guards the lazy-init to prevent TOCTOU race on concurrent first calls.
139
+ self._store_fast_embed_pool_lock = threading.Lock()
106
140
 
107
141
  # -- Public properties (Phase 2+ access) --------------------------------
108
142
 
@@ -525,9 +559,6 @@ class MemoryEngine:
525
559
  import re as _re
526
560
  import uuid as _uuid
527
561
  from datetime import datetime, timezone
528
- from superlocalmemory.storage.models import (
529
- AtomicFact, FactType, MemoryRecord,
530
- )
531
562
  from superlocalmemory.core.engine_ingestion import content_passes_admission
532
563
  if not content_passes_admission(content):
533
564
  return []
@@ -568,15 +599,64 @@ class MemoryEngine:
568
599
  r"\b([A-Z][a-z]+(?:\s[A-Z][a-z]+){0,3})\b", fact_text)}
569
600
  | {m.group(1) for m in _re.finditer(r"\b([A-Z]{2,})\b", fact_text)}
570
601
  )
571
- # Queryable admission must never acquire the embedding worker lock.
572
- # On a warm daemon that looked cheap, but on a clean Mode A install the
573
- # background model load owns that lock for up to 180s and turned the
574
- # receipt-first path into a hidden synchronous wait. The canonical
575
- # materializer below runs the complete pipeline and promotes this same
576
- # fact with its embedding, Fisher parameters, entities and graph edges.
577
- # Until then it is deliberately BM25/entity/date recallable.
602
+ # Workstream D (3.8.4) warm-guard synchronous embed.
603
+ #
604
+ # Original contract (3.8.2): queryable admission NEVER acquires the
605
+ # embedding worker lock. On a clean Mode-A install the background model
606
+ # load owns that lock for up to 180s, turning the receipt-first path into
607
+ # a hidden synchronous wait. The canonical materializer promotes this
608
+ # fact with its full pipeline (embedding, Fisher, entities, graph edges).
609
+ #
610
+ # 3.8.4 extension: when the embedder is PROVABLY warm (_available is True)
611
+ # AND is a local embedder (not a remote cloud/OpenAI endpoint), compute the
612
+ # embedding synchronously with a hard 500ms cap. On timeout or any
613
+ # exception, fall through to emb=None — the materializer fills it async.
614
+ # This preserves the 3.8.2 invariant for cold start while eliminating the
615
+ # semantic-channel blind spot on warm daemons (the top UX complaint).
578
616
  emb = None
579
617
  fmean = fvar = None
618
+ _embedder_ref = self._embedder
619
+ if (
620
+ _embedder_ref is not None
621
+ and getattr(_embedder_ref, "_available", None) is True
622
+ and not _is_remote_embedder(_embedder_ref)
623
+ ):
624
+ import concurrent.futures as _cf
625
+ # Lazy-init the pool once per engine instance — avoids per-call
626
+ # thread churn and the associated resource leak from discard-on-exit.
627
+ # Double-checked locking guards against TOCTOU on concurrent first calls.
628
+ if self._store_fast_embed_pool is None:
629
+ with self._store_fast_embed_pool_lock:
630
+ if self._store_fast_embed_pool is None:
631
+ self._store_fast_embed_pool = _cf.ThreadPoolExecutor(
632
+ max_workers=1,
633
+ thread_name_prefix="slm-sg-embed",
634
+ )
635
+ try:
636
+ _timeout_s = int(os.environ.get("SLM_STORE_FAST_EMBED_TIMEOUT_MS", 500)) / 1000.0
637
+ except (ValueError, TypeError):
638
+ _timeout_s = 0.5 # default 500 ms
639
+ try:
640
+ _future = self._store_fast_embed_pool.submit(_embedder_ref.embed, fact_text)
641
+ try:
642
+ emb = _future.result(timeout=_timeout_s)
643
+ if emb:
644
+ fmean, fvar = _embedder_ref.compute_fisher_params(emb)
645
+ except _cf.TimeoutError:
646
+ logger.debug(
647
+ "store_fast: warm-guard embed timed out (>%.0fms) — deferring to materializer",
648
+ _timeout_s * 1000,
649
+ )
650
+ emb = None
651
+ except Exception as _exc:
652
+ logger.debug(
653
+ "store_fast: warm-guard embed failed (%s) — deferring to materializer",
654
+ _exc,
655
+ )
656
+ emb = None
657
+ except Exception as _exc:
658
+ logger.debug("store_fast: warm-guard pool submit failed (%s)", _exc)
659
+ emb = None
580
660
  fact = AtomicFact(
581
661
  fact_id=_uuid.uuid4().hex[:16], memory_id=record.memory_id,
582
662
  profile_id=self._profile_id, content=fact_text,
@@ -674,44 +754,12 @@ class MemoryEngine:
674
754
  include_shared=include_shared,
675
755
  window=window,
676
756
  )
677
- except Exception as exc:
678
- from superlocalmemory.infra.local_diagnostics import record_operation
679
-
680
- record_operation("recall", client=agent_id, error=exc)
757
+ except Exception:
758
+ # Diagnostics are intentionally not recorded here. A recall is a
759
+ # read command; diagnostics, outcomes, and implicit feedback must
760
+ # be submitted through explicit write commands.
681
761
  raise
682
762
 
683
- from superlocalmemory.infra.local_diagnostics import record_recall
684
-
685
- record_recall(self._db, response, client=agent_id)
686
-
687
- # S9-DASH-02: enqueue for pending_outcomes. Non-blocking; errors
688
- # swallowed because signal capture is never load-bearing on
689
- # recall correctness (LLD-02 §4.9, LLD-08 §4.1).
690
- if session_id:
691
- try:
692
- fact_ids = tuple(
693
- getattr(r.fact, "fact_id", "") or ""
694
- for r in getattr(response, "results", [])
695
- if getattr(r, "fact", None) is not None
696
- )
697
- fact_ids = tuple(f for f in fact_ids if f)
698
- if fact_ids:
699
- enqueue_recall(RecallEvent(
700
- session_id=session_id,
701
- profile_id=pid,
702
- query=query,
703
- fact_ids=fact_ids,
704
- query_id=getattr(response, "query_id", "") or "",
705
- ))
706
- except Exception as _outcome_exc:
707
- # Engagement-signal enqueue is non-blocking; recall
708
- # correctness does not depend on it. Log so the failure
709
- # is visible instead of silently losing learning signals.
710
- logger.warning(
711
- "outcome-queue enqueue failed (engagement signal lost): %s",
712
- _outcome_exc,
713
- )
714
-
715
763
  return response
716
764
 
717
765
  # -- Session operations -------------------------------------------------
@@ -739,25 +787,103 @@ class MemoryEngine:
739
787
  # -- Lifecycle ----------------------------------------------------------
740
788
 
741
789
  def close(self) -> None:
742
- if self._maintenance_scheduler is not None:
743
- self._maintenance_scheduler.stop()
744
- if self._retrieval_engine is not None:
790
+ """Release engine-owned resources without waiting for model workers.
791
+
792
+ Daemon shutdown must be a bounded operation. In particular, a
793
+ ``store_fast`` embed submitted just before shutdown may be blocked in a
794
+ model runtime forever; waiting for its executor here used to make the
795
+ service manager SIGKILL the daemon and leave its children behind.
796
+ References are cleared before invoking each cleanup hook, so a second
797
+ close is safe even when one optional cleanup hook fails.
798
+ """
799
+ scheduler = getattr(self, "_maintenance_scheduler", None)
800
+ self._maintenance_scheduler = None
801
+ if scheduler is not None:
745
802
  try:
746
- self._retrieval_engine.close()
803
+ scheduler.stop()
747
804
  except Exception:
748
- pass
749
- if self._db is not None:
805
+ logger.warning("engine cleanup: maintenance scheduler stop failed", exc_info=True)
806
+
807
+ embed_pool = getattr(self, "_store_fast_embed_pool", None)
808
+ embed_pool_lock = getattr(self, "_store_fast_embed_pool_lock", None)
809
+ if embed_pool_lock is not None:
810
+ with embed_pool_lock:
811
+ embed_pool, self._store_fast_embed_pool = self._store_fast_embed_pool, None
812
+ else:
813
+ self._store_fast_embed_pool = None
814
+ if embed_pool is not None:
750
815
  try:
751
- from superlocalmemory.core.recall_pipeline import (
752
- release_recall_resources,
816
+ embed_pool.shutdown(wait=False, cancel_futures=True)
817
+ except Exception:
818
+ logger.warning(
819
+ "engine cleanup: store-fast embed pool shutdown failed",
820
+ exc_info=True,
753
821
  )
754
- release_recall_resources(self._db)
822
+
823
+ retrieval = getattr(self, "_retrieval_engine", None)
824
+ self._retrieval_engine = None
825
+ if retrieval is not None:
826
+ reranker = getattr(retrieval, "_reranker", None)
827
+ try:
828
+ if reranker is not None:
829
+ shutdown = getattr(reranker, "shutdown", None)
830
+ if callable(shutdown):
831
+ shutdown(timeout=1.0)
832
+ else:
833
+ unload = getattr(reranker, "unload", None)
834
+ if callable(unload):
835
+ unload()
755
836
  except Exception:
756
- pass
837
+ logger.warning("engine cleanup: reranker shutdown failed", exc_info=True)
757
838
  try:
758
- self._db.close()
839
+ retrieval.close(wait=False)
840
+ except TypeError:
841
+ # Compatibility for plugins with the legacy no-argument
842
+ # close hook. Built-in RetrievalEngine accepts ``wait``.
843
+ try:
844
+ retrieval.close()
845
+ except Exception:
846
+ logger.warning(
847
+ "engine cleanup: legacy retrieval shutdown failed",
848
+ exc_info=True,
849
+ )
759
850
  except Exception:
760
- pass
851
+ logger.warning("engine cleanup: retrieval executor shutdown failed", exc_info=True)
852
+
853
+ embedder = getattr(self, "_embedder", None)
854
+ self._embedder = None
855
+ if embedder is not None:
856
+ try:
857
+ shutdown = getattr(embedder, "shutdown", None)
858
+ if callable(shutdown):
859
+ shutdown(timeout=1.0)
860
+ else:
861
+ unload = getattr(embedder, "unload", None)
862
+ if not callable(unload):
863
+ unload = None
864
+ if not callable(shutdown) and callable(unload):
865
+ try:
866
+ unload(timeout=1.0)
867
+ except TypeError:
868
+ # Ollama and third-party embedders may still expose
869
+ # the legacy no-argument unload hook.
870
+ unload()
871
+ except Exception:
872
+ logger.warning("engine cleanup: embedder unload failed", exc_info=True)
873
+
874
+ db = getattr(self, "_db", None)
875
+ self._db = None
876
+ if db is not None:
877
+ try:
878
+ from superlocalmemory.core.recall_pipeline import release_recall_resources
879
+
880
+ release_recall_resources(db)
881
+ except Exception:
882
+ logger.warning("engine cleanup: recall resources release failed", exc_info=True)
883
+ try:
884
+ db.close()
885
+ except Exception:
886
+ logger.warning("engine cleanup: database close failed", exc_info=True)
761
887
  self._initialized = False
762
888
 
763
889
  @property
@@ -14,7 +14,7 @@ import hashlib
14
14
  import logging
15
15
  import os
16
16
  import uuid
17
- from typing import TYPE_CHECKING
17
+ from typing import TYPE_CHECKING, Protocol
18
18
 
19
19
  from superlocalmemory.core.ingestion_command import (
20
20
  IngestionCommand,
@@ -26,7 +26,7 @@ from superlocalmemory.core.ingestion_command import (
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  from superlocalmemory.core.engine import MemoryEngine
29
- from superlocalmemory.storage.models import AtomicFact
29
+ from superlocalmemory.storage.models import AtomicFact, MemoryRecord
30
30
 
31
31
 
32
32
  logger = logging.getLogger(__name__)
@@ -36,6 +36,22 @@ _PREBUILT_FACT_KEY = "_slm_prebuilt_fact_v1"
36
36
  _DERIVATION_VERSION = "v3.7-ingestion-1"
37
37
 
38
38
 
39
+ class _ImmediateAdmissionDatabase(Protocol):
40
+ """The deliberately tiny persistence surface used by receipt admission.
41
+
42
+ The coordinator will bind this to its sole writer connection. Keeping the
43
+ seam this narrow prevents the immediate path from accidentally acquiring a
44
+ model, hook, graph, or secondary-store dependency while it owns SQLite's
45
+ write transaction.
46
+ """
47
+
48
+ def store_memory(self, record: "MemoryRecord") -> str: ...
49
+
50
+ def store_fact(self, fact: "AtomicFact") -> str: ...
51
+
52
+ def execute(self, sql: str, params: tuple = ()) -> list: ...
53
+
54
+
39
55
  def _pii_redaction_enabled(engine: "MemoryEngine") -> bool:
40
56
  """C4: opt-in PII redaction on ingest.
41
57
 
@@ -106,6 +122,124 @@ def _prebuilt_fact_from_payload(payload: dict):
106
122
  return AtomicFact(**values)
107
123
 
108
124
 
125
+ def build_immediate_admission_handler(
126
+ db: _ImmediateAdmissionDatabase,
127
+ *,
128
+ profile_id: str,
129
+ max_verbatim_chars: int = 24_000,
130
+ max_ingest_bytes: int = 1_048_576,
131
+ ):
132
+ """Build the deterministic queryable projection for one engine profile.
133
+
134
+ ``IngestionCommand`` wraps this callback with operation creation and the
135
+ RAW -> QUERYABLE receipt transition in one transaction. This function is
136
+ intentionally restricted to constructing a raw ``MemoryRecord`` and one
137
+ embedding-free ``AtomicFact`` then persisting those two records. All
138
+ authorization hooks, embedding, extraction, FTS-adjacent enrichment,
139
+ graph, provenance, and external index work run only after this receipt has
140
+ committed through the durable materializer.
141
+ """
142
+ def write_queryable(request: IngestionRequest, operation_id: str) -> list[str]:
143
+ if request.profile_id != profile_id:
144
+ raise ValueError("ingestion request profile does not match engine")
145
+ if not request.trusted_actor_id:
146
+ raise ValueError("trusted actor identity is required")
147
+ if not content_passes_admission(request.content):
148
+ return []
149
+
150
+ import re
151
+ from datetime import UTC, datetime
152
+
153
+ from superlocalmemory.core.ingest_gate import apply_ingest_gate
154
+ from superlocalmemory.storage.models import AtomicFact, FactType, MemoryRecord
155
+
156
+ metadata = dict(request.metadata)
157
+ metadata["ingestion_operation_id"] = operation_id
158
+ if request.session_id:
159
+ metadata.setdefault("session_id", request.session_id)
160
+ gate = apply_ingest_gate(
161
+ request.content,
162
+ max_verbatim_chars=max_verbatim_chars,
163
+ max_ingest_bytes=max_ingest_bytes,
164
+ )
165
+ if gate.rejected:
166
+ return []
167
+ fact_content = gate.fact_content
168
+ now = datetime.now(UTC).isoformat()
169
+ observation_date = request.session_date or now[:10]
170
+
171
+ prebuilt_payload = metadata.get(_PREBUILT_FACT_KEY)
172
+ if isinstance(prebuilt_payload, dict):
173
+ fact = _prebuilt_fact_from_payload(prebuilt_payload)
174
+ fact.profile_id = request.profile_id
175
+ fact.scope = request.scope
176
+ fact.shared_with = list(request.shared_with) or None
177
+ fact.session_id = request.session_id or fact.session_id
178
+ if request.session_date:
179
+ fact.observation_date = request.session_date
180
+ else:
181
+ entities = sorted(
182
+ {match.group(1) for match in re.finditer(
183
+ r"\b([A-Z][a-z]+(?:\s[A-Z][a-z]+){0,3})\b", fact_content,
184
+ )}
185
+ | {match.group(1) for match in re.finditer(
186
+ r"\b([A-Z]{2,})\b", fact_content,
187
+ )}
188
+ )
189
+ fact = AtomicFact(
190
+ fact_id=uuid.uuid4().hex[:16],
191
+ profile_id=request.profile_id,
192
+ scope=request.scope,
193
+ shared_with=list(request.shared_with) or None,
194
+ content=fact_content,
195
+ fact_type=FactType.EPISODIC,
196
+ entities=entities,
197
+ observation_date=observation_date,
198
+ session_id=request.session_id,
199
+ confidence=0.7,
200
+ importance=0.5,
201
+ created_at=now,
202
+ )
203
+
204
+ # A receipt is queryable through FTS immediately, but model-derived
205
+ # values cannot participate in its write transaction. The materializer
206
+ # owns their later, retryable population.
207
+ fact.embedding = None
208
+ fact.fisher_mean = None
209
+ fact.fisher_variance = None
210
+ memory_id = fact.memory_id or uuid.uuid4().hex
211
+ existing = db.execute(
212
+ "SELECT profile_id FROM memories WHERE memory_id = ?",
213
+ (memory_id,),
214
+ )
215
+ if existing:
216
+ existing_profile = str(existing[0]["profile_id"])
217
+ if existing_profile != request.profile_id:
218
+ raise ValueError("prebuilt fact memory belongs to a different profile")
219
+ # Reuse the existing source row. DatabaseManager.store_memory uses
220
+ # INSERT OR REPLACE; calling it here would delete every child fact
221
+ # through the memory_id foreign-key cascade before recreating the
222
+ # parent.
223
+ fact.memory_id = memory_id
224
+ else:
225
+ record = MemoryRecord(
226
+ memory_id=memory_id,
227
+ profile_id=request.profile_id,
228
+ content=request.content,
229
+ session_id=request.session_id,
230
+ session_date=observation_date,
231
+ speaker=request.speaker,
232
+ role=request.role,
233
+ metadata=metadata,
234
+ scope=request.scope,
235
+ shared_with=list(request.shared_with) or None,
236
+ )
237
+ fact.memory_id = db.store_memory(record)
238
+ return [db.store_fact(fact)]
239
+
240
+ return write_queryable
241
+
242
+
109
243
  def local_trusted_actor_id(actor_kind: str) -> str:
110
244
  """Derive a stable local actor from the private install capability."""
111
245
  from superlocalmemory.core.security_primitives import ensure_install_token
@@ -283,71 +417,22 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
283
417
  engine._require_full("canonical_ingestion")
284
418
  engine._ensure_init()
285
419
  repository = IngestionOperationRepository(engine._db)
420
+ store_config = getattr(engine._config, "store", None)
421
+ write_queryable = build_immediate_admission_handler(
422
+ engine._db,
423
+ profile_id=engine._profile_id,
424
+ max_verbatim_chars=getattr(store_config, "max_verbatim_chars", 24_000),
425
+ max_ingest_bytes=getattr(store_config, "max_ingest_bytes", 1_048_576),
426
+ )
286
427
 
287
- def write_queryable(request: IngestionRequest, operation_id: str) -> list[str]:
288
- if request.profile_id != engine._profile_id:
289
- raise ValueError("ingestion request profile does not match engine")
290
- if not request.trusted_actor_id:
291
- raise ValueError("trusted actor identity is required")
292
- hook_context = {
428
+ def validate_admission(request: IngestionRequest) -> None:
429
+ """Apply trust policy before the journal or canonical transaction."""
430
+ engine._hooks.run_pre("store", {
293
431
  "operation": "store",
294
- "agent_id": request.trusted_actor_id or "unknown",
432
+ "agent_id": request.trusted_actor_id,
295
433
  "profile_id": request.profile_id,
296
434
  "content_preview": request.content[:100],
297
- "ingestion_operation_id": operation_id,
298
- }
299
- # Authorization and trust policy must run before raw evidence reaches
300
- # durable storage. Materialization reuses this authorization decision.
301
- engine._hooks.run_pre("store", hook_context)
302
- metadata = dict(request.metadata)
303
- metadata["ingestion_operation_id"] = operation_id
304
- if request.session_id:
305
- metadata.setdefault("session_id", request.session_id)
306
- prebuilt_payload = metadata.get(_PREBUILT_FACT_KEY)
307
- if isinstance(prebuilt_payload, dict):
308
- from superlocalmemory.storage.models import MemoryRecord
309
-
310
- fact = _prebuilt_fact_from_payload(prebuilt_payload)
311
- fact.profile_id = request.profile_id
312
- fact.scope = request.scope
313
- fact.shared_with = list(request.shared_with) or None
314
- fact.session_id = request.session_id or fact.session_id
315
- if request.session_date:
316
- fact.observation_date = request.session_date
317
- memory_id = fact.memory_id
318
- memory_rows = (
319
- engine._db.execute(
320
- "SELECT memory_id FROM memories WHERE memory_id=? AND profile_id=?",
321
- (memory_id, request.profile_id),
322
- )
323
- if memory_id else []
324
- )
325
- if not memory_rows:
326
- record = MemoryRecord(
327
- memory_id=memory_id or uuid.uuid4().hex,
328
- profile_id=request.profile_id,
329
- content=request.content,
330
- session_id=request.session_id,
331
- session_date=request.session_date,
332
- metadata=metadata,
333
- scope=request.scope,
334
- shared_with=list(request.shared_with) or None,
335
- )
336
- engine._db.store_memory(record)
337
- memory_id = record.memory_id
338
- fact.memory_id = memory_id
339
- engine._db.store_fact(fact)
340
- return [fact.fact_id]
341
- return engine.store_fast(
342
- request.content,
343
- metadata=metadata,
344
- scope=request.scope,
345
- shared_with=list(request.shared_with) or None,
346
- session_date=request.session_date or None,
347
- speaker=request.speaker,
348
- role=request.role,
349
- index_external=False,
350
- )
435
+ })
351
436
 
352
437
  def resume_checkpoint(operation: IngestionOperation) -> MaterializationResult:
353
438
  """Repair only stages whose writes have an idempotent natural key."""
@@ -816,12 +901,14 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
816
901
  repository,
817
902
  write_queryable=write_queryable,
818
903
  materialize=materialize,
904
+ validate_admission=validate_admission,
819
905
  project=project,
820
906
  derivation_version=_DERIVATION_VERSION,
821
907
  )
822
908
 
823
909
 
824
910
  __all__ = [
911
+ "build_immediate_admission_handler",
825
912
  "build_engine_ingestion_command",
826
913
  "canonical_store",
827
914
  "canonical_store_fn",