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,485 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""MemoryService — primary domain service for retain/recall/get/forget."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from memkernel.core.models import (
|
|
15
|
+
DEFAULT_IMPORTANCE,
|
|
16
|
+
ArtifactRef,
|
|
17
|
+
Memory,
|
|
18
|
+
MemoryTier,
|
|
19
|
+
MemoryType,
|
|
20
|
+
SearchQuery,
|
|
21
|
+
tier_of,
|
|
22
|
+
)
|
|
23
|
+
from memkernel.memory.dedup import compute_content_hash
|
|
24
|
+
from memkernel.memory.lifecycle import memory_is_expired
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from memkernel.config import MemKernelSettings
|
|
28
|
+
from memkernel.core.ports import ChunkerPort, EmbeddingPort, SparseIndexPort, VectorStorePort
|
|
29
|
+
from memkernel.retrieval.search import HybridSearchService
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _ExtraMeta:
|
|
36
|
+
"""Optional tiered-memory metadata passed through retain()."""
|
|
37
|
+
|
|
38
|
+
importance: float | None = None
|
|
39
|
+
tags: list[str] = field(default_factory=list)
|
|
40
|
+
supersedes: str | None = None
|
|
41
|
+
promoted_from: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MemoryService:
|
|
45
|
+
"""Orchestrates retain, recall, get, and forget operations.
|
|
46
|
+
|
|
47
|
+
This is the only entry point for domain logic — MCP tools and the admin
|
|
48
|
+
API both delegate to this service.
|
|
49
|
+
|
|
50
|
+
Deduplication (ADR-5): identical content + same type within a project
|
|
51
|
+
returns the existing ref_id and bumps session_id / timestamp.
|
|
52
|
+
Same content + different type is treated as a separate memory.
|
|
53
|
+
|
|
54
|
+
Tiered memory (ADR-7): every memory belongs to a tier derived from its
|
|
55
|
+
type (working / short_term / long_term). ``recall`` and ``get`` record
|
|
56
|
+
access so that useful short-term memories are reinforced instead of
|
|
57
|
+
expiring; ``promote`` moves knowledge into the long-term tier and
|
|
58
|
+
``consolidate`` lists what is worth promoting and purges what has decayed.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
vector_store: VectorStorePort,
|
|
64
|
+
embedding: EmbeddingPort,
|
|
65
|
+
sparse_index: SparseIndexPort,
|
|
66
|
+
search_service: HybridSearchService,
|
|
67
|
+
settings: MemKernelSettings,
|
|
68
|
+
chunker: ChunkerPort | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._vector_store = vector_store
|
|
71
|
+
self._embedding = embedding
|
|
72
|
+
self._sparse_index = sparse_index
|
|
73
|
+
self._search_service = search_service
|
|
74
|
+
self._settings = settings
|
|
75
|
+
self._chunker = chunker
|
|
76
|
+
self._last_recall_ms: float | None = None
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Core operations
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def retain(
|
|
83
|
+
self,
|
|
84
|
+
content: str,
|
|
85
|
+
type: MemoryType, # noqa: A002 (shadows built-in intentionally)
|
|
86
|
+
session_id: str | None = None,
|
|
87
|
+
checkpoint: bool = False,
|
|
88
|
+
source: str = "",
|
|
89
|
+
importance: float | None = None,
|
|
90
|
+
tags: list[str] | None = None,
|
|
91
|
+
supersedes: str | None = None,
|
|
92
|
+
promoted_from: str | None = None,
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Store content and return its ref_id.
|
|
95
|
+
|
|
96
|
+
For MemoryType.CODE with a chunker injected: stores the original
|
|
97
|
+
content as the canonical record (so get(retain(code)) always returns
|
|
98
|
+
the full file), then also indexes each AST chunk so recall() can
|
|
99
|
+
match at function/class granularity. For all other types (or when
|
|
100
|
+
no chunker is set): stores content as a single record.
|
|
101
|
+
|
|
102
|
+
``importance`` (0..1) defaults per type (see ``DEFAULT_IMPORTANCE``).
|
|
103
|
+
``supersedes`` links a newer memory to the one it replaces so that
|
|
104
|
+
recall can hide the stale version (see ``forget``/``consolidate``).
|
|
105
|
+
|
|
106
|
+
Idempotent: identical content + type returns the existing ref_id.
|
|
107
|
+
"""
|
|
108
|
+
extra = _ExtraMeta(importance=importance, tags=tags or [], supersedes=supersedes, promoted_from=promoted_from)
|
|
109
|
+
if type == MemoryType.CODE and self._chunker is not None:
|
|
110
|
+
chunks = self._chunker.chunk(content, {"source": source})
|
|
111
|
+
if chunks:
|
|
112
|
+
# Canonical record: original file — preserves get(retain(code)) == code
|
|
113
|
+
ref_id = self._retain_single(content, type, session_id, checkpoint, source, extra)
|
|
114
|
+
# Fine-grained index: each chunk stored for recall() granularity
|
|
115
|
+
chunk_extra = _ExtraMeta(importance=importance, tags=tags or [])
|
|
116
|
+
chunk_ref_ids: list[str] = []
|
|
117
|
+
for chunk in chunks:
|
|
118
|
+
chunk_ref_ids.append(self._retain_single(chunk, type, session_id, checkpoint, source, chunk_extra))
|
|
119
|
+
# Link chunk ref_ids to canonical so forget() can clean them up
|
|
120
|
+
unique_chunk_refs = list(dict.fromkeys(r for r in chunk_ref_ids if r != ref_id))
|
|
121
|
+
if unique_chunk_refs:
|
|
122
|
+
self._vector_store.update_metadata(ref_id, {"chunk_refs": ",".join(unique_chunk_refs)})
|
|
123
|
+
return ref_id
|
|
124
|
+
|
|
125
|
+
return self._retain_single(content, type, session_id, checkpoint, source, extra)
|
|
126
|
+
|
|
127
|
+
def _retain_single(
|
|
128
|
+
self,
|
|
129
|
+
content: str,
|
|
130
|
+
type: MemoryType, # noqa: A002
|
|
131
|
+
session_id: str | None,
|
|
132
|
+
checkpoint: bool,
|
|
133
|
+
source: str,
|
|
134
|
+
extra: _ExtraMeta | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Store a single content string and return its ref_id."""
|
|
137
|
+
extra = extra or _ExtraMeta()
|
|
138
|
+
resolved_session = session_id or self._settings.default_session_id
|
|
139
|
+
content_hash = compute_content_hash(content)
|
|
140
|
+
|
|
141
|
+
# Dedup check: same hash + same type -> update metadata, return existing id
|
|
142
|
+
existing = self._vector_store.find_by_hash(content_hash, type.value)
|
|
143
|
+
if existing is not None:
|
|
144
|
+
now = datetime.now(UTC)
|
|
145
|
+
update: dict[str, Any] = {
|
|
146
|
+
"session_id": resolved_session,
|
|
147
|
+
"timestamp": now.isoformat(),
|
|
148
|
+
"checkpoint": checkpoint,
|
|
149
|
+
}
|
|
150
|
+
if extra.importance is not None:
|
|
151
|
+
update["importance"] = max(existing.importance, extra.importance)
|
|
152
|
+
if extra.tags:
|
|
153
|
+
update["tags"] = ",".join(dict.fromkeys([*existing.tags, *extra.tags]))
|
|
154
|
+
if extra.supersedes:
|
|
155
|
+
update["supersedes"] = extra.supersedes
|
|
156
|
+
self._vector_store.update_metadata(existing.ref_id, update)
|
|
157
|
+
return existing.ref_id
|
|
158
|
+
|
|
159
|
+
# New memory
|
|
160
|
+
ref_id = str(uuid4())
|
|
161
|
+
now = datetime.now(UTC)
|
|
162
|
+
token_count = max(1, len(content) // 4)
|
|
163
|
+
embedding = self._embedding.embed([content])[0]
|
|
164
|
+
importance = extra.importance if extra.importance is not None else DEFAULT_IMPORTANCE[type]
|
|
165
|
+
metadata: dict[str, Any] = {
|
|
166
|
+
"project_id": self._settings.project_id,
|
|
167
|
+
"session_id": resolved_session,
|
|
168
|
+
"timestamp": now.isoformat(),
|
|
169
|
+
"type": type.value,
|
|
170
|
+
"tier": tier_of(type).value,
|
|
171
|
+
"source": source,
|
|
172
|
+
"hash": content_hash,
|
|
173
|
+
"token_count": token_count,
|
|
174
|
+
"checkpoint": checkpoint,
|
|
175
|
+
"importance": max(0.0, min(1.0, importance)),
|
|
176
|
+
"access_count": 0,
|
|
177
|
+
"tags": ",".join(extra.tags),
|
|
178
|
+
}
|
|
179
|
+
if extra.supersedes:
|
|
180
|
+
metadata["supersedes"] = extra.supersedes
|
|
181
|
+
if extra.promoted_from:
|
|
182
|
+
metadata["promoted_from"] = extra.promoted_from
|
|
183
|
+
|
|
184
|
+
# Authoritative store first
|
|
185
|
+
self._vector_store.upsert(
|
|
186
|
+
ref_id=ref_id,
|
|
187
|
+
content=content,
|
|
188
|
+
embedding=embedding,
|
|
189
|
+
metadata=metadata,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# BM25 is a derived index — failure is non-fatal
|
|
193
|
+
try:
|
|
194
|
+
self._sparse_index.add(ref_id, content)
|
|
195
|
+
except Exception:
|
|
196
|
+
logger.warning("BM25 add failed for %s", ref_id)
|
|
197
|
+
self._sparse_index.mark_needs_rebuild()
|
|
198
|
+
|
|
199
|
+
return ref_id
|
|
200
|
+
|
|
201
|
+
def recall(
|
|
202
|
+
self,
|
|
203
|
+
query: str,
|
|
204
|
+
type: MemoryType | None = None, # noqa: A002
|
|
205
|
+
session_id: str | None = None,
|
|
206
|
+
checkpoint_only: bool = False,
|
|
207
|
+
top_k: int | None = None,
|
|
208
|
+
tier: MemoryTier | None = None,
|
|
209
|
+
touch: bool = True,
|
|
210
|
+
) -> list[ArtifactRef]:
|
|
211
|
+
"""Search for memories matching the query.
|
|
212
|
+
|
|
213
|
+
Returns a list of ArtifactRef (lightweight references, no full content).
|
|
214
|
+
Expired and superseded memories are filtered out from the results.
|
|
215
|
+
Returned memories are marked as accessed (``touch``), which reinforces
|
|
216
|
+
their retention — unless ``touch=False`` (used by maintenance paths).
|
|
217
|
+
BM25 index is self-healed (rebuilt from vector store) if needs_rebuild is set.
|
|
218
|
+
"""
|
|
219
|
+
_start = time.perf_counter()
|
|
220
|
+
|
|
221
|
+
if self._sparse_index.needs_rebuild:
|
|
222
|
+
self.rebuild_sparse_index()
|
|
223
|
+
|
|
224
|
+
desired_k = top_k if top_k is not None else self._settings.search_top_k
|
|
225
|
+
|
|
226
|
+
# Iteratively widen the fetch window until we have enough live results
|
|
227
|
+
# or the store is fully exhausted. No fixed ceiling: the store_exhausted
|
|
228
|
+
# guard is the correct termination condition and handles any corpus size.
|
|
229
|
+
fetch_k = desired_k * 2
|
|
230
|
+
live: list[ArtifactRef] = []
|
|
231
|
+
|
|
232
|
+
while True:
|
|
233
|
+
search_query = SearchQuery(
|
|
234
|
+
query_text=query,
|
|
235
|
+
type_filter=type,
|
|
236
|
+
tier_filter=tier,
|
|
237
|
+
session_id_filter=session_id,
|
|
238
|
+
checkpoint_only=checkpoint_only,
|
|
239
|
+
top_k=fetch_k,
|
|
240
|
+
)
|
|
241
|
+
results = self._search_service.search(search_query)
|
|
242
|
+
live = self._filter_live(results)
|
|
243
|
+
|
|
244
|
+
store_exhausted = len(results) < fetch_k
|
|
245
|
+
if len(live) >= desired_k or store_exhausted:
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
fetch_k *= 2
|
|
249
|
+
|
|
250
|
+
live = live[:desired_k]
|
|
251
|
+
if touch:
|
|
252
|
+
for ref in live:
|
|
253
|
+
self._touch(ref.ref_id)
|
|
254
|
+
self._last_recall_ms = (time.perf_counter() - _start) * 1000
|
|
255
|
+
return live
|
|
256
|
+
|
|
257
|
+
def _filter_live(self, results: list[ArtifactRef]) -> list[ArtifactRef]:
|
|
258
|
+
"""Drop expired memories and memories that a newer record supersedes."""
|
|
259
|
+
memories: dict[str, Memory] = {}
|
|
260
|
+
for ref in results:
|
|
261
|
+
memory = self._vector_store.get_by_id(ref.ref_id)
|
|
262
|
+
if memory is not None:
|
|
263
|
+
memories[ref.ref_id] = memory
|
|
264
|
+
superseded = {m.supersedes for m in memories.values() if m.supersedes}
|
|
265
|
+
live: list[ArtifactRef] = []
|
|
266
|
+
for ref in results:
|
|
267
|
+
memory = memories.get(ref.ref_id)
|
|
268
|
+
if memory is None or ref.ref_id in superseded:
|
|
269
|
+
continue
|
|
270
|
+
if memory_is_expired(memory, self._settings):
|
|
271
|
+
continue
|
|
272
|
+
live.append(ref)
|
|
273
|
+
return live
|
|
274
|
+
|
|
275
|
+
def _touch(self, ref_id: str) -> None:
|
|
276
|
+
"""Record an access: bump access_count and last_accessed (rehearsal)."""
|
|
277
|
+
memory = self._vector_store.get_by_id(ref_id)
|
|
278
|
+
if memory is None:
|
|
279
|
+
return
|
|
280
|
+
self._vector_store.update_metadata(
|
|
281
|
+
ref_id,
|
|
282
|
+
{"access_count": memory.access_count + 1, "last_accessed": datetime.now(UTC).isoformat()},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def count(self) -> int:
|
|
286
|
+
"""Return the total number of memories stored in this project."""
|
|
287
|
+
return self._vector_store.count()
|
|
288
|
+
|
|
289
|
+
def list_memories(
|
|
290
|
+
self,
|
|
291
|
+
tier: MemoryTier | None = None,
|
|
292
|
+
type: MemoryType | None = None, # noqa: A002
|
|
293
|
+
session_id: str | None = None,
|
|
294
|
+
) -> list[Memory]:
|
|
295
|
+
"""Return memories matching the given filters (no ranking, no access tracking).
|
|
296
|
+
|
|
297
|
+
``tier`` is filtered in Python because records written before tiers
|
|
298
|
+
existed carry no ``tier`` metadata; the adapter derives it from the type.
|
|
299
|
+
"""
|
|
300
|
+
conditions: list[dict[str, Any]] = []
|
|
301
|
+
if type is not None:
|
|
302
|
+
conditions.append({"type": type.value})
|
|
303
|
+
if session_id is not None:
|
|
304
|
+
conditions.append({"session_id": session_id})
|
|
305
|
+
where: dict[str, Any] | None = None
|
|
306
|
+
if len(conditions) == 1:
|
|
307
|
+
where = conditions[0]
|
|
308
|
+
elif conditions:
|
|
309
|
+
where = {"$and": conditions}
|
|
310
|
+
memories = self._vector_store.list_all(where)
|
|
311
|
+
if tier is not None:
|
|
312
|
+
memories = [m for m in memories if m.tier == tier]
|
|
313
|
+
return memories
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def project_id(self) -> str:
|
|
317
|
+
"""Return the project_id for this service instance."""
|
|
318
|
+
return self._settings.project_id
|
|
319
|
+
|
|
320
|
+
def stats(self) -> dict[str, object]:
|
|
321
|
+
"""Return total count, per-type breakdown, memory growth, and last recall timing."""
|
|
322
|
+
all_memories = self._vector_store.list_all()
|
|
323
|
+
now = datetime.now(UTC)
|
|
324
|
+
by_type: dict[str, int] = {}
|
|
325
|
+
by_tier: dict[str, int] = {}
|
|
326
|
+
for m in all_memories:
|
|
327
|
+
by_type[m.type.value] = by_type.get(m.type.value, 0) + 1
|
|
328
|
+
by_tier[m.tier.value] = by_tier.get(m.tier.value, 0) + 1
|
|
329
|
+
growth = {
|
|
330
|
+
"last_24h": sum(1 for m in all_memories if m.timestamp >= now - timedelta(hours=24)),
|
|
331
|
+
"last_7d": sum(1 for m in all_memories if m.timestamp >= now - timedelta(days=7)),
|
|
332
|
+
"last_30d": sum(1 for m in all_memories if m.timestamp >= now - timedelta(days=30)),
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
"total": len(all_memories),
|
|
336
|
+
"by_type": by_type,
|
|
337
|
+
"by_tier": by_tier,
|
|
338
|
+
"growth": growth,
|
|
339
|
+
"last_recall_ms": self._last_recall_ms,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def collection_count(self) -> int:
|
|
343
|
+
"""Return the number of collections visible to this server's storage backend."""
|
|
344
|
+
return len(self._vector_store.list_collections())
|
|
345
|
+
|
|
346
|
+
def list_collections(self) -> list[dict[str, object]]:
|
|
347
|
+
"""Return all collections visible to the storage backend with their record counts."""
|
|
348
|
+
return self._vector_store.list_collections()
|
|
349
|
+
|
|
350
|
+
def get(self, ref_id: str, touch: bool = True) -> Memory | None:
|
|
351
|
+
"""Fetch full memory content by ref_id. Returns None if not found.
|
|
352
|
+
|
|
353
|
+
Reading the full content is the strongest signal that a memory is
|
|
354
|
+
useful, so it is recorded as an access unless ``touch=False``.
|
|
355
|
+
"""
|
|
356
|
+
memory = self._vector_store.get_by_id(ref_id)
|
|
357
|
+
if memory is not None and touch:
|
|
358
|
+
self._touch(ref_id)
|
|
359
|
+
return memory
|
|
360
|
+
|
|
361
|
+
# ------------------------------------------------------------------
|
|
362
|
+
# Consolidation (short-term -> long-term)
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def promote(
|
|
366
|
+
self,
|
|
367
|
+
ref_id: str,
|
|
368
|
+
type: MemoryType, # noqa: A002
|
|
369
|
+
content: str | None = None,
|
|
370
|
+
importance: float | None = None,
|
|
371
|
+
tags: list[str] | None = None,
|
|
372
|
+
session_id: str | None = None,
|
|
373
|
+
) -> str | None:
|
|
374
|
+
"""Promote a memory into a (normally long-term) type and return the new ref_id.
|
|
375
|
+
|
|
376
|
+
The caller — typically the agent, which is the only component with an
|
|
377
|
+
LLM in the loop — supplies the distilled ``content``; when omitted the
|
|
378
|
+
original content is reused verbatim. The source memory is kept (it
|
|
379
|
+
decays on its own schedule) and linked via ``promoted_from`` so that
|
|
380
|
+
provenance survives. Returns None if ``ref_id`` does not exist.
|
|
381
|
+
"""
|
|
382
|
+
source = self._vector_store.get_by_id(ref_id)
|
|
383
|
+
if source is None:
|
|
384
|
+
return None
|
|
385
|
+
merged_tags = list(dict.fromkeys([*source.tags, *(tags or [])]))
|
|
386
|
+
return self.retain(
|
|
387
|
+
content=content if content is not None else source.content,
|
|
388
|
+
type=type,
|
|
389
|
+
session_id=session_id or source.session_id,
|
|
390
|
+
source=source.source,
|
|
391
|
+
importance=importance,
|
|
392
|
+
tags=merged_tags,
|
|
393
|
+
promoted_from=ref_id,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def consolidate(
|
|
397
|
+
self,
|
|
398
|
+
session_id: str | None = None,
|
|
399
|
+
limit: int = 20,
|
|
400
|
+
purge: bool = True,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
"""Run a consolidation pass and return promotion candidates.
|
|
403
|
+
|
|
404
|
+
1. Purge expired short-term / working memories (``purge=True``).
|
|
405
|
+
2. Rank the surviving short-term and working memories that have not
|
|
406
|
+
been promoted yet by ``importance * (1 + access_count)`` — the
|
|
407
|
+
memories the agent kept coming back to — and return the top
|
|
408
|
+
``limit`` as candidates for :meth:`promote`.
|
|
409
|
+
|
|
410
|
+
This is deliberately LLM-free: the server does the bookkeeping, the
|
|
411
|
+
agent does the synthesis (see docs/architecture/memory-tiers.md).
|
|
412
|
+
"""
|
|
413
|
+
purged = self.forget_expired() if purge else 0
|
|
414
|
+
where: dict[str, Any] | None = {"session_id": session_id} if session_id else None
|
|
415
|
+
all_memories = self._vector_store.list_all(where)
|
|
416
|
+
promoted_sources = {m.promoted_from for m in all_memories if m.promoted_from}
|
|
417
|
+
candidates = [
|
|
418
|
+
m
|
|
419
|
+
for m in all_memories
|
|
420
|
+
if m.tier != MemoryTier.LONG_TERM and m.ref_id not in promoted_sources and not m.checkpoint
|
|
421
|
+
]
|
|
422
|
+
candidates.sort(key=lambda m: m.importance * (1 + m.access_count), reverse=True)
|
|
423
|
+
preview_len = self._settings.preview_length
|
|
424
|
+
return {
|
|
425
|
+
"purged": purged,
|
|
426
|
+
"candidates": [
|
|
427
|
+
{
|
|
428
|
+
"ref_id": m.ref_id,
|
|
429
|
+
"type": m.type.value,
|
|
430
|
+
"tier": m.tier.value,
|
|
431
|
+
"importance": m.importance,
|
|
432
|
+
"access_count": m.access_count,
|
|
433
|
+
"timestamp": m.timestamp.isoformat(),
|
|
434
|
+
"preview": m.content[:preview_len],
|
|
435
|
+
}
|
|
436
|
+
for m in candidates[:limit]
|
|
437
|
+
],
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
def forget_expired(self) -> int:
|
|
441
|
+
"""Delete every memory whose retention window has lapsed. Returns the number deleted."""
|
|
442
|
+
deleted = 0
|
|
443
|
+
for memory in self._vector_store.list_all():
|
|
444
|
+
if (
|
|
445
|
+
memory.tier != MemoryTier.LONG_TERM
|
|
446
|
+
and memory_is_expired(memory, self._settings)
|
|
447
|
+
and self.forget(memory.ref_id)
|
|
448
|
+
):
|
|
449
|
+
deleted += 1
|
|
450
|
+
return deleted
|
|
451
|
+
|
|
452
|
+
def forget(self, ref_id: str) -> bool:
|
|
453
|
+
"""Delete a memory from all stores.
|
|
454
|
+
|
|
455
|
+
Returns True if the memory existed and was deleted, False otherwise.
|
|
456
|
+
BM25 removal failure is non-fatal — the index is marked for rebuild.
|
|
457
|
+
"""
|
|
458
|
+
# For CODE canonical records, delete linked chunk records first
|
|
459
|
+
canonical = self._vector_store.get_by_id(ref_id)
|
|
460
|
+
if canonical is not None:
|
|
461
|
+
for chunk_ref in canonical.chunk_refs:
|
|
462
|
+
self._vector_store.delete(chunk_ref)
|
|
463
|
+
try:
|
|
464
|
+
self._sparse_index.remove(chunk_ref)
|
|
465
|
+
except Exception:
|
|
466
|
+
logger.warning("BM25 remove failed for chunk %s", chunk_ref)
|
|
467
|
+
self._sparse_index.mark_needs_rebuild()
|
|
468
|
+
|
|
469
|
+
deleted = self._vector_store.delete(ref_id)
|
|
470
|
+
try:
|
|
471
|
+
self._sparse_index.remove(ref_id)
|
|
472
|
+
except Exception:
|
|
473
|
+
logger.warning("BM25 remove failed for %s", ref_id)
|
|
474
|
+
self._sparse_index.mark_needs_rebuild()
|
|
475
|
+
return deleted
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
# Maintenance
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def rebuild_sparse_index(self) -> None:
|
|
482
|
+
"""Rebuild the BM25 index from scratch using the vector store as source of truth."""
|
|
483
|
+
all_memories = self._vector_store.list_all()
|
|
484
|
+
documents = [(m.ref_id, m.content) for m in all_memories]
|
|
485
|
+
self._sparse_index.rebuild(documents)
|
memkernel/factory.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Public factory for a fully wired :class:`MemoryService`.
|
|
4
|
+
|
|
5
|
+
Embedders of MemKernel (Claude Code hooks, the ``haa`` harness, scripts) should
|
|
6
|
+
build the service through :func:`build_service` instead of assembling the
|
|
7
|
+
adapters by hand, so that internal refactors do not break them. This is the
|
|
8
|
+
supported Python entry point; the MCP server and the admin API use it too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from memkernel.config import MemKernelSettings
|
|
16
|
+
from memkernel.core.services import MemoryService
|
|
17
|
+
from memkernel.retrieval.search import HybridSearchService
|
|
18
|
+
from memkernel.retrieval.sparse import BM25Index
|
|
19
|
+
from memkernel.storage.chroma import ChromaVectorStore
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from chromadb.api import ClientAPI
|
|
23
|
+
|
|
24
|
+
from memkernel.core.ports import ChunkerPort, EmbeddingPort
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_service(
|
|
28
|
+
settings: MemKernelSettings | None = None,
|
|
29
|
+
embedding: EmbeddingPort | None = None,
|
|
30
|
+
chunker: ChunkerPort | None = None,
|
|
31
|
+
chroma_client: ClientAPI | None = None,
|
|
32
|
+
rebuild_sparse_index: bool = True,
|
|
33
|
+
) -> MemoryService:
|
|
34
|
+
"""Return a ready-to-use MemoryService wired to ChromaDB + BM25 + sentence-transformers.
|
|
35
|
+
|
|
36
|
+
``settings`` defaults to environment-driven :class:`MemKernelSettings`.
|
|
37
|
+
``embedding`` defaults to the configured sentence-transformers model (loaded
|
|
38
|
+
lazily here so tests and lightweight callers can pass a mock instead).
|
|
39
|
+
``chunker`` defaults to the tree-sitter :class:`ASTChunker`.
|
|
40
|
+
"""
|
|
41
|
+
resolved = settings or MemKernelSettings()
|
|
42
|
+
|
|
43
|
+
if embedding is None:
|
|
44
|
+
from memkernel.retrieval.embeddings import SentenceTransformerEmbedding
|
|
45
|
+
|
|
46
|
+
embedding = SentenceTransformerEmbedding(resolved)
|
|
47
|
+
|
|
48
|
+
if chunker is None:
|
|
49
|
+
from memkernel.retrieval.chunking import ASTChunker
|
|
50
|
+
|
|
51
|
+
chunker = ASTChunker(resolved)
|
|
52
|
+
|
|
53
|
+
vector_store = ChromaVectorStore(resolved, client=chroma_client)
|
|
54
|
+
sparse_index = BM25Index()
|
|
55
|
+
search_service = HybridSearchService(
|
|
56
|
+
vector_store=vector_store,
|
|
57
|
+
embedding=embedding,
|
|
58
|
+
sparse_index=sparse_index,
|
|
59
|
+
settings=resolved,
|
|
60
|
+
)
|
|
61
|
+
service = MemoryService(
|
|
62
|
+
vector_store=vector_store,
|
|
63
|
+
embedding=embedding,
|
|
64
|
+
sparse_index=sparse_index,
|
|
65
|
+
search_service=search_service,
|
|
66
|
+
settings=resolved,
|
|
67
|
+
chunker=chunker,
|
|
68
|
+
)
|
|
69
|
+
if rebuild_sparse_index:
|
|
70
|
+
service.rebuild_sparse_index()
|
|
71
|
+
return service
|
memkernel/mcp/server.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""MCP server factory and entry point."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
from mcp.server.fastmcp.server import Context # noqa: TC002 — runtime import for introspection
|
|
19
|
+
|
|
20
|
+
from memkernel.config import MemKernelSettings
|
|
21
|
+
from memkernel.factory import build_service
|
|
22
|
+
from memkernel.mcp.tools.memory import register_memory_tools
|
|
23
|
+
from memkernel.mcp.tools.wiki import register_wiki_tools
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from memkernel.core.ports import EmbeddingPort
|
|
27
|
+
from memkernel.core.services import MemoryService
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MemKernelContext:
|
|
34
|
+
"""Lifespan context shared with all tool handlers."""
|
|
35
|
+
|
|
36
|
+
memory_service: MemoryService
|
|
37
|
+
settings: MemKernelSettings
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_server(
|
|
41
|
+
settings: MemKernelSettings | None = None,
|
|
42
|
+
embedding: EmbeddingPort | None = None,
|
|
43
|
+
) -> FastMCP[MemKernelContext]:
|
|
44
|
+
"""Factory — single source of truth for settings.
|
|
45
|
+
|
|
46
|
+
The lifespan closure captures ``settings`` from here.
|
|
47
|
+
Tests pass custom settings (e.g. in-memory chroma) and a MockEmbedding;
|
|
48
|
+
production calls with None to load from env and use the real model.
|
|
49
|
+
"""
|
|
50
|
+
resolved_settings = settings or MemKernelSettings()
|
|
51
|
+
captured_embedding = embedding
|
|
52
|
+
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def lifespan(server: FastMCP[MemKernelContext]) -> AsyncIterator[MemKernelContext]:
|
|
55
|
+
memory_service = build_service(settings=resolved_settings, embedding=captured_embedding)
|
|
56
|
+
yield MemKernelContext(memory_service=memory_service, settings=resolved_settings)
|
|
57
|
+
|
|
58
|
+
server: FastMCP[MemKernelContext] = FastMCP(
|
|
59
|
+
"MemKernel",
|
|
60
|
+
host=resolved_settings.host,
|
|
61
|
+
port=resolved_settings.port,
|
|
62
|
+
lifespan=lifespan,
|
|
63
|
+
)
|
|
64
|
+
register_memory_tools(server)
|
|
65
|
+
register_wiki_tools(server)
|
|
66
|
+
_register_health_resource(server)
|
|
67
|
+
return server
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _register_health_resource(server: FastMCP[MemKernelContext]) -> None:
|
|
71
|
+
@server.resource("memory://status")
|
|
72
|
+
async def status(ctx: Context[Any, MemKernelContext]) -> str:
|
|
73
|
+
"""Server health and memory statistics."""
|
|
74
|
+
app_ctx: MemKernelContext = ctx.request_context.lifespan_context
|
|
75
|
+
count = app_ctx.memory_service.count()
|
|
76
|
+
return json.dumps(
|
|
77
|
+
{
|
|
78
|
+
"status": "ok",
|
|
79
|
+
"project_id": app_ctx.settings.project_id,
|
|
80
|
+
"memory_count": count,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> None:
|
|
86
|
+
"""CLI entry point."""
|
|
87
|
+
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
|
88
|
+
settings = MemKernelSettings()
|
|
89
|
+
server = create_server(settings)
|
|
90
|
+
server.run(transport=settings.transport)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|