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,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
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -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()
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0