superlocalmemory 3.8.5 → 3.8.7

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 (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  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 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
  35. package/src/superlocalmemory/core/component_registry.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +33 -6
  37. package/src/superlocalmemory/core/engine.py +94 -49
  38. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  39. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  40. package/src/superlocalmemory/core/mutations.py +32 -10
  41. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  42. package/src/superlocalmemory/core/remember_admission.py +152 -0
  43. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  44. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  45. package/src/superlocalmemory/learning/bandit.py +50 -1
  46. package/src/superlocalmemory/learning/source_quality.py +38 -35
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  48. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  49. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  50. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  51. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  52. package/src/superlocalmemory/retrieval/engine.py +8 -3
  53. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  54. package/src/superlocalmemory/server/loopback.py +7 -13
  55. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  56. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  57. package/src/superlocalmemory/server/routes/agents.py +3 -5
  58. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  59. package/src/superlocalmemory/server/routes/brain.py +6 -9
  60. package/src/superlocalmemory/server/routes/entity.py +3 -7
  61. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  62. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  63. package/src/superlocalmemory/server/routes/insights.py +2 -4
  64. package/src/superlocalmemory/server/routes/learning.py +2 -5
  65. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  66. package/src/superlocalmemory/server/routes/memories.py +122 -100
  67. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  68. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  69. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  70. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  71. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  72. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  73. package/src/superlocalmemory/storage/database.py +59 -0
  74. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  75. package/src/superlocalmemory/storage/memory_write.py +8 -12
  76. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  77. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  78. package/src/superlocalmemory/storage/read_connection.py +115 -0
  79. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  80. package/src/superlocalmemory/ui/index.html +1 -1
  81. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  82. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -438,7 +438,13 @@ class BackendOrchestrator:
438
438
  self._cozo = CozoDBGraphBackend(str(cozo_path / "graph"))
439
439
  self._update_status("cozo", "not_initialized")
440
440
  logger.info("CozoDB initialized at %s", cozo_path)
441
- except Exception as exc:
441
+ except BaseException as exc:
442
+ # PyO3 exposes Rust panics as PanicException(BaseException), not
443
+ # Exception. An incompatible optional projection must never abort
444
+ # daemon startup or hide canonical SQLite memory. Re-raise genuine
445
+ # process-control exceptions; preserve the graph and degrade Cozo.
446
+ if not isinstance(exc, Exception) and type(exc).__name__ != "PanicException":
447
+ raise
442
448
  logger.warning("CozoDB init failed: %s", exc)
443
449
  self._cozo = None
444
450
 
@@ -265,8 +265,10 @@ def probe_reranker_model(config: Any = None) -> Component:
265
265
  auto_fixable=enabled,
266
266
  fix_cmd="slm doctor --fix",
267
267
  )
268
- if not enabled and comp.status == STATUS_MISSING:
269
- # Not enabled absent is expected, not a problem.
268
+ if not enabled:
269
+ # A cached reranker is still inactive when the operator disabled the
270
+ # channel. The dashboard must describe configured runtime state, not
271
+ # machine-specific HuggingFace-cache state.
270
272
  return replace(comp, status=STATUS_OK,
271
273
  detail="disabled (retrieval.use_cross_encoder=false)",
272
274
  fix_cmd="", auto_fixable=False)
@@ -250,11 +250,38 @@ class EmbeddingService:
250
250
  def dimension(self) -> int:
251
251
  return self._config.dimension
252
252
 
253
- def unload(self) -> None:
254
- """Kill the worker subprocess to free all memory."""
255
- with self._lock:
253
+ def unload(self, timeout: float = 1.0) -> bool:
254
+ """Release the worker without blocking daemon shutdown on an embed call.
255
+
256
+ An in-flight request owns ``_lock`` while it waits for the worker's
257
+ response. Shutdown must not wait behind a wedged response: callers
258
+ can continue teardown and the worker process will be handled by the
259
+ process supervisor if necessary.
260
+ """
261
+ if not self._lock.acquire(timeout=max(0.0, timeout)):
262
+ logger.warning("EmbeddingService: unload skipped; embed worker is busy")
263
+ return False
264
+ try:
256
265
  self._kill_worker()
257
266
  logger.info("EmbeddingService: worker killed (idle timeout)")
267
+ return True
268
+ finally:
269
+ self._lock.release()
270
+
271
+ def shutdown(self, timeout: float = 1.0) -> None:
272
+ """Force bounded process teardown even when an embed call owns the lock.
273
+
274
+ Shutdown is stronger than the idle-time ``unload`` operation. Once
275
+ the engine is closing, no new request may use this service, so it is
276
+ safe to detach and terminate a wedged child without waiting behind the
277
+ request lock.
278
+ """
279
+ acquired = self._lock.acquire(timeout=max(0.0, timeout))
280
+ try:
281
+ self._kill_worker(timeout=min(max(0.0, timeout), 1.0))
282
+ finally:
283
+ if acquired:
284
+ self._lock.release()
258
285
 
259
286
  # ------------------------------------------------------------------
260
287
  # Public API
@@ -596,7 +623,7 @@ class EmbeddingService:
596
623
  self._available = False
597
624
  self._worker_proc = None
598
625
 
599
- def _kill_worker(self) -> None:
626
+ def _kill_worker(self, timeout: float = 3.0) -> None:
600
627
  """Terminate the worker and close every owned pipe exactly once."""
601
628
  if self._idle_timer is not None:
602
629
  self._idle_timer.cancel()
@@ -610,7 +637,7 @@ class EmbeddingService:
610
637
  try:
611
638
  proc.stdin.write('{"cmd":"quit"}\n')
612
639
  proc.stdin.flush()
613
- proc.wait(timeout=3)
640
+ proc.wait(timeout=max(0.0, timeout))
614
641
  except Exception:
615
642
  try:
616
643
  returncode = proc.poll()
@@ -621,7 +648,7 @@ class EmbeddingService:
621
648
  if returncode is None or not isinstance(returncode, int):
622
649
  try:
623
650
  proc.kill()
624
- proc.wait(timeout=3)
651
+ proc.wait(timeout=max(0.0, timeout))
625
652
  except Exception:
626
653
  pass
627
654
  finally:
@@ -26,7 +26,6 @@ from typing import Any
26
26
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT, SLMConfig
27
27
  from superlocalmemory.core.engine_capabilities import Capabilities, CapabilityError
28
28
  from superlocalmemory.core.modes import get_capabilities
29
- from superlocalmemory.learning.outcome_queue import RecallEvent, enqueue_recall
30
29
  from superlocalmemory.storage.models import (
31
30
  AtomicFact, FactType, MemoryRecord, Mode, RecallResponse,
32
31
  )
@@ -755,44 +754,12 @@ class MemoryEngine:
755
754
  include_shared=include_shared,
756
755
  window=window,
757
756
  )
758
- except Exception as exc:
759
- from superlocalmemory.infra.local_diagnostics import record_operation
760
-
761
- 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.
762
761
  raise
763
762
 
764
- from superlocalmemory.infra.local_diagnostics import record_recall
765
-
766
- record_recall(self._db, response, client=agent_id)
767
-
768
- # S9-DASH-02: enqueue for pending_outcomes. Non-blocking; errors
769
- # swallowed because signal capture is never load-bearing on
770
- # recall correctness (LLD-02 §4.9, LLD-08 §4.1).
771
- if session_id:
772
- try:
773
- fact_ids = tuple(
774
- getattr(r.fact, "fact_id", "") or ""
775
- for r in getattr(response, "results", [])
776
- if getattr(r, "fact", None) is not None
777
- )
778
- fact_ids = tuple(f for f in fact_ids if f)
779
- if fact_ids:
780
- enqueue_recall(RecallEvent(
781
- session_id=session_id,
782
- profile_id=pid,
783
- query=query,
784
- fact_ids=fact_ids,
785
- query_id=getattr(response, "query_id", "") or "",
786
- ))
787
- except Exception as _outcome_exc:
788
- # Engagement-signal enqueue is non-blocking; recall
789
- # correctness does not depend on it. Log so the failure
790
- # is visible instead of silently losing learning signals.
791
- logger.warning(
792
- "outcome-queue enqueue failed (engagement signal lost): %s",
793
- _outcome_exc,
794
- )
795
-
796
763
  return response
797
764
 
798
765
  # -- Session operations -------------------------------------------------
@@ -820,25 +787,103 @@ class MemoryEngine:
820
787
  # -- Lifecycle ----------------------------------------------------------
821
788
 
822
789
  def close(self) -> None:
823
- if self._maintenance_scheduler is not None:
824
- self._maintenance_scheduler.stop()
825
- 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:
826
802
  try:
827
- self._retrieval_engine.close()
803
+ scheduler.stop()
828
804
  except Exception:
829
- pass
830
- 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:
831
815
  try:
832
- from superlocalmemory.core.recall_pipeline import (
833
- 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,
834
821
  )
835
- 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()
836
836
  except Exception:
837
- pass
837
+ logger.warning("engine cleanup: reranker shutdown failed", exc_info=True)
838
838
  try:
839
- 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
+ )
840
850
  except Exception:
841
- 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)
842
887
  self._initialized = False
843
888
 
844
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",