superlocalmemory 3.2.1 → 3.2.3

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 +113 -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
@@ -0,0 +1,484 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3.2 | https://qualixar.com
4
+
5
+ """Auto-Invoke Engine -- multi-signal memory retrieval.
6
+
7
+ Drop-in replacement for AutoRecall. Surfaces memories automatically
8
+ when context triggers them, using 4-signal (or 3-signal ACT-R) scoring.
9
+
10
+ NEVER imports core/engine.py (Rule 06).
11
+ Components received via __init__, not the engine itself.
12
+
13
+ References:
14
+ - SYNAPSE: FOK gating (tau_gate = 0.12)
15
+ - ACT-R: base-level activation (Anderson & Lebiere 1998)
16
+ - Zep/Hindsight: multi-signal ranking consensus
17
+ - A-MEM: contextual description enrichment
18
+
19
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import math
27
+
28
+ from superlocalmemory.core.config import AutoInvokeConfig
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class AutoInvoker:
34
+ """Multi-signal memory auto-invocation.
35
+
36
+ Replaces AutoRecall with richer scoring. Maintains exact same
37
+ public interface for backward compatibility (Rule 16 / AI-04).
38
+
39
+ Components received via __init__ (NOT the engine itself -- Rule 06):
40
+ - db: DatabaseManager (for access_log, fact_context queries)
41
+ - vector_store: VectorStore or None (for KNN similarity)
42
+ - trust_scorer: TrustScorer (for per-fact trust)
43
+ - embedder: EmbeddingService (for query encoding)
44
+ - config: AutoInvokeConfig
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ db, # DatabaseManager
50
+ vector_store=None, # VectorStore (Phase 1) or None
51
+ trust_scorer=None, # TrustScorer (existing)
52
+ embedder=None, # EmbeddingService for query encoding
53
+ config=None, # AutoInvokeConfig
54
+ ) -> None:
55
+ self._db = db
56
+ self._vector_store = vector_store
57
+ self._trust_scorer = trust_scorer
58
+ self._embedder = embedder
59
+ self._config = config or AutoInvokeConfig()
60
+
61
+ # ------------------------------------------------------------------
62
+ # Public API: AutoRecall-compatible interface (Rule 16 / AI-04)
63
+ # ------------------------------------------------------------------
64
+
65
+ def get_session_context(self, project_path: str = "", query: str = "") -> str:
66
+ """Get relevant context for a session or query.
67
+
68
+ EXACT same signature as AutoRecall.get_session_context().
69
+ Returns a formatted string of relevant memories suitable
70
+ for injection into an AI's system prompt.
71
+ """
72
+ if not self._config.enabled:
73
+ return ""
74
+
75
+ try:
76
+ results = self.invoke(
77
+ query=query or f"project context {project_path}",
78
+ profile_id=self._config.profile_id,
79
+ limit=self._config.max_memories_injected,
80
+ )
81
+
82
+ if not results:
83
+ return ""
84
+
85
+ return self.format_for_injection(results)
86
+ except Exception as exc:
87
+ logger.debug("Auto-invoke failed: %s", exc)
88
+ return ""
89
+
90
+ def get_query_context(self, query: str) -> list[dict]:
91
+ """Get relevant memories for a specific query.
92
+
93
+ EXACT same signature as AutoRecall.get_query_context().
94
+ Returns structured data for MCP tools.
95
+ """
96
+ if not self._config.enabled:
97
+ return []
98
+
99
+ try:
100
+ results = self.invoke(
101
+ query=query,
102
+ profile_id=self._config.profile_id,
103
+ limit=self._config.max_memories_injected,
104
+ )
105
+ return [
106
+ {
107
+ "fact_id": r["fact_id"],
108
+ "content": r["content"][:300],
109
+ "score": round(r["score"], 3),
110
+ "context": r.get("contextual_description", ""),
111
+ }
112
+ for r in results
113
+ ]
114
+ except Exception as exc:
115
+ logger.debug("Auto-invoke query failed: %s", exc)
116
+ return []
117
+
118
+ @property
119
+ def enabled(self) -> bool:
120
+ return self._config.enabled
121
+
122
+ def enable(self) -> None:
123
+ """Enable auto-invoke. Creates new frozen config."""
124
+ fields = {
125
+ k: v for k, v in self._config.__dict__.items() if k != "enabled"
126
+ }
127
+ self._config = AutoInvokeConfig(enabled=True, **fields)
128
+
129
+ def disable(self) -> None:
130
+ """Disable auto-invoke. Creates new frozen config."""
131
+ fields = {
132
+ k: v for k, v in self._config.__dict__.items() if k != "enabled"
133
+ }
134
+ self._config = AutoInvokeConfig(enabled=False, **fields)
135
+
136
+ # ------------------------------------------------------------------
137
+ # Core: invoke() -- multi-signal scoring
138
+ # ------------------------------------------------------------------
139
+
140
+ def invoke(
141
+ self,
142
+ query: str,
143
+ profile_id: str,
144
+ limit: int = 10,
145
+ ) -> list[dict]:
146
+ """Run multi-signal scoring to find relevant memories.
147
+
148
+ Algorithm:
149
+ 1. Get candidate facts via VectorStore KNN (or BM25 fallback).
150
+ 2. For each candidate, compute multi-signal score.
151
+ 3. Apply FOK gating (reject scores < threshold).
152
+ 4. Return top-K ranked results with contextual descriptions.
153
+
154
+ Returns list of dicts: {fact_id, content, score, signals, contextual_description}
155
+ """
156
+ # Step 1: Get candidates
157
+ candidates = self._get_candidates(
158
+ query, profile_id, top_k=limit * self._config.candidate_multiplier,
159
+ )
160
+
161
+ if not candidates:
162
+ return []
163
+
164
+ # Step 2: Score each candidate
165
+ scored = []
166
+ for fact_id, similarity in candidates:
167
+ signals = self._compute_signals(fact_id, profile_id, similarity)
168
+ score = self._combine_signals(signals)
169
+ scored.append((fact_id, score, signals))
170
+
171
+ # Step 3: Sort by combined score descending
172
+ scored.sort(key=lambda t: t[1], reverse=True)
173
+
174
+ # Step 4: FOK gating -- reject below threshold
175
+ gated = [
176
+ (fid, score, signals)
177
+ for fid, score, signals in scored
178
+ if score >= self._config.fok_threshold
179
+ ]
180
+
181
+ # Step 5: Enrich with fact content and contextual description
182
+ results = []
183
+ for fact_id, score, signals in gated[:limit]:
184
+ fact_data = self._enrich_result(fact_id, score, signals, profile_id)
185
+ if fact_data:
186
+ results.append(fact_data)
187
+
188
+ return results
189
+
190
+ # ------------------------------------------------------------------
191
+ # Signal computation
192
+ # ------------------------------------------------------------------
193
+
194
+ def _get_candidates(
195
+ self, query: str, profile_id: str, top_k: int = 30,
196
+ ) -> list[tuple[str, float]]:
197
+ """Get candidate facts via VectorStore KNN or text search fallback.
198
+
199
+ Returns list of (fact_id, similarity_score).
200
+ """
201
+ # Primary: VectorStore KNN (Phase 1)
202
+ if self._vector_store is not None and self._embedder is not None:
203
+ try:
204
+ query_embedding = self._embedder.embed(query)
205
+ if query_embedding:
206
+ return self._vector_store.search(
207
+ query_embedding, top_k=top_k, profile_id=profile_id,
208
+ )
209
+ except Exception as exc:
210
+ logger.debug("VectorStore search failed: %s", exc)
211
+
212
+ # Fallback: text search for candidates (Mode A degradation)
213
+ try:
214
+ rows = self._db.execute(
215
+ "SELECT fact_id FROM atomic_facts "
216
+ "WHERE profile_id = ? AND content LIKE ? "
217
+ "ORDER BY access_count DESC LIMIT ?",
218
+ (profile_id, f"%{query[:50]}%", top_k),
219
+ )
220
+ # Text fallback: similarity=0 (per Mode A weights)
221
+ return [(dict(r)["fact_id"], 0.0) for r in rows]
222
+ except Exception as exc:
223
+ logger.debug("Text search fallback failed for profile %s: %s", profile_id, exc)
224
+ return []
225
+
226
+ def _compute_signals(
227
+ self, fact_id: str, profile_id: str, similarity: float,
228
+ ) -> dict[str, float]:
229
+ """Compute all signals for a single fact.
230
+
231
+ Returns dict with keys: similarity, recency, frequency, trust,
232
+ and optionally base_level (ACT-R mode).
233
+ """
234
+ signals: dict[str, float] = {"similarity": similarity}
235
+
236
+ # Recency: from fact_access_log.MAX(accessed_at) [H1 fix / AI-17]
237
+ signals["recency"] = self._compute_recency(fact_id, profile_id)
238
+
239
+ # Frequency: from atomic_facts.access_count
240
+ signals["frequency"] = self._compute_frequency(fact_id, profile_id)
241
+
242
+ # Trust: from TrustScorer Bayesian Beta distribution
243
+ signals["trust"] = self._compute_trust(fact_id, profile_id)
244
+
245
+ # Optional: ACT-R base-level activation (combines recency + frequency)
246
+ if self._config.use_act_r:
247
+ signals["base_level"] = self._compute_act_r_base_level(
248
+ fact_id, profile_id,
249
+ )
250
+
251
+ return signals
252
+
253
+ def _compute_recency(self, fact_id: str, profile_id: str) -> float:
254
+ """Recency from fact_access_log.MAX(accessed_at) [AI-17].
255
+
256
+ Formula: exp(-0.01 * seconds_since_last_access)
257
+ Returns 0.1 if never accessed (cold start default).
258
+ Range: [0.01, 1.0]
259
+ """
260
+ try:
261
+ rows = self._db.execute(
262
+ "SELECT MAX(accessed_at) as last_access "
263
+ "FROM fact_access_log "
264
+ "WHERE fact_id = ? AND profile_id = ?",
265
+ (fact_id, profile_id),
266
+ )
267
+ if rows:
268
+ last_access = dict(rows[0]).get("last_access")
269
+ if last_access:
270
+ from datetime import UTC, datetime
271
+ last_dt = datetime.fromisoformat(last_access)
272
+ now = datetime.now(UTC)
273
+ seconds_since = max(
274
+ 0, (now - last_dt.replace(tzinfo=UTC)).total_seconds(),
275
+ )
276
+ return max(0.01, math.exp(-0.01 * seconds_since))
277
+ except Exception as exc:
278
+ logger.debug("Recency computation failed for fact %s: %s", fact_id, exc)
279
+
280
+ return 0.1 # Cold start default
281
+
282
+ def _compute_frequency(self, fact_id: str, profile_id: str) -> float:
283
+ """Frequency from access_count, normalized via log1p.
284
+
285
+ Formula: log1p(access_count) / log1p(max_access_count_in_profile)
286
+ Range: [0.0, 1.0]
287
+ """
288
+ try:
289
+ rows = self._db.execute(
290
+ "SELECT access_count FROM atomic_facts WHERE fact_id = ?",
291
+ (fact_id,),
292
+ )
293
+ count = dict(rows[0])["access_count"] if rows else 0
294
+
295
+ max_rows = self._db.execute(
296
+ "SELECT MAX(access_count) as max_count "
297
+ "FROM atomic_facts WHERE profile_id = ?",
298
+ (profile_id,),
299
+ )
300
+ max_count = dict(max_rows[0]).get("max_count", 1) if max_rows else 1
301
+ max_count = max(max_count, 1)
302
+
303
+ return math.log1p(count) / math.log1p(max_count)
304
+ except Exception as exc:
305
+ logger.debug("Frequency computation failed for fact %s: %s", fact_id, exc)
306
+ return 0.0
307
+
308
+ def _compute_trust(self, fact_id: str, profile_id: str) -> float:
309
+ """Trust score from TrustScorer Bayesian Beta distribution.
310
+
311
+ Default: 0.5 (uniform prior).
312
+ Range: [0.0, 1.0]
313
+ """
314
+ if self._trust_scorer is None:
315
+ return 0.5
316
+ try:
317
+ return self._trust_scorer.get_fact_trust(fact_id, profile_id)
318
+ except Exception:
319
+ return 0.5
320
+
321
+ def _compute_act_r_base_level(
322
+ self, fact_id: str, profile_id: str,
323
+ ) -> float:
324
+ """ACT-R base-level activation [L21 fix].
325
+
326
+ Formula: B_i = ln(SUM_k (t_k)^(-d))
327
+ Where t_k = seconds since k-th access, d = decay parameter.
328
+
329
+ Combines recency AND frequency into a single signal.
330
+ Returns 0.0 if no access history.
331
+
332
+ Reference: Anderson & Lebiere 1998, "The Atomic Components of Thought"
333
+ """
334
+ try:
335
+ rows = self._db.execute(
336
+ "SELECT accessed_at FROM fact_access_log "
337
+ "WHERE fact_id = ? AND profile_id = ? "
338
+ "ORDER BY accessed_at DESC LIMIT 100",
339
+ (fact_id, profile_id),
340
+ )
341
+ if not rows:
342
+ return 0.0
343
+
344
+ from datetime import UTC, datetime
345
+ now = datetime.now(UTC)
346
+ decay = self._config.act_r_decay
347
+
348
+ summation = 0.0
349
+ for row in rows:
350
+ accessed = dict(row)["accessed_at"]
351
+ try:
352
+ t_dt = datetime.fromisoformat(accessed)
353
+ t_seconds = max(
354
+ 1.0, (now - t_dt.replace(tzinfo=UTC)).total_seconds(),
355
+ )
356
+ summation += t_seconds ** (-decay)
357
+ except (ValueError, TypeError):
358
+ continue
359
+
360
+ if summation <= 0:
361
+ return 0.0
362
+
363
+ # ln(sum) can be negative for small sums; normalize to [0, 1]
364
+ raw = math.log(summation)
365
+ # Sigmoid normalization to [0, 1] range
366
+ return 1.0 / (1.0 + math.exp(-raw))
367
+
368
+ except Exception as exc:
369
+ logger.debug(
370
+ "ACT-R base-level computation failed for fact %s: %s",
371
+ fact_id, exc,
372
+ )
373
+ return 0.0
374
+
375
+ # ------------------------------------------------------------------
376
+ # Signal combination
377
+ # ------------------------------------------------------------------
378
+
379
+ def _combine_signals(self, signals: dict[str, float]) -> float:
380
+ """Combine signals into a single score using configured weights.
381
+
382
+ 4-signal mode (default):
383
+ score = 0.40*sim + 0.25*rec + 0.20*freq + 0.15*trust
384
+
385
+ 3-signal ACT-R mode (optional):
386
+ score = 0.40*sim + 0.35*base_level + 0.25*trust
387
+
388
+ Mode A degradation (no embeddings):
389
+ score = 0.00*sim + 0.40*rec + 0.35*freq + 0.25*trust
390
+ """
391
+ # Mode A degradation: no embeddings available
392
+ if (
393
+ signals.get("similarity", 0.0) == 0.0
394
+ and self._vector_store is None
395
+ ):
396
+ weights = self._config.mode_a_weights
397
+ elif self._config.use_act_r and "base_level" in signals:
398
+ # 3-signal ACT-R mode
399
+ weights = self._config.act_r_weights
400
+ return (
401
+ weights.get("similarity", 0.40) * signals.get("similarity", 0.0)
402
+ + weights.get("base_level", 0.35) * signals.get("base_level", 0.0)
403
+ + weights.get("trust", 0.25) * signals.get("trust", 0.5)
404
+ )
405
+ else:
406
+ weights = self._config.weights
407
+
408
+ # 4-signal default mode (or Mode A degradation)
409
+ return (
410
+ weights.get("similarity", 0.40) * signals.get("similarity", 0.0)
411
+ + weights.get("recency", 0.25) * signals.get("recency", 0.0)
412
+ + weights.get("frequency", 0.20) * signals.get("frequency", 0.0)
413
+ + weights.get("trust", 0.15) * signals.get("trust", 0.5)
414
+ )
415
+
416
+ # ------------------------------------------------------------------
417
+ # Result enrichment
418
+ # ------------------------------------------------------------------
419
+
420
+ def _enrich_result(
421
+ self, fact_id: str, score: float, signals: dict, profile_id: str,
422
+ ) -> dict | None:
423
+ """Load full fact + contextual description for a scored result."""
424
+ try:
425
+ fact_rows = self._db.execute(
426
+ "SELECT fact_id, content, fact_type, lifecycle "
427
+ "FROM atomic_facts WHERE fact_id = ?",
428
+ (fact_id,),
429
+ )
430
+ if not fact_rows:
431
+ return None
432
+ fact_data = dict(fact_rows[0])
433
+
434
+ # Skip archived facts unless config allows
435
+ if (
436
+ fact_data.get("lifecycle") in ("archived",)
437
+ and not self._config.include_archived
438
+ ):
439
+ return None
440
+
441
+ # Get contextual description
442
+ ctx = self._db.get_fact_context(fact_id)
443
+ ctx_desc = ctx["contextual_description"] if ctx else ""
444
+
445
+ return {
446
+ "fact_id": fact_id,
447
+ "content": fact_data["content"],
448
+ "fact_type": fact_data.get("fact_type", "semantic"),
449
+ "score": score,
450
+ "signals": signals,
451
+ "contextual_description": ctx_desc,
452
+ }
453
+ except Exception as exc:
454
+ logger.debug("Result enrichment failed for %s: %s", fact_id, exc)
455
+ return None
456
+
457
+ # ------------------------------------------------------------------
458
+ # Output formatting
459
+ # ------------------------------------------------------------------
460
+
461
+ def format_for_injection(self, results: list[dict]) -> str:
462
+ """Format results for system prompt injection.
463
+
464
+ Output: Markdown list with content previews and context.
465
+ """
466
+ if not results:
467
+ return ""
468
+
469
+ lines = ["# Relevant Memory Context", ""]
470
+ for r in results:
471
+ content_preview = r["content"][:200]
472
+ ctx = r.get("contextual_description", "")
473
+
474
+ line = f"- [{r['fact_type']}] {content_preview}"
475
+ if ctx:
476
+ line += f"\n > Context: {ctx}"
477
+ lines.append(line)
478
+
479
+ lines.append("")
480
+ lines.append(
481
+ f"_Auto-invoked {len(results)} memories "
482
+ f"(FOK >= {self._config.fok_threshold})_"
483
+ )
484
+ return "\n".join(lines)
@@ -0,0 +1,154 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """SuperLocalMemory V3.2 -- Channel Registry.
6
+
7
+ Self-registration system for retrieval channels. Prevents merge
8
+ conflicts when Phases 2/3/4 add new channels in parallel.
9
+
10
+ Design: Registry Pattern + Protocol (structural subtyping).
11
+ Each channel registers itself with a name and implements the
12
+ RetrievalChannel protocol. The registry dispatches search()
13
+ calls to all registered channels and collects results.
14
+
15
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Any, Callable, Protocol, runtime_checkable
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @runtime_checkable
27
+ class RetrievalChannel(Protocol):
28
+ """Protocol for retrieval channels.
29
+
30
+ Any object with a search() method matching this signature
31
+ is a valid channel. No inheritance required.
32
+ """
33
+
34
+ def search(
35
+ self,
36
+ query: Any,
37
+ profile_id: str,
38
+ top_k: int = 50,
39
+ ) -> list[tuple[str, float]]:
40
+ """Search for relevant facts. Returns [(fact_id, score)]."""
41
+ ...
42
+
43
+
44
+ # Type alias for post-retrieval filter functions.
45
+ # Filters operate on the FULL channel results dict, NOT per-channel.
46
+ # Signature: (all_channel_results, profile_id, context) -> filtered_results
47
+ FilterFn = Callable[
48
+ [dict[str, list[tuple[str, float]]], str, Any],
49
+ dict[str, list[tuple[str, float]]]
50
+ ]
51
+
52
+
53
+ class ChannelRegistry:
54
+ """Registry for retrieval channels with self-registration.
55
+
56
+ Usage:
57
+ registry = ChannelRegistry()
58
+ registry.register_channel("semantic", semantic_ch, needs_embedding=True)
59
+ registry.register_channel("bm25", bm25_ch)
60
+ registry.register_filter(temporal_filter)
61
+ results = registry.run_all(query, "default", embedder=emb)
62
+
63
+ BACKWARD COMPATIBILITY:
64
+ - register_channel() defaults needs_embedding=False (existing channels unaffected)
65
+ - run_all() is a new method (no existing callers to break)
66
+ - Filters use dict-based signature for Phase 4 temporal filter compatibility
67
+ """
68
+
69
+ def __init__(self) -> None:
70
+ self._channels: dict[str, RetrievalChannel] = {}
71
+ self._filters: list[FilterFn] = []
72
+ self._embedding_channels: set[str] = set()
73
+
74
+ def register_channel(
75
+ self, name: str, channel: RetrievalChannel, needs_embedding: bool = False,
76
+ ) -> None:
77
+ """Register a retrieval channel by name.
78
+
79
+ Args:
80
+ name: Channel identifier (e.g., "semantic", "spreading_activation").
81
+ channel: Object implementing RetrievalChannel protocol.
82
+ needs_embedding: If True, raw query string is embedded into a vector
83
+ before passing to channel.search(). Required for channels that
84
+ expect vector input (semantic, spreading_activation).
85
+ """
86
+ self._channels[name] = channel
87
+ if needs_embedding:
88
+ self._embedding_channels.add(name)
89
+ logger.debug("Registered channel: %s (needs_embedding=%s)", name, needs_embedding)
90
+
91
+ def register_filter(self, fn: FilterFn) -> None:
92
+ """Register a post-retrieval filter function.
93
+
94
+ Filters run after all channels, before fusion. Used for
95
+ temporal validity filtering (Phase 4) and other concerns.
96
+
97
+ Filter signature: (channel_results_dict, profile_id, context) -> filtered_dict
98
+ """
99
+ self._filters.append(fn)
100
+ logger.debug("Registered filter: %s", getattr(fn, '__name__', str(fn)))
101
+
102
+ def run_all(
103
+ self,
104
+ query: str,
105
+ profile_id: str,
106
+ *,
107
+ embedder: Any | None = None,
108
+ disabled: set[str] | None = None,
109
+ top_k: int = 50,
110
+ ) -> dict[str, list[tuple[str, float]]]:
111
+ """Run all registered channels and return results.
112
+
113
+ Channels in `disabled` are skipped. Channels in _embedding_channels
114
+ receive embedder.embed(query) instead of raw query text.
115
+ Errors in channels are logged, not raised (Rule 19).
116
+
117
+ Returns dict of channel_name to [(fact_id, score)].
118
+ """
119
+ disabled = disabled or set()
120
+ out: dict[str, list[tuple[str, float]]] = {}
121
+
122
+ for name, channel in self._channels.items():
123
+ if name in disabled:
124
+ continue
125
+ try:
126
+ if name in self._embedding_channels and embedder is not None:
127
+ q_emb = embedder.embed(query)
128
+ results = channel.search(q_emb, profile_id, top_k)
129
+ else:
130
+ results = channel.search(query, profile_id, top_k)
131
+ if results:
132
+ out[name] = results
133
+ except Exception as exc:
134
+ logger.warning("Channel %s failed: %s", name, exc)
135
+
136
+ # Filters operate on the FULL results dict (not per-channel).
137
+ for fn in self._filters:
138
+ try:
139
+ out = fn(out, profile_id, None)
140
+ except Exception as exc:
141
+ logger.debug("Filter %s failed: %s",
142
+ getattr(fn, '__name__', str(fn)), exc)
143
+
144
+ return out
145
+
146
+ @property
147
+ def channel_names(self) -> list[str]:
148
+ """List of registered channel names."""
149
+ return list(self._channels.keys())
150
+
151
+ @property
152
+ def channel_count(self) -> int:
153
+ """Number of registered channels."""
154
+ return len(self._channels)
@@ -79,6 +79,18 @@ class RetrievalEngine:
79
79
  self._bridge = bridge_discovery
80
80
  self._trust_scorer = trust_scorer
81
81
 
82
+ # V3.2: ChannelRegistry for self-registration (Phase 0.5)
83
+ from superlocalmemory.retrieval.channel_registry import ChannelRegistry
84
+ self._registry = ChannelRegistry()
85
+ if self._semantic is not None:
86
+ self._registry.register_channel("semantic", self._semantic, needs_embedding=True)
87
+ if self._bm25 is not None:
88
+ self._registry.register_channel("bm25", self._bm25)
89
+ if self._entity is not None:
90
+ self._registry.register_channel("entity_graph", self._entity)
91
+ if self._temporal is not None:
92
+ self._registry.register_channel("temporal", self._temporal)
93
+
82
94
  def recall(
83
95
  self, query: str, profile_id: str,
84
96
  mode: Mode = Mode.A, limit: int = 20,