memkernel 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,235 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Hybrid search engine: dense (ChromaDB) + sparse (BM25) fused via RRF."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from memkernel.core.models import ArtifactRef, Memory, SearchResult
10
+ from memkernel.memory.lifecycle import recency_score
11
+
12
+ if TYPE_CHECKING:
13
+ from memkernel.config import MemKernelSettings
14
+ from memkernel.core.models import SearchQuery
15
+ from memkernel.core.ports import EmbeddingPort, SparseIndexPort, VectorStorePort
16
+
17
+
18
+ # Map ChromaDB metadata field names to Memory attribute names.
19
+ _FIELD_MAP: dict[str, str] = {
20
+ "type": "type",
21
+ "tier": "tier",
22
+ "session_id": "session_id",
23
+ "checkpoint": "checkpoint",
24
+ "project_id": "project_id",
25
+ }
26
+
27
+
28
+ class HybridSearchService:
29
+ """Fuses dense and sparse search results via Reciprocal Rank Fusion (RRF).
30
+
31
+ Dense search: ChromaDB cosine-similarity query via VectorStorePort.
32
+ Sparse search: BM25Plus over the full corpus via SparseIndexPort, then
33
+ post-filtered against the ChromaDB metadata via get_by_id.
34
+ Fusion: RRF with configurable k (default 60).
35
+ Scoring: the fused relevance rank is re-weighted by recency (per-tier
36
+ exponential decay) and importance, following the
37
+ relevance + recency + importance retrieval function of
38
+ Generative Agents (Park et al., 2023). Weights come from settings.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ vector_store: VectorStorePort,
44
+ embedding: EmbeddingPort,
45
+ sparse_index: SparseIndexPort,
46
+ settings: MemKernelSettings,
47
+ ) -> None:
48
+ self._vector_store = vector_store
49
+ self._embedding = embedding
50
+ self._sparse_index = sparse_index
51
+ self._settings = settings
52
+
53
+ def search(self, query: SearchQuery) -> list[ArtifactRef]:
54
+ """Run hybrid search and return up to query.top_k ArtifactRefs."""
55
+ where = _build_where_filter(query)
56
+ over_fetch = max(1, query.top_k * 3)
57
+
58
+ # Dense pass
59
+ query_embedding = self._embedding.embed([query.query_text])[0]
60
+ dense_results = self._vector_store.search_dense(query_embedding, over_fetch, where)
61
+
62
+ # Sparse pass — score all, then post-filter to honour metadata filters
63
+ sparse_all = self._sparse_index.search_all(query.query_text)
64
+ sparse_results = filter_sparse_results(sparse_all, where, self._vector_store)
65
+ sparse_results = sparse_results[:over_fetch]
66
+
67
+ # RRF fusion
68
+ fused = rrf_fuse(self._settings.rrf_k, dense_results, sparse_results)
69
+
70
+ # Hydrate every fused candidate (not just top_k): re-weighting can
71
+ # promote a lower-ranked but recent/important memory into the top_k.
72
+ refs = build_artifact_refs(fused, len(fused), self._vector_store, self._settings.preview_length)
73
+ refs = rescore(refs, self._settings, self._vector_store)
74
+ return refs[: query.top_k]
75
+
76
+
77
+ # ------------------------------------------------------------------
78
+ # Module-level helpers (pure functions, easier to unit-test independently)
79
+ # ------------------------------------------------------------------
80
+
81
+
82
+ def _build_where_filter(query: SearchQuery) -> dict[str, Any] | None:
83
+ """Construct a ChromaDB ``where`` filter dict from a SearchQuery.
84
+
85
+ Returns None when no filters are active (avoids passing an empty dict
86
+ to ChromaDB which would cause a validation error).
87
+ """
88
+ conditions: list[dict[str, Any]] = []
89
+ if query.type_filter is not None:
90
+ conditions.append({"type": query.type_filter.value})
91
+ if query.tier_filter is not None:
92
+ conditions.append({"tier": query.tier_filter.value})
93
+ if query.session_id_filter is not None:
94
+ conditions.append({"session_id": query.session_id_filter})
95
+ if query.checkpoint_only:
96
+ conditions.append({"checkpoint": True})
97
+
98
+ if not conditions:
99
+ return None
100
+ if len(conditions) == 1:
101
+ return conditions[0]
102
+ return {"$and": conditions}
103
+
104
+
105
+ def rrf_fuse(
106
+ k: int,
107
+ *result_lists: list[SearchResult],
108
+ ) -> list[tuple[str, float]]:
109
+ """Reciprocal Rank Fusion across arbitrarily many ranked lists.
110
+
111
+ score(d) = sum of 1 / (k + rank_i(d)) for each list that contains d.
112
+
113
+ Returns (ref_id, fused_score) pairs sorted descending by score.
114
+ """
115
+ scores: dict[str, float] = {}
116
+ for results in result_lists:
117
+ for result in results:
118
+ rrf_score = 1.0 / (k + result.rank)
119
+ scores[result.ref_id] = scores.get(result.ref_id, 0.0) + rrf_score
120
+ return sorted(scores.items(), key=lambda x: x[1], reverse=True)
121
+
122
+
123
+ def rescore(
124
+ refs: list[ArtifactRef],
125
+ settings: MemKernelSettings,
126
+ vector_store: VectorStorePort,
127
+ ) -> list[ArtifactRef]:
128
+ """Re-rank fused results by relevance + recency + importance.
129
+
130
+ The fused RRF score is min-max normalised to [0, 1] so the three axes
131
+ are commensurable; then::
132
+
133
+ final = w_rel * relevance + w_rec * recency + w_imp * importance
134
+
135
+ Recency uses the per-tier half-life from settings (long-term memories do
136
+ not decay). The ``last_accessed`` timestamp is consulted so that a memory
137
+ the agent keeps recalling stays fresh (rehearsal).
138
+ """
139
+ if not refs:
140
+ return refs
141
+ scores = [r.score for r in refs]
142
+ lo, hi = min(scores), max(scores)
143
+ span = hi - lo
144
+ rescored: list[ArtifactRef] = []
145
+ for ref in refs:
146
+ relevance = (ref.score - lo) / span if span > 0 else 1.0
147
+ memory = vector_store.get_by_id(ref.ref_id)
148
+ last_accessed = memory.last_accessed if memory is not None else None
149
+ recency = recency_score(ref.type, ref.timestamp, settings, last_accessed=last_accessed)
150
+ final = (
151
+ settings.recall_weight_relevance * relevance
152
+ + settings.recall_weight_recency * recency
153
+ + settings.recall_weight_importance * ref.importance
154
+ )
155
+ rescored.append(ref.model_copy(update={"score": final}))
156
+ rescored.sort(key=lambda r: r.score, reverse=True)
157
+ return rescored
158
+
159
+
160
+ def _metadata_matches(memory: Memory, where: dict[str, Any]) -> bool:
161
+ """Check whether a Memory satisfies a ChromaDB-style where filter.
162
+
163
+ Supports the subset of ChromaDB filter syntax used by this module:
164
+ - Simple equality: {"field": value}
165
+ - $and: [cond, ...]
166
+ """
167
+ if "$and" in where:
168
+ return all(_metadata_matches(memory, cond) for cond in where["$and"])
169
+
170
+ for key, value in where.items():
171
+ actual = getattr(memory, _FIELD_MAP.get(key, key), None)
172
+ if actual is None:
173
+ return False
174
+ # MemoryType is a StrEnum — compare via string value
175
+ if hasattr(actual, "value"):
176
+ actual = actual.value
177
+ if actual != value:
178
+ return False
179
+ return True
180
+
181
+
182
+ def filter_sparse_results(
183
+ results: list[SearchResult],
184
+ where: dict[str, Any] | None,
185
+ vector_store: VectorStorePort,
186
+ ) -> list[SearchResult]:
187
+ """Post-filter BM25 results against ChromaDB metadata.
188
+
189
+ For each BM25 result, fetch the Memory from the vector store.
190
+ Stale entries (get_by_id returns None) are skipped silently.
191
+ Surviving results are re-ranked 1-based.
192
+ """
193
+ kept: list[SearchResult] = []
194
+ for result in results:
195
+ memory = vector_store.get_by_id(result.ref_id)
196
+ if memory is None:
197
+ continue
198
+ if where is not None and not _metadata_matches(memory, where):
199
+ continue
200
+ kept.append(result)
201
+
202
+ return [SearchResult(ref_id=r.ref_id, score=r.score, rank=new_rank) for new_rank, r in enumerate(kept, start=1)]
203
+
204
+
205
+ def build_artifact_refs(
206
+ fused: list[tuple[str, float]],
207
+ top_k: int,
208
+ vector_store: VectorStorePort,
209
+ preview_length: int,
210
+ ) -> list[ArtifactRef]:
211
+ """Fetch full Memory for each fused result and build ArtifactRef list.
212
+
213
+ Stale entries (get_by_id returns None) are skipped silently.
214
+ Stops after collecting top_k valid results.
215
+ """
216
+ refs: list[ArtifactRef] = []
217
+ for ref_id, score in fused:
218
+ if len(refs) >= top_k:
219
+ break
220
+ memory = vector_store.get_by_id(ref_id)
221
+ if memory is None:
222
+ continue
223
+ refs.append(
224
+ ArtifactRef(
225
+ ref_id=memory.ref_id,
226
+ preview=memory.content[:preview_length],
227
+ score=score,
228
+ token_count=memory.token_count,
229
+ type=memory.type,
230
+ timestamp=memory.timestamp,
231
+ tier=memory.tier,
232
+ importance=memory.importance,
233
+ )
234
+ )
235
+ return refs
@@ -0,0 +1,137 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """BM25 sparse search index implementing SparseIndexPort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import re
9
+
10
+ from rank_bm25 import BM25Plus # type: ignore[import-untyped]
11
+
12
+ from memkernel.core.models import SearchResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class BM25Index:
18
+ """In-memory BM25 index backed by rank_bm25.BM25Plus.
19
+
20
+ BM25Plus uses a lower-bounded IDF that always yields non-negative scores,
21
+ avoiding the negative-score pathology of BM25Okapi on small corpora.
22
+ BM25Plus is immutable after construction, so the index is rebuilt lazily
23
+ whenever the document corpus changes (dirty flag pattern).
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ self._documents: dict[str, list[str]] = {} # ref_id -> token list
28
+ self._ref_ids: list[str] = []
29
+ self._index: BM25Plus | None = None
30
+ self._dirty: bool = True
31
+ self._needs_rebuild_flag: bool = False
32
+
33
+ # ------------------------------------------------------------------
34
+ # Mutation
35
+ # ------------------------------------------------------------------
36
+
37
+ def add(self, ref_id: str, content: str) -> None:
38
+ """Index a document by ref_id. Replaces existing entry if present."""
39
+ self._documents[ref_id] = self._tokenize(content)
40
+ self._dirty = True
41
+
42
+ def remove(self, ref_id: str) -> None:
43
+ """Remove a document from the index. No-op if ref_id is absent."""
44
+ self._documents.pop(ref_id, None)
45
+ self._dirty = True
46
+
47
+ def rebuild(self, documents: list[tuple[str, str]]) -> None:
48
+ """Replace the entire corpus and eagerly rebuild the BM25 index."""
49
+ self._documents.clear()
50
+ for ref_id, content in documents:
51
+ self._documents[ref_id] = self._tokenize(content)
52
+ self._needs_rebuild_flag = False
53
+ self._dirty = True
54
+ self._ensure_index()
55
+
56
+ # ------------------------------------------------------------------
57
+ # Search
58
+ # ------------------------------------------------------------------
59
+
60
+ def search(self, query: str, top_k: int) -> list[SearchResult]:
61
+ """Return up to top_k results ranked by BM25 score."""
62
+ return self.search_all(query)[:top_k]
63
+
64
+ def search_all(self, query: str) -> list[SearchResult]:
65
+ """Score the entire corpus and return all documents with score > 0.
66
+
67
+ Results are sorted by descending score and assigned 1-based ranks.
68
+ """
69
+ self._ensure_index()
70
+ if self._index is None or not self._ref_ids:
71
+ return []
72
+
73
+ query_tokens = self._tokenize(query)
74
+ raw_scores = self._index.get_scores(query_tokens)
75
+
76
+ # BM25Plus uses a lower-bounded IDF that gives every document a
77
+ # positive baseline score, even those that don't contain the query
78
+ # term. Guard with a token-overlap check so only documents that
79
+ # actually share at least one token with the query are returned.
80
+ query_token_set = set(query_tokens)
81
+ scored: list[tuple[int, float]] = sorted(
82
+ (
83
+ (i, float(s))
84
+ for i, (s, rid) in enumerate(zip(raw_scores, self._ref_ids, strict=True))
85
+ if query_token_set & set(self._documents[rid])
86
+ ),
87
+ key=lambda t: t[1],
88
+ reverse=True,
89
+ )
90
+
91
+ return [
92
+ SearchResult(ref_id=self._ref_ids[i], score=score, rank=rank + 1) for rank, (i, score) in enumerate(scored)
93
+ ]
94
+
95
+ # ------------------------------------------------------------------
96
+ # Rebuild marker
97
+ # ------------------------------------------------------------------
98
+
99
+ @property
100
+ def needs_rebuild(self) -> bool:
101
+ """True if an external caller has signalled that the index needs a full rebuild."""
102
+ return self._needs_rebuild_flag
103
+
104
+ def mark_needs_rebuild(self) -> None:
105
+ """Signal that the caller should supply fresh documents via rebuild()."""
106
+ self._needs_rebuild_flag = True
107
+
108
+ # ------------------------------------------------------------------
109
+ # Dunder
110
+ # ------------------------------------------------------------------
111
+
112
+ def __len__(self) -> int:
113
+ return len(self._documents)
114
+
115
+ # ------------------------------------------------------------------
116
+ # Internal helpers
117
+ # ------------------------------------------------------------------
118
+
119
+ def _ensure_index(self) -> None:
120
+ """Rebuild BM25Plus from the current corpus if dirty."""
121
+ if not self._dirty:
122
+ return
123
+ self._ref_ids = list(self._documents.keys())
124
+ corpus = [self._documents[rid] for rid in self._ref_ids]
125
+ self._index = BM25Plus(corpus) if corpus else None
126
+ self._dirty = False
127
+
128
+ _TOKEN_RE = re.compile(r"[a-zA-Z0-9_]+")
129
+
130
+ @staticmethod
131
+ def _tokenize(text: str) -> list[str]:
132
+ """Split on non-alphanumeric boundaries so code identifiers are isolated.
133
+
134
+ 'foo()' -> ['foo'], 'Class.method' -> ['class', 'method'],
135
+ 'path/file.py' -> ['path', 'file', 'py']
136
+ """
137
+ return [t.lower() for t in BM25Index._TOKEN_RE.findall(text)]
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Storage adapters for MemKernel."""
@@ -0,0 +1,256 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ChromaDB adapter implementing VectorStorePort."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import UTC, datetime
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import chromadb
11
+
12
+ from memkernel.core.models import DEFAULT_IMPORTANCE, Memory, MemoryTier, MemoryType, SearchResult, tier_of
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping, Sequence
16
+
17
+ from chromadb.api import ClientAPI
18
+ from chromadb.api.models.Collection import Collection
19
+
20
+ from memkernel.config import MemKernelSettings
21
+
22
+
23
+ _REQUIRED_METADATA_KEYS: frozenset[str] = frozenset(
24
+ {"project_id", "session_id", "timestamp", "type", "source", "hash", "token_count"}
25
+ )
26
+
27
+
28
+ def _to_chroma_metadata(metadata: dict[str, Any]) -> dict[str, str | int | float | bool]:
29
+ """Normalise a metadata dict to ChromaDB-acceptable scalar types."""
30
+ result: dict[str, str | int | float | bool] = {}
31
+ for key, value in metadata.items():
32
+ if isinstance(value, bool | int | float):
33
+ result[key] = value
34
+ elif isinstance(value, datetime):
35
+ result[key] = value.isoformat()
36
+ else:
37
+ result[key] = str(value)
38
+ return result
39
+
40
+
41
+ def _memory_from_chroma(
42
+ ids: list[str],
43
+ documents: list[str],
44
+ metadatas: list[Mapping[str, Any]],
45
+ index: int,
46
+ ) -> Memory:
47
+ """Reconstruct a Memory from ChromaDB get/query result lists."""
48
+ meta = metadatas[index]
49
+ raw_ts = meta.get("timestamp", "")
50
+ timestamp = datetime.fromisoformat(str(raw_ts)) if raw_ts else datetime.now(tz=UTC)
51
+ chunk_refs = _split_csv(meta.get("chunk_refs"))
52
+ memory_type = MemoryType(str(meta.get("type", "episode")))
53
+ # Records written by memkernel 0.1.x carry no tier/importance metadata;
54
+ # derive defaults from the type so old stores keep working unchanged.
55
+ raw_tier = meta.get("tier")
56
+ tier = MemoryTier(str(raw_tier)) if raw_tier else tier_of(memory_type)
57
+ raw_importance = meta.get("importance")
58
+ importance = float(raw_importance) if raw_importance is not None else DEFAULT_IMPORTANCE[memory_type]
59
+ raw_last = meta.get("last_accessed")
60
+ last_accessed = datetime.fromisoformat(str(raw_last)) if raw_last else None
61
+ return Memory(
62
+ ref_id=ids[index],
63
+ content=documents[index],
64
+ type=memory_type,
65
+ project_id=str(meta.get("project_id", "")),
66
+ session_id=str(meta.get("session_id", "")),
67
+ timestamp=timestamp,
68
+ source=str(meta.get("source", "")),
69
+ content_hash=str(meta.get("hash", "")),
70
+ token_count=int(meta.get("token_count", 0)),
71
+ checkpoint=bool(meta.get("checkpoint", False)),
72
+ chunk_refs=chunk_refs,
73
+ tier=tier,
74
+ importance=importance,
75
+ access_count=int(meta.get("access_count", 0) or 0),
76
+ last_accessed=last_accessed,
77
+ tags=_split_csv(meta.get("tags")),
78
+ supersedes=str(meta["supersedes"]) if meta.get("supersedes") else None,
79
+ promoted_from=str(meta["promoted_from"]) if meta.get("promoted_from") else None,
80
+ )
81
+
82
+
83
+ def _split_csv(raw: Any) -> list[str]:
84
+ """Split a comma-separated metadata value into a list (ChromaDB has no list type)."""
85
+ if not raw:
86
+ return []
87
+ return [part for part in str(raw).split(",") if part]
88
+
89
+
90
+ class ChromaVectorStore:
91
+ """ChromaDB-backed implementation of VectorStorePort.
92
+
93
+ Production usage:
94
+ store = ChromaVectorStore(settings)
95
+
96
+ Test usage (in-memory, no persistence):
97
+ store = ChromaVectorStore(settings, client=chromadb.EphemeralClient())
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ settings: MemKernelSettings,
103
+ client: ClientAPI | None = None,
104
+ ) -> None:
105
+ if client is not None:
106
+ self._client: ClientAPI = client
107
+ else:
108
+ persist_path = settings.chroma_persist_path.strip()
109
+ if persist_path:
110
+ self._client = chromadb.PersistentClient(path=persist_path)
111
+ else:
112
+ self._client = chromadb.EphemeralClient()
113
+
114
+ collection_name = f"{settings.chroma_collection_prefix}_{settings.project_id}"
115
+ self._collection: Collection = self._client.get_or_create_collection(
116
+ name=collection_name,
117
+ metadata={"hnsw:space": "cosine"},
118
+ )
119
+
120
+ # ------------------------------------------------------------------
121
+ # VectorStorePort methods
122
+ # ------------------------------------------------------------------
123
+
124
+ def upsert(
125
+ self,
126
+ ref_id: str,
127
+ content: str,
128
+ embedding: list[float],
129
+ metadata: dict[str, Any],
130
+ ) -> None:
131
+ """Insert or update a memory in the collection."""
132
+ missing = _REQUIRED_METADATA_KEYS - metadata.keys()
133
+ if missing:
134
+ raise ValueError(f"upsert missing required metadata keys: {sorted(missing)}")
135
+ embeddings: list[Sequence[float]] = [embedding]
136
+ self._collection.upsert(
137
+ ids=[ref_id],
138
+ embeddings=embeddings,
139
+ documents=[content],
140
+ metadatas=[_to_chroma_metadata(metadata)],
141
+ )
142
+
143
+ def search_dense(
144
+ self,
145
+ query_embedding: list[float],
146
+ top_k: int,
147
+ where: dict[str, Any] | None = None,
148
+ ) -> list[SearchResult]:
149
+ """Query by vector similarity. Returns results ordered by score descending."""
150
+ query_embeddings: list[Sequence[float]] = [query_embedding]
151
+ kwargs: dict[str, Any] = {
152
+ "query_embeddings": query_embeddings,
153
+ "n_results": top_k,
154
+ "include": ["metadatas", "documents", "distances"],
155
+ }
156
+ if where is not None:
157
+ kwargs["where"] = where
158
+
159
+ results = self._collection.query(**kwargs)
160
+
161
+ # ChromaDB returns nested lists; unpack the first (only) query row.
162
+ ids: list[str] = (results.get("ids") or [[]])[0]
163
+ distances: list[float] = (results.get("distances") or [[]])[0]
164
+
165
+ search_results: list[SearchResult] = []
166
+ for rank_idx, (ref_id, distance) in enumerate(zip(ids, distances, strict=True), start=1):
167
+ score = 1.0 - distance
168
+ search_results.append(SearchResult(ref_id=ref_id, score=score, rank=rank_idx))
169
+
170
+ return search_results
171
+
172
+ def get_by_id(self, ref_id: str) -> Memory | None:
173
+ """Fetch a single memory by its ref_id. Returns None if not found."""
174
+ result = self._collection.get(
175
+ ids=[ref_id],
176
+ include=["metadatas", "documents"],
177
+ )
178
+ ids: list[str] = result.get("ids") or []
179
+ raw_docs = result.get("documents")
180
+ documents: list[str] = list(raw_docs) if raw_docs is not None else []
181
+ raw_metas = result.get("metadatas")
182
+ metadatas: list[Mapping[str, Any]] = list(raw_metas) if raw_metas is not None else []
183
+
184
+ if not ids:
185
+ return None
186
+
187
+ return _memory_from_chroma(ids, documents, metadatas, 0)
188
+
189
+ def delete(self, ref_id: str) -> bool:
190
+ """Delete a memory by ref_id. Returns True if it existed, False otherwise."""
191
+ existing = self.get_by_id(ref_id)
192
+ if existing is None:
193
+ return False
194
+ self._collection.delete(ids=[ref_id])
195
+ return True
196
+
197
+ def list_all(self, where: dict[str, Any] | None = None) -> list[Memory]:
198
+ """Return all memories, optionally filtered by metadata."""
199
+ kwargs: dict[str, Any] = {"include": ["metadatas", "documents"]}
200
+ if where is not None:
201
+ kwargs["where"] = where
202
+
203
+ result = self._collection.get(**kwargs)
204
+ ids: list[str] = result.get("ids") or []
205
+ raw_docs = result.get("documents")
206
+ documents: list[str] = list(raw_docs) if raw_docs is not None else []
207
+ raw_metas = result.get("metadatas")
208
+ metadatas: list[Mapping[str, Any]] = list(raw_metas) if raw_metas is not None else []
209
+
210
+ return [_memory_from_chroma(ids, documents, metadatas, i) for i in range(len(ids))]
211
+
212
+ def find_by_hash(self, content_hash: str, memory_type: str) -> Memory | None:
213
+ """Find a memory by content hash + type. Returns first match or None."""
214
+ result = self._collection.get(
215
+ where={"$and": [{"hash": content_hash}, {"type": memory_type}]},
216
+ include=["metadatas", "documents"],
217
+ )
218
+ ids: list[str] = result.get("ids") or []
219
+ raw_docs = result.get("documents")
220
+ documents: list[str] = list(raw_docs) if raw_docs is not None else []
221
+ raw_metas = result.get("metadatas")
222
+ metadatas: list[Mapping[str, Any]] = list(raw_metas) if raw_metas is not None else []
223
+
224
+ if not ids:
225
+ return None
226
+
227
+ return _memory_from_chroma(ids, documents, metadatas, 0)
228
+
229
+ def update_metadata(self, ref_id: str, metadata: dict[str, Any]) -> None:
230
+ """Fetch-and-merge: caller-specified keys overwrite existing; all other keys preserved."""
231
+ existing_result = self._collection.get(
232
+ ids=[ref_id],
233
+ include=["metadatas"],
234
+ )
235
+ existing_ids: list[str] = existing_result.get("ids") or []
236
+ raw_existing_metas = existing_result.get("metadatas")
237
+ existing_metadatas: list[Mapping[str, Any]] = list(raw_existing_metas) if raw_existing_metas is not None else []
238
+
239
+ if not existing_ids:
240
+ return
241
+
242
+ merged: dict[str, Any] = dict(existing_metadatas[0])
243
+ merged.update(metadata)
244
+
245
+ self._collection.update(
246
+ ids=[ref_id],
247
+ metadatas=[_to_chroma_metadata(merged)],
248
+ )
249
+
250
+ def count(self) -> int:
251
+ """Return the total number of memories in the collection."""
252
+ return self._collection.count()
253
+
254
+ def list_collections(self) -> list[dict[str, Any]]:
255
+ """List all collections accessible via this client with their record counts."""
256
+ return [{"name": coll.name, "count": coll.count()} for coll in self._client.list_collections()]
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """LLM-wiki bridge: compile long-term memories into markdown pages and index wiki pages as memories.
4
+
5
+ The wiki (Karpathy's "LLM Wiki" pattern) is the human-readable, git-versioned
6
+ form of the long-term semantic tier. MemKernel's vector store is the index
7
+ over it plus the home of everything that is *not* wiki-worthy (episodes,
8
+ observations, working state). See docs/architecture/memory-tiers.md.
9
+ """
10
+
11
+ from memkernel.wiki.pages import WikiPage, parse_page, render_page
12
+ from memkernel.wiki.sync import WikiExporter, WikiImporter
13
+
14
+ __all__ = ["WikiExporter", "WikiImporter", "WikiPage", "parse_page", "render_page"]