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.
- memkernel/__init__.py +5 -0
- memkernel/api/__init__.py +2 -0
- memkernel/api/app.py +64 -0
- memkernel/api/routes/__init__.py +2 -0
- memkernel/api/routes/admin.py +44 -0
- memkernel/config.py +56 -0
- memkernel/core/__init__.py +2 -0
- memkernel/core/models.py +143 -0
- memkernel/core/ports.py +95 -0
- memkernel/core/services.py +485 -0
- memkernel/factory.py +71 -0
- memkernel/mcp/__init__.py +2 -0
- memkernel/mcp/server.py +94 -0
- memkernel/mcp/tools/__init__.py +2 -0
- memkernel/mcp/tools/memory.py +148 -0
- memkernel/mcp/tools/wiki.py +59 -0
- memkernel/memory/__init__.py +2 -0
- memkernel/memory/dedup.py +12 -0
- memkernel/memory/lifecycle.py +92 -0
- memkernel/memory/types.py +40 -0
- memkernel/py.typed +0 -0
- memkernel/retrieval/__init__.py +2 -0
- memkernel/retrieval/chunking.py +244 -0
- memkernel/retrieval/embeddings.py +39 -0
- memkernel/retrieval/search.py +235 -0
- memkernel/retrieval/sparse.py +137 -0
- memkernel/storage/__init__.py +3 -0
- memkernel/storage/chroma.py +256 -0
- memkernel/wiki/__init__.py +14 -0
- memkernel/wiki/pages.py +165 -0
- memkernel/wiki/sync.py +228 -0
- memkernel-0.2.0.dist-info/METADATA +327 -0
- memkernel-0.2.0.dist-info/RECORD +37 -0
- memkernel-0.2.0.dist-info/WHEEL +4 -0
- memkernel-0.2.0.dist-info/entry_points.txt +3 -0
- memkernel-0.2.0.dist-info/licenses/LICENSE +201 -0
- memkernel-0.2.0.dist-info/licenses/NOTICE +5 -0
|
@@ -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,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"]
|