superlocalmemory 3.2.1 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +114 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. package/src/superlocalmemory/storage/v2_migrator.py +24 -2
@@ -2,9 +2,13 @@
2
2
  # Licensed under the MIT License - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """SuperLocalMemory V3 — Main Memory Engine.
5
+ """SuperLocalMemory V3 — Main Memory Engine (Facade).
6
+
7
+ Thin orchestrator that delegates to extracted pipeline modules:
8
+ - store_pipeline (store, store_fact_direct, close_session, enrich_fact)
9
+ - recall_pipeline (recall, adaptive ranking)
10
+ - engine_wiring (embedder init, encoding init, retrieval init, hooks)
6
11
 
7
- Orchestrates the full memory lifecycle: store, encode, retrieve.
8
12
  Single entry point for all memory operations.
9
13
  Profile-scoped. Mode-aware (A/B/C).
10
14
 
@@ -19,7 +23,7 @@ from typing import Any
19
23
  from superlocalmemory.core.config import SLMConfig
20
24
  from superlocalmemory.core.modes import get_capabilities
21
25
  from superlocalmemory.storage.models import (
22
- AtomicFact, FactType, MemoryRecord, Mode, RecallResponse,
26
+ AtomicFact, MemoryRecord, Mode, RecallResponse,
23
27
  )
24
28
 
25
29
  logger = logging.getLogger(__name__)
@@ -67,8 +71,35 @@ class MemoryEngine:
67
71
  self._provenance = None
68
72
  self._adaptive_learner = None
69
73
  self._compliance_checker = None
74
+ self._vector_store = None
75
+ self._access_log = None
76
+ self._context_generator = None
77
+ self._temporal_validator = None
78
+ self._auto_invoker = None
79
+ self._auto_linker = None
80
+ self._graph_analyzer = None
81
+ self._consolidation_engine = None
70
82
  self._hooks = HookRegistry()
71
83
 
84
+ # -- Public properties (Phase 2+ access) --------------------------------
85
+
86
+ @property
87
+ def db(self):
88
+ """Database manager (read-only access for Phase 2+)."""
89
+ return self._db
90
+
91
+ @property
92
+ def trust_scorer(self):
93
+ """Trust scorer (read-only access for Phase 2+)."""
94
+ return self._trust_scorer
95
+
96
+ @property
97
+ def embedder(self):
98
+ """Embedding service (read-only access for Phase 2+)."""
99
+ return self._embedder
100
+
101
+ # -- Initialization -----------------------------------------------------
102
+
72
103
  def initialize(self) -> None:
73
104
  """Initialize all components. Call once before use."""
74
105
  if self._initialized:
@@ -76,17 +107,22 @@ class MemoryEngine:
76
107
 
77
108
  from superlocalmemory.storage import schema
78
109
  from superlocalmemory.storage.database import DatabaseManager
79
- from superlocalmemory.core.embeddings import EmbeddingService
80
110
  from superlocalmemory.llm.backbone import LLMBackbone
111
+ from superlocalmemory.core.engine_wiring import (
112
+ init_embedder, init_encoding, init_retrieval, wire_hooks,
113
+ _init_auto_invoker, _init_consolidation,
114
+ )
81
115
 
82
116
  self._db = DatabaseManager(self._config.db_path)
83
117
  self._db.initialize(schema)
84
- self._embedder = self._init_embedder()
118
+ self._embedder = init_embedder(self._config)
85
119
 
86
120
  if self._caps.llm_fact_extraction:
87
121
  self._llm = LLMBackbone(self._config.llm)
88
122
  if not self._llm.is_available():
89
- logger.warning("LLM not available. Falling back to Mode A extraction.")
123
+ logger.warning(
124
+ "LLM not available. Falling back to Mode A extraction.",
125
+ )
90
126
  self._llm = None
91
127
 
92
128
  from superlocalmemory.trust.scorer import TrustScorer
@@ -96,89 +132,72 @@ class MemoryEngine:
96
132
 
97
133
  self._trust_scorer = TrustScorer(self._db)
98
134
 
99
- self._init_encoding()
100
- self._init_retrieval()
135
+ # Encoding components
136
+ enc = init_encoding(
137
+ self._config, self._db, self._embedder, self._llm,
138
+ )
139
+ self._ann_index = enc["ann_index"]
140
+ self._fact_extractor = enc["fact_extractor"]
141
+ self._entity_resolver = enc["entity_resolver"]
142
+ self._temporal_parser = enc["temporal_parser"]
143
+ self._type_router = enc["type_router"]
144
+ self._graph_builder = enc["graph_builder"]
145
+ self._consolidator = enc["consolidator"]
146
+ self._observation_builder = enc["observation_builder"]
147
+ self._scene_builder = enc["scene_builder"]
148
+ self._entropy_gate = enc["entropy_gate"]
149
+ self._sheaf_checker = enc["sheaf_checker"]
150
+ self._vector_store = enc.get("vector_store")
151
+ self._access_log = enc.get("access_log")
152
+ self._context_generator = enc.get("context_generator")
153
+ self._temporal_validator = enc.get("temporal_validator")
154
+ self._auto_linker = enc.get("auto_linker")
155
+ self._graph_analyzer = enc.get("graph_analyzer")
156
+
157
+ # Retrieval engine
158
+ self._retrieval_engine = init_retrieval(
159
+ self._config, self._db, self._embedder,
160
+ self._entity_resolver, self._trust_scorer,
161
+ vector_store=self._vector_store,
162
+ )
101
163
 
102
164
  self._provenance = ProvenanceTracker(self._db)
103
165
  self._adaptive_learner = AdaptiveLearner(self._db)
104
166
  self._compliance_checker = EUAIActChecker()
105
167
 
106
168
  # Wire lifecycle hooks
107
- self._wire_hooks()
169
+ hook_result = wire_hooks(
170
+ self._hooks, self._config, self._db,
171
+ self._trust_scorer, self._profile_id,
172
+ )
173
+ self._signal_recorder = hook_result["signal_recorder"]
174
+ self._audit_chain = hook_result["audit_chain"]
175
+
176
+ # V3.2: AutoInvoker (Phase 2) -- multi-signal auto-recall
177
+ self._auto_invoker = _init_auto_invoker(
178
+ self._config, self._db, self._vector_store,
179
+ self._trust_scorer, self._embedder,
180
+ )
181
+
182
+ # V3.2: ConsolidationEngine (Phase 5) -- sleep-time consolidation
183
+ from superlocalmemory.core.summarizer import Summarizer
184
+ summarizer = Summarizer(self._config)
185
+ self._consolidation_engine = _init_consolidation(
186
+ self._config, self._db,
187
+ auto_linker=self._auto_linker,
188
+ graph_analyzer=self._graph_analyzer,
189
+ temporal_validator=self._temporal_validator,
190
+ summarizer=summarizer,
191
+ behavioral_store=None,
192
+ )
108
193
 
109
194
  self._initialized = True
110
- logger.info("MemoryEngine initialized: mode=%s profile=%s",
111
- self._config.mode.value, self._profile_id)
112
-
113
- def _init_embedder(self):
114
- """Initialize the best available embedding provider.
115
-
116
- Priority order:
117
- 1. Explicit provider in config (ollama / cloud / sentence-transformers)
118
- 2. Auto-detect: if LLM provider=ollama and Ollama has embedding model → use it
119
- 3. Fallback to sentence-transformers subprocess
120
- 4. If nothing works → None (BM25-only mode)
121
- """
122
- from superlocalmemory.core.embeddings import EmbeddingService
123
-
124
- emb_cfg = self._config.embedding
125
- provider = emb_cfg.provider
126
-
127
- # --- Explicit ollama provider ---
128
- if provider == "ollama":
129
- return self._try_ollama_embedder(emb_cfg)
130
-
131
- # --- Explicit cloud provider ---
132
- if provider == "cloud" or emb_cfg.is_cloud:
133
- return self._try_service_embedder(EmbeddingService, emb_cfg)
134
-
135
- # --- Explicit sentence-transformers ---
136
- if provider == "sentence-transformers":
137
- return self._try_service_embedder(EmbeddingService, emb_cfg)
138
-
139
- # --- Auto-detect: try Ollama first (fast path, <1s) ---
140
- # Check regardless of LLM provider — if Ollama is running and has
141
- # the embedding model, use it. This avoids the 30s cold start of
142
- # sentence-transformers subprocess.
143
- ollama_emb = self._try_ollama_embedder(emb_cfg)
144
- if ollama_emb is not None:
145
- logger.info("Auto-detected Ollama embeddings (fast path)")
146
- return ollama_emb
147
-
148
- # --- Fallback: sentence-transformers subprocess ---
149
- return self._try_service_embedder(EmbeddingService, emb_cfg)
150
-
151
- def _try_ollama_embedder(self, emb_cfg):
152
- """Try to create an OllamaEmbedder. Returns it or None."""
153
- try:
154
- from superlocalmemory.core.ollama_embedder import OllamaEmbedder
155
- emb = OllamaEmbedder(
156
- model=emb_cfg.ollama_model,
157
- base_url=emb_cfg.ollama_base_url,
158
- dimension=emb_cfg.dimension,
159
- )
160
- if emb.is_available:
161
- logger.info("Using Ollama embeddings (%s)", emb_cfg.ollama_model)
162
- return emb
163
- logger.warning(
164
- "Ollama embedder not available (model=%s). Falling back.",
165
- emb_cfg.ollama_model,
166
- )
167
- except Exception as exc:
168
- logger.warning("OllamaEmbedder init failed: %s", exc)
169
- return None
170
-
171
- @staticmethod
172
- def _try_service_embedder(cls, emb_cfg):
173
- """Try to create an EmbeddingService. Returns it or None."""
174
- try:
175
- emb = cls(emb_cfg)
176
- if emb.is_available:
177
- return emb
178
- logger.warning("EmbeddingService not available. BM25-only mode. Run 'slm doctor' to diagnose.")
179
- except Exception as exc:
180
- logger.warning("Embeddings unavailable (%s). BM25-only mode. Run 'slm doctor' to diagnose.", exc)
181
- return None
195
+ logger.info(
196
+ "MemoryEngine initialized: mode=%s profile=%s",
197
+ self._config.mode.value, self._profile_id,
198
+ )
199
+
200
+ # -- Store operations ---------------------------------------------------
182
201
 
183
202
  def store(
184
203
  self,
@@ -192,364 +211,79 @@ class MemoryEngine:
192
211
  """Store content and extract structured facts. Returns fact_ids."""
193
212
  self._ensure_init()
194
213
 
195
- # Pre-operation hooks (trust gate, ABAC, rate limiter)
196
- hook_ctx = {"operation": "store", "agent_id": metadata.get("agent_id", "unknown") if metadata else "unknown",
197
- "profile_id": self._profile_id, "content_preview": content[:100]}
198
- self._hooks.run_pre("store", hook_ctx)
199
-
200
- if self._entropy_gate and not self._entropy_gate.should_pass(content):
201
- return []
214
+ from superlocalmemory.core.store_pipeline import run_store
215
+ return run_store(
216
+ content, self._profile_id,
217
+ session_id=session_id, session_date=session_date,
218
+ speaker=speaker, role=role, metadata=metadata,
219
+ config=self._config, db=self._db,
220
+ embedder=self._embedder,
221
+ fact_extractor=self._fact_extractor,
222
+ entity_resolver=self._entity_resolver,
223
+ temporal_parser=self._temporal_parser,
224
+ type_router=self._type_router,
225
+ graph_builder=self._graph_builder,
226
+ consolidator=self._consolidator,
227
+ observation_builder=self._observation_builder,
228
+ scene_builder=self._scene_builder,
229
+ entropy_gate=self._entropy_gate,
230
+ ann_index=self._ann_index,
231
+ sheaf_checker=self._sheaf_checker,
232
+ retrieval_engine=self._retrieval_engine,
233
+ provenance=self._provenance,
234
+ hooks=self._hooks,
235
+ vector_store=self._vector_store,
236
+ context_generator=self._context_generator,
237
+ temporal_validator=self._temporal_validator,
238
+ auto_linker=self._auto_linker,
239
+ consolidation_engine=self._consolidation_engine,
240
+ )
202
241
 
203
- from superlocalmemory.encoding.temporal_parser import TemporalParser
204
- parser = self._temporal_parser or TemporalParser()
205
- parsed_date = parser.parse_session_date(session_date) if session_date else None
242
+ def store_fact_direct(self, fact: AtomicFact) -> str:
243
+ """Store a pre-built fact with full enrichment."""
244
+ self._ensure_init()
206
245
 
207
- record = MemoryRecord(
208
- profile_id=self._profile_id, content=content,
209
- session_id=session_id, speaker=speaker, role=role,
210
- session_date=parsed_date, metadata=metadata or {},
246
+ from superlocalmemory.core.store_pipeline import run_store_fact_direct
247
+ return run_store_fact_direct(
248
+ fact, self._profile_id,
249
+ db=self._db, embedder=self._embedder,
250
+ entity_resolver=self._entity_resolver,
251
+ ann_index=self._ann_index,
252
+ graph_builder=self._graph_builder,
253
+ retrieval_engine=self._retrieval_engine,
254
+ vector_store=self._vector_store,
211
255
  )
212
- self._db.store_memory(record)
213
256
 
214
- facts = self._fact_extractor.extract_facts(
215
- turns=[content], session_id=session_id,
216
- session_date=parsed_date, speaker_a=speaker,
217
- )
218
- if not facts:
219
- return []
220
-
221
- if self._type_router:
222
- facts = self._type_router.route_facts(facts)
223
-
224
- stored_ids: list[str] = []
225
- for fact in facts:
226
- fact = self._enrich_fact(fact, record)
227
-
228
- if self._consolidator:
229
- action = self._consolidator.consolidate(fact, self._profile_id)
230
- if action.action_type.value == "noop":
231
- continue
232
-
233
- # Opinion confidence tracking: reinforce or decay
234
- # When a new opinion aligns with existing, boost confidence.
235
- # When contradicted (supersede), reduce old fact's confidence.
236
- if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
237
- try:
238
- existing = self._db.get_fact(action.new_fact_id)
239
- if existing and existing.fact_type == FactType.OPINION:
240
- new_conf = min(1.0, existing.confidence + 0.1)
241
- self._db.update_fact(action.new_fact_id, {"confidence": new_conf})
242
- except Exception:
243
- pass
244
- elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
245
- try:
246
- old_id = getattr(action, "old_fact_id", None)
247
- if old_id:
248
- old_fact = self._db.get_fact(old_id)
249
- if old_fact:
250
- new_conf = max(0.0, old_fact.confidence - 0.2)
251
- self._db.update_fact(old_id, {"confidence": new_conf})
252
- except Exception:
253
- pass
254
-
255
- if action.action_type.value in ("update", "supersede"):
256
- # Run post-processing on updated facts
257
- updated_fact = self._db.get_fact(action.new_fact_id)
258
- if updated_fact:
259
- if self._graph_builder:
260
- self._graph_builder.build_edges(updated_fact, self._profile_id)
261
- if self._observation_builder:
262
- for eid in updated_fact.canonical_entities:
263
- self._observation_builder.update_profile(
264
- eid, updated_fact, self._profile_id,
265
- )
266
- stored_ids.append(action.new_fact_id)
267
- continue
268
- # ADD case: consolidator already stored the fact (F8 fix)
269
- # Fall through to post-processing below
270
- else:
271
- self._db.store_fact(fact)
272
-
273
- stored_ids.append(fact.fact_id)
274
-
275
- if fact.embedding and self._ann_index:
276
- self._ann_index.add(fact.fact_id, fact.embedding)
277
- if self._graph_builder:
278
- self._graph_builder.build_edges(fact, self._profile_id)
279
-
280
- # Sheaf consistency check (runs after edges exist)
281
- # Cap edge check to prevent O(N^2) hang on large graphs
282
- if (self._sheaf_checker
283
- and fact.embedding
284
- and fact.canonical_entities):
285
- from superlocalmemory.storage.models import EdgeType, GraphEdge
286
- try:
287
- edges_for_fact = self._db.get_edges_for_node(
288
- fact.fact_id, self._profile_id,
289
- )
290
- # Only run sheaf if edge count is manageable
291
- # At 18K+ edges, the coboundary computation becomes O(N*dim^2)
292
- if len(edges_for_fact) < self._config.math.sheaf_max_edges_per_check:
293
- contradictions = self._sheaf_checker.check_consistency(
294
- fact, self._profile_id,
295
- )
296
- for c in contradictions:
297
- if c.severity > 0.45:
298
- edge = GraphEdge(
299
- profile_id=self._profile_id,
300
- source_id=fact.fact_id,
301
- target_id=c.fact_id_b,
302
- edge_type=EdgeType.SUPERSEDES,
303
- weight=c.severity,
304
- )
305
- self._db.store_edge(edge)
306
- except Exception as exc:
307
- logger.debug("Sheaf check skipped: %s", exc)
308
-
309
- if self._observation_builder:
310
- for eid in fact.canonical_entities:
311
- self._observation_builder.update_profile(eid, fact, self._profile_id)
312
-
313
- # Increment fact_count for each linked canonical entity
314
- for eid in fact.canonical_entities:
315
- try:
316
- self._db.increment_entity_fact_count(eid)
317
- except Exception:
318
- pass # Non-critical — entity may have been deleted
319
- if self._scene_builder:
320
- self._scene_builder.assign_to_scene(fact, self._profile_id)
321
-
322
- # Populate temporal_events for temporal retrieval
323
- has_dates = (fact.observation_date or fact.referenced_date
324
- or fact.interval_start)
325
- if fact.canonical_entities and has_dates:
326
- from superlocalmemory.storage.models import TemporalEvent
327
- for eid in fact.canonical_entities:
328
- event = TemporalEvent(
329
- profile_id=self._profile_id, entity_id=eid,
330
- fact_id=fact.fact_id,
331
- observation_date=fact.observation_date,
332
- referenced_date=fact.referenced_date,
333
- interval_start=fact.interval_start,
334
- interval_end=fact.interval_end,
335
- description=fact.content[:200],
336
- )
337
- self._db.store_temporal_event(event)
338
-
339
- # Foresight: extract time-bounded predictions
340
- try:
341
- from superlocalmemory.encoding.foresight import extract_foresight_signals
342
- from superlocalmemory.storage.models import TemporalEvent as _TE
343
- foresight_signals = extract_foresight_signals(fact)
344
- for sig in foresight_signals:
345
- f_event = _TE(
346
- profile_id=self._profile_id,
347
- entity_id=sig.get("entity_id", ""),
348
- fact_id=fact.fact_id,
349
- interval_start=sig.get("start_time"),
350
- interval_end=sig.get("end_time"),
351
- description=sig.get("description", ""),
352
- )
353
- self._db.store_temporal_event(f_event)
354
- except Exception as exc:
355
- logger.debug("Foresight extraction: %s", exc)
356
-
357
- # Persist BM25 tokens at ingestion
358
- bm25 = getattr(self._retrieval_engine, '_bm25', None) if self._retrieval_engine else None
359
- if bm25:
360
- bm25.add(fact.fact_id, fact.content, self._profile_id)
361
-
362
- # Record provenance for data lineage (EU AI Act Art. 10)
363
- if self._provenance:
364
- try:
365
- self._provenance.record(
366
- fact_id=fact.fact_id,
367
- profile_id=self._profile_id,
368
- source_type="store",
369
- source_id=session_id,
370
- created_by=speaker or "unknown",
371
- )
372
- except Exception:
373
- pass
374
-
375
- logger.info("Stored %d facts (session=%s)", len(stored_ids), session_id)
376
-
377
- # Post-operation hooks (audit, trust signal, event bus)
378
- hook_ctx["fact_ids"] = stored_ids
379
- hook_ctx["fact_count"] = len(stored_ids)
380
- self._hooks.run_post("store", hook_ctx)
381
-
382
- return stored_ids
257
+ # -- Recall operations --------------------------------------------------
383
258
 
384
259
  def recall(
385
260
  self, query: str, profile_id: str | None = None,
386
261
  mode: Mode | None = None, limit: int = 20,
387
262
  agent_id: str = "unknown",
388
263
  ) -> RecallResponse:
389
- """Recall relevant facts for a query.
390
-
391
- Pipeline: retrieval → agentic sufficiency (if configured) → post-recall updates.
392
- Agentic sufficiency (sufficiency check): triggers 2-round re-retrieval when
393
- initial results are insufficient. Mode C uses LLM judgment; Mode A uses
394
- heuristic alias expansion.
395
- """
264
+ """Recall relevant facts for a query."""
396
265
  self._ensure_init()
397
266
 
398
- # Pre-operation hooks
399
- hook_ctx = {"operation": "recall", "agent_id": agent_id,
400
- "profile_id": profile_id or self._profile_id, "query_preview": query[:100]}
401
- self._hooks.run_pre("recall", hook_ctx)
402
-
403
267
  pid = profile_id or self._profile_id
404
- m = mode or self._config.mode
405
-
406
- response = self._retrieval_engine.recall(query, pid, m, limit)
407
-
408
- # Agentic sufficiency verification
409
- # Only trigger when: (a) configured rounds > 0, (b) results look weak
410
- agentic_rounds = self._config.retrieval.agentic_max_rounds
411
- if agentic_rounds > 0 and response.results:
412
- max_score = max((r.score for r in response.results), default=0.0)
413
- should_trigger = (
414
- max_score < self._config.retrieval.agentic_confidence_threshold
415
- or response.query_type == "multi_hop"
416
- or len(response.results) < 3
417
- )
418
- if should_trigger:
419
- try:
420
- from superlocalmemory.retrieval.agentic import AgenticRetriever
421
- agentic = AgenticRetriever(
422
- confidence_threshold=self._config.retrieval.agentic_confidence_threshold,
423
- db=self._db,
424
- )
425
- enhanced_facts = agentic.retrieve(
426
- query=query, profile_id=pid,
427
- retrieval_engine=self._retrieval_engine,
428
- llm=self._llm,
429
- top_k=limit,
430
- query_type=response.query_type,
431
- )
432
- # Replace response results with enhanced facts if we got more
433
- if len(enhanced_facts) > len(response.results):
434
- from superlocalmemory.storage.models import RetrievalResult
435
- enhanced_results = []
436
- for i, f in enumerate(enhanced_facts):
437
- # Look up real trust score for agentic results
438
- fact_trust = 0.5
439
- if self._trust_scorer:
440
- try:
441
- fact_trust = self._trust_scorer.get_fact_trust(
442
- f.fact_id, pid,
443
- )
444
- except Exception:
445
- pass
446
- enhanced_results.append(RetrievalResult(
447
- fact=f, score=1.0 / (i + 1),
448
- channel_scores={"agentic": 1.0},
449
- confidence=f.confidence,
450
- evidence_chain=["agentic_round_2"],
451
- trust_score=fact_trust,
452
- ))
453
- response = RecallResponse(
454
- query=query, mode=m, results=enhanced_results[:limit],
455
- query_type=response.query_type,
456
- channel_weights=response.channel_weights,
457
- total_candidates=response.total_candidates + len(enhanced_facts),
458
- retrieval_time_ms=response.retrieval_time_ms,
459
- )
460
- except Exception as exc:
461
- logger.debug("Agentic sufficiency skipped: %s", exc)
462
-
463
- # Adaptive re-ranking (V3.1 Active Memory)
464
- # Phase 1 (< 50 signals): no change (cross-encoder order preserved)
465
- # Phase 2 (50+): heuristic boosts (recency, access, trust)
466
- # Phase 3 (200+): LightGBM ML ranking
467
- try:
468
- response = self._apply_adaptive_ranking(response, query, pid)
469
- except Exception as exc:
470
- logger.debug("Adaptive ranking skipped: %s", exc)
471
-
472
- # Reconsolidation: access updates trust + count (neuroscience principle)
473
- if self._trust_scorer:
474
- for r in response.results:
475
- self._trust_scorer.update_on_access("fact", r.fact.fact_id, pid)
476
-
477
- # Fisher Bayesian update on recall
478
- q_emb = self._embedder.embed(query) if self._embedder else None
479
- q_var_arr = None
480
- if self._embedder and q_emb:
481
- _, q_var_list = self._embedder.compute_fisher_params(q_emb)
482
- import numpy as _np
483
- q_var_arr = _np.array(q_var_list, dtype=_np.float64)
484
-
485
- for r in response.results:
486
- updates: dict[str, object] = {
487
- "access_count": r.fact.access_count + 1,
488
- }
489
- # Bayesian variance narrowing after 3+ accesses
490
- if (q_var_arr is not None
491
- and r.fact.fisher_variance
492
- and len(r.fact.fisher_variance) == len(q_var_arr)
493
- and r.fact.access_count >= 3):
494
- import numpy as _np
495
- f_var = _np.array(r.fact.fisher_variance, dtype=_np.float64)
496
- # Conjugate Gaussian update: 1/new_var = 1/f_var + 1/q_var
497
- new_var = 1.0 / (1.0 / _np.maximum(f_var, 0.05) + 1.0 / _np.maximum(q_var_arr, 0.05))
498
- new_var = _np.clip(new_var, 0.05, 2.0)
499
- updates["fisher_variance"] = new_var.tolist()
500
-
501
- self._db.update_fact(r.fact.fact_id, updates)
502
-
503
- # Post-operation hooks (audit, trust signal, learning)
504
- hook_ctx["result_count"] = len(response.results)
505
- hook_ctx["query_type"] = response.query_type
506
- self._hooks.run_post("recall", hook_ctx)
507
-
508
- return response
509
268
 
510
- def store_fact_direct(self, fact: AtomicFact) -> str:
511
- """Store a pre-built fact with full enrichment.
269
+ from superlocalmemory.core.recall_pipeline import run_recall
270
+ return run_recall(
271
+ query, pid, mode=mode, limit=limit, agent_id=agent_id,
272
+ config=self._config,
273
+ retrieval_engine=self._retrieval_engine,
274
+ trust_scorer=self._trust_scorer,
275
+ embedder=self._embedder,
276
+ db=self._db, llm=self._llm,
277
+ hooks=self._hooks,
278
+ access_log=self._access_log,
279
+ auto_linker=self._auto_linker,
280
+ )
512
281
 
513
- Ensures embedding, Fisher params, canonical entities, BM25 tokens,
514
- and graph edges are all populated — even for auxiliary data.
515
- Creates a parent memory record to satisfy FK constraint.
516
- """
517
- self._ensure_init()
282
+ # -- Session operations -------------------------------------------------
518
283
 
519
- # Create parent memory record (FK: atomic_facts.memory_id → memories.memory_id)
520
- if not fact.memory_id:
521
- from superlocalmemory.storage.models import _new_id
522
- record = MemoryRecord(
523
- profile_id=self._profile_id,
524
- content=fact.content[:500],
525
- session_id=fact.session_id,
526
- )
527
- self._db.store_memory(record)
528
- fact.memory_id = record.memory_id
529
-
530
- if not fact.embedding and self._embedder:
531
- fact.embedding = self._embedder.embed(fact.content)
532
- if fact.embedding:
533
- fact.fisher_mean, fact.fisher_variance = (
534
- self._embedder.compute_fisher_params(fact.embedding)
535
- )
536
- if self._entity_resolver and fact.entities:
537
- canonical = self._entity_resolver.resolve(
538
- fact.entities, self._profile_id,
539
- )
540
- fact.canonical_entities = list(canonical.values())
541
- self._db.store_fact(fact)
542
- if fact.embedding and self._ann_index:
543
- self._ann_index.add(fact.fact_id, fact.embedding)
544
- if self._graph_builder:
545
- self._graph_builder.build_edges(fact, self._profile_id)
546
- # BM25 indexing
547
- bm25 = getattr(self._retrieval_engine, '_bm25', None) if self._retrieval_engine else None
548
- if bm25:
549
- bm25.add(fact.fact_id, fact.content, self._profile_id)
550
- return fact.fact_id
551
-
552
- def create_speaker_entities(self, speaker_a: str, speaker_b: str) -> None:
284
+ def create_speaker_entities(
285
+ self, speaker_a: str, speaker_b: str,
286
+ ) -> None:
553
287
  """Pre-create canonical entities for conversation speakers."""
554
288
  self._ensure_init()
555
289
  if self._entity_resolver:
@@ -558,48 +292,15 @@ class MemoryEngine:
558
292
  )
559
293
 
560
294
  def close_session(self, session_id: str) -> int:
561
- """Create session-level temporal summary for session-level retrieval.
562
-
563
- Aggregates facts from a completed session into temporal_events
564
- with session scope. Enables temporal queries like "What happened
565
- in session 3?"
566
-
567
- Returns number of session summary events created.
568
- """
295
+ """Create session-level temporal summary."""
569
296
  self._ensure_init()
570
- from superlocalmemory.storage.models import TemporalEvent
571
-
572
- facts = self._db.get_all_facts(self._profile_id)
573
- session_facts = [f for f in facts if f.session_id == session_id]
574
- if not session_facts:
575
- return 0
576
-
577
- # Group by entity for session-level summaries
578
- entity_facts: dict[str, list[AtomicFact]] = {}
579
- for f in session_facts:
580
- for eid in f.canonical_entities:
581
- entity_facts.setdefault(eid, []).append(f)
582
-
583
- count = 0
584
- session_date = session_facts[0].observation_date or ""
585
- for eid, efacts in entity_facts.items():
586
- summary_parts = [f.content[:80] for f in efacts[:5]]
587
- summary = f"Session {session_id}: " + "; ".join(summary_parts)
588
- event = TemporalEvent(
589
- profile_id=self._profile_id,
590
- entity_id=eid,
591
- fact_id=efacts[0].fact_id,
592
- observation_date=session_date,
593
- description=summary[:500],
594
- )
595
- self._db.store_temporal_event(event)
596
- count += 1
597
297
 
598
- logger.info(
599
- "Session %s closed: %d summary events for %d facts",
600
- session_id, count, len(session_facts),
298
+ from superlocalmemory.core.store_pipeline import run_close_session
299
+ return run_close_session(
300
+ session_id, self._profile_id, db=self._db,
601
301
  )
602
- return count
302
+
303
+ # -- Lifecycle ----------------------------------------------------------
603
304
 
604
305
  def close(self) -> None:
605
306
  self._initialized = False
@@ -617,219 +318,8 @@ class MemoryEngine:
617
318
  self._ensure_init()
618
319
  return self._db.get_fact_count(self._profile_id)
619
320
 
620
- # -- Internal ----------------------------------------------------------
321
+ # -- Internal -----------------------------------------------------------
621
322
 
622
323
  def _ensure_init(self) -> None:
623
324
  if not self._initialized:
624
325
  self.initialize()
625
-
626
- def _apply_adaptive_ranking(self, response, query: str, pid: str):
627
- """Apply adaptive re-ranking if enough learning signals exist.
628
-
629
- Phase 1 (< 50 signals): returns response unchanged (backward compat).
630
- Phase 2 (50+): heuristic boosts from recency, access count, trust.
631
- Phase 3 (200+): LightGBM ML-based reranking.
632
- """
633
- from superlocalmemory.learning.feedback import FeedbackCollector
634
- from pathlib import Path
635
-
636
- learning_db = Path.home() / ".superlocalmemory" / "learning.db"
637
- if not learning_db.exists():
638
- return response
639
-
640
- collector = FeedbackCollector(learning_db)
641
- signal_count = collector.get_feedback_count(pid)
642
-
643
- if signal_count < 50:
644
- return response # Phase 1: no change
645
-
646
- from superlocalmemory.learning.ranker import AdaptiveRanker
647
- ranker = AdaptiveRanker(signal_count=signal_count)
648
-
649
- result_dicts = []
650
- for r in response.results:
651
- result_dicts.append({
652
- "score": r.score,
653
- "cross_encoder_score": r.score,
654
- "trust_score": r.trust_score,
655
- "channel_scores": r.channel_scores or {},
656
- "fact": {
657
- "age_days": 0,
658
- "access_count": r.fact.access_count,
659
- },
660
- "_original": r,
661
- })
662
-
663
- query_context = {"query_type": response.query_type}
664
- reranked = ranker.rerank(result_dicts, query_context)
665
-
666
- # Rebuild response with new ordering
667
- new_results = [d["_original"] for d in reranked]
668
-
669
- from superlocalmemory.storage.models import RecallResponse
670
- return RecallResponse(
671
- query=response.query,
672
- mode=response.mode,
673
- results=new_results,
674
- query_type=response.query_type,
675
- channel_weights=response.channel_weights,
676
- total_candidates=response.total_candidates,
677
- retrieval_time_ms=response.retrieval_time_ms,
678
- )
679
-
680
- def _init_encoding(self) -> None:
681
- from superlocalmemory.encoding.fact_extractor import FactExtractor
682
- from superlocalmemory.encoding.entity_resolver import EntityResolver
683
- from superlocalmemory.encoding.temporal_parser import TemporalParser
684
- from superlocalmemory.encoding.type_router import TypeRouter
685
- from superlocalmemory.encoding.graph_builder import GraphBuilder
686
- from superlocalmemory.encoding.consolidator import MemoryConsolidator
687
- from superlocalmemory.encoding.observation_builder import ObservationBuilder
688
- from superlocalmemory.encoding.scene_builder import SceneBuilder
689
- from superlocalmemory.encoding.entropy_gate import EntropyGate
690
- from superlocalmemory.retrieval.ann_index import ANNIndex
691
-
692
- self._ann_index = ANNIndex(dimension=self._config.embedding.dimension)
693
- self._fact_extractor = FactExtractor(
694
- config=self._config.encoding, llm=self._llm,
695
- embedder=self._embedder, mode=self._config.mode,
696
- )
697
- self._entity_resolver = EntityResolver(self._db, self._llm)
698
- self._temporal_parser = TemporalParser()
699
- self._type_router = TypeRouter(
700
- mode=self._config.mode, embedder=self._embedder, llm=self._llm,
701
- )
702
- self._graph_builder = GraphBuilder(self._db, self._ann_index)
703
- self._consolidator = MemoryConsolidator(
704
- self._db, self._embedder, self._llm, self._config.encoding,
705
- )
706
- self._observation_builder = ObservationBuilder(self._db)
707
- self._scene_builder = SceneBuilder(self._db, self._embedder)
708
- self._entropy_gate = EntropyGate(
709
- self._embedder, self._config.encoding.entropy_threshold,
710
- )
711
-
712
- # Wire Sheaf consistency checker
713
- if self._config.math.sheaf_at_encoding:
714
- from superlocalmemory.math.sheaf import SheafConsistencyChecker
715
- self._sheaf_checker = SheafConsistencyChecker(
716
- self._db, self._config.math.sheaf_contradiction_threshold,
717
- )
718
-
719
- def _init_retrieval(self) -> None:
720
- from superlocalmemory.retrieval.engine import RetrievalEngine
721
- from superlocalmemory.retrieval.semantic_channel import SemanticChannel
722
- from superlocalmemory.retrieval.bm25_channel import BM25Channel
723
- from superlocalmemory.retrieval.entity_channel import EntityGraphChannel
724
- from superlocalmemory.retrieval.temporal_channel import TemporalChannel
725
- from superlocalmemory.retrieval.reranker import CrossEncoderReranker
726
- from superlocalmemory.retrieval.profile_channel import ProfileChannel
727
- from superlocalmemory.retrieval.bridge_discovery import BridgeDiscovery
728
-
729
- channels: dict = {
730
- "semantic": SemanticChannel(
731
- self._db,
732
- fisher_temperature=self._config.math.fisher_temperature,
733
- embedder=self._embedder,
734
- fisher_mode=self._config.math.fisher_mode,
735
- ),
736
- "bm25": BM25Channel(self._db),
737
- "entity_graph": EntityGraphChannel(self._db, self._entity_resolver),
738
- "temporal": TemporalChannel(self._db),
739
- }
740
- reranker = None
741
- if self._config.retrieval.use_cross_encoder:
742
- reranker = CrossEncoderReranker(self._config.retrieval.cross_encoder_model)
743
-
744
- profile_ch = ProfileChannel(self._db)
745
- bridge = BridgeDiscovery(self._db)
746
-
747
- self._retrieval_engine = RetrievalEngine(
748
- db=self._db, config=self._config.retrieval, channels=channels,
749
- embedder=self._embedder, reranker=reranker,
750
- base_weights=self._config.channel_weights,
751
- profile_channel=profile_ch,
752
- bridge_discovery=bridge,
753
- trust_scorer=self._trust_scorer,
754
- )
755
-
756
- def _wire_hooks(self) -> None:
757
- """Wire trust, compliance, and event bus hooks into engine lifecycle."""
758
- # -- Pre-store hooks (synchronous, can reject) --
759
- if self._trust_scorer:
760
- from superlocalmemory.trust.gate import TrustGate
761
- gate = TrustGate(self._trust_scorer)
762
- self._hooks.register_pre("store", lambda ctx: gate.check_write(
763
- ctx.get("agent_id", "unknown"), ctx.get("profile_id", self._profile_id)))
764
- self._hooks.register_pre("delete", lambda ctx: gate.check_delete(
765
- ctx.get("agent_id", "unknown"), ctx.get("profile_id", self._profile_id)))
766
-
767
- # -- Post-store hooks (async, never block) --
768
- if self._trust_scorer:
769
- self._hooks.register_post("store", lambda ctx: self._trust_scorer.record_signal(
770
- ctx.get("agent_id", "unknown"), ctx.get("profile_id", self._profile_id), "store_success"))
771
- self._hooks.register_post("recall", lambda ctx: self._trust_scorer.record_signal(
772
- ctx.get("agent_id", "unknown"), ctx.get("profile_id", self._profile_id), "recall_hit"))
773
-
774
- # -- Burst detection via SignalRecorder --
775
- try:
776
- from superlocalmemory.trust.signals import SignalRecorder
777
- self._signal_recorder = SignalRecorder(self._db)
778
- self._hooks.register_post("store", lambda ctx: self._signal_recorder.record(
779
- ctx.get("agent_id", "unknown"), ctx.get("profile_id", self._profile_id), "store_success"))
780
- except Exception:
781
- self._signal_recorder = None
782
-
783
- # -- Tamper-proof audit chain (all operations logged with hash chain) --
784
- try:
785
- from superlocalmemory.compliance.audit import AuditChain
786
- audit_path = self._config.db_path.parent / "audit_chain.db"
787
- self._audit_chain = AuditChain(audit_path)
788
- for op in ("store", "recall", "delete"):
789
- self._hooks.register_post(op, lambda ctx, _op=op: self._audit_chain.log(
790
- operation=_op,
791
- agent_id=ctx.get("agent_id", "unknown"),
792
- profile_id=ctx.get("profile_id", self._profile_id),
793
- content_hash=ctx.get("content_hash", ""),
794
- ))
795
- except Exception:
796
- self._audit_chain = None
797
-
798
- def _enrich_fact(self, fact: AtomicFact, record: MemoryRecord) -> AtomicFact:
799
- """Enrich fact with embeddings, entities, temporal, emotional data."""
800
- from superlocalmemory.encoding.emotional import tag_emotion, emotional_importance_boost
801
- from superlocalmemory.encoding.signal_inference import infer_signal
802
-
803
- embedding = self._embedder.embed(fact.content) if self._embedder else None
804
- fisher_mean, fisher_variance = (None, None)
805
- if self._embedder and embedding:
806
- fisher_mean, fisher_variance = self._embedder.compute_fisher_params(embedding)
807
-
808
- canonical = {}
809
- if self._entity_resolver and fact.entities:
810
- canonical = self._entity_resolver.resolve(fact.entities, self._profile_id)
811
-
812
- temporal = {}
813
- if self._temporal_parser:
814
- temporal = self._temporal_parser.extract_dates_from_text(fact.content)
815
-
816
- emotion = tag_emotion(fact.content)
817
- signal = infer_signal(fact.content)
818
-
819
- return AtomicFact(
820
- fact_id=fact.fact_id, memory_id=record.memory_id,
821
- profile_id=self._profile_id, content=fact.content,
822
- fact_type=fact.fact_type, entities=fact.entities,
823
- canonical_entities=list(canonical.values()),
824
- observation_date=fact.observation_date or record.session_date,
825
- referenced_date=fact.referenced_date or temporal.get("referenced_date"),
826
- interval_start=fact.interval_start or temporal.get("interval_start"),
827
- interval_end=fact.interval_end or temporal.get("interval_end"),
828
- confidence=fact.confidence,
829
- importance=min(1.0, fact.importance + emotional_importance_boost(emotion)),
830
- evidence_count=fact.evidence_count,
831
- source_turn_ids=fact.source_turn_ids, session_id=record.session_id,
832
- embedding=embedding, fisher_mean=fisher_mean, fisher_variance=fisher_variance,
833
- emotional_valence=emotion.valence, emotional_arousal=emotion.arousal,
834
- signal_type=signal, created_at=fact.created_at,
835
- )