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,148 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """MCP tool registrations: retain, recall, get, forget, promote, consolidate, forget_expired."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import FastMCP # noqa: TC002 — runtime import required by FastMCP introspection
11
+ from mcp.server.fastmcp.server import Context # noqa: TC002
12
+
13
+ from memkernel.core.models import MemoryTier, MemoryType
14
+
15
+
16
+ def _service(ctx: Context[Any, Any, Any] | None) -> Any:
17
+ if ctx is None:
18
+ raise RuntimeError("Tool called outside of MCP request context")
19
+ return ctx.request_context.lifespan_context.memory_service
20
+
21
+
22
+ def register_memory_tools(server: FastMCP[Any]) -> None:
23
+ """Register the memory tools on the server."""
24
+
25
+ @server.tool()
26
+ async def retain(
27
+ content: str,
28
+ type: str, # noqa: A002
29
+ session_id: str | None = None,
30
+ checkpoint: bool = False,
31
+ source: str = "",
32
+ importance: float | None = None,
33
+ tags: list[str] | None = None,
34
+ supersedes: str | None = None,
35
+ ctx: Context[Any, Any, Any] | None = None,
36
+ ) -> str:
37
+ """Store a memory. Returns ref_id. Idempotent for identical content+type.
38
+
39
+ type: decision | convention | fact | code | procedure (long-term),
40
+ episode | observation (short-term), task (working memory).
41
+ importance: 0..1, defaults per type. supersedes: ref_id this memory replaces.
42
+ """
43
+ svc = _service(ctx)
44
+ ref_id = svc.retain(
45
+ content=content,
46
+ type=MemoryType(type),
47
+ session_id=session_id,
48
+ checkpoint=checkpoint,
49
+ source=source,
50
+ importance=importance,
51
+ tags=tags,
52
+ supersedes=supersedes,
53
+ )
54
+ return json.dumps({"ref_id": ref_id})
55
+
56
+ @server.tool()
57
+ async def recall(
58
+ query: str,
59
+ type: str | None = None, # noqa: A002
60
+ tier: str | None = None,
61
+ session_id: str | None = None,
62
+ checkpoint_only: bool = False,
63
+ top_k: int | None = None,
64
+ ctx: Context[Any, Any, Any] | None = None,
65
+ ) -> str:
66
+ """Search memories by query. Returns ref_id + preview pairs ranked by relevance,
67
+ recency and importance. Use get(ref_id) for full content.
68
+
69
+ tier: working | short_term | long_term to restrict the search to one tier.
70
+ """
71
+ svc = _service(ctx)
72
+ results = svc.recall(
73
+ query=query,
74
+ type=MemoryType(type) if type else None,
75
+ tier=MemoryTier(tier) if tier else None,
76
+ session_id=session_id,
77
+ checkpoint_only=checkpoint_only,
78
+ top_k=top_k,
79
+ )
80
+ return json.dumps([ref.model_dump(mode="json") for ref in results])
81
+
82
+ @server.tool()
83
+ async def get(
84
+ ref_id: str,
85
+ ctx: Context[Any, Any, Any] | None = None,
86
+ ) -> str:
87
+ """Retrieve full content of a memory by ref_id."""
88
+ svc = _service(ctx)
89
+ memory = svc.get(ref_id)
90
+ if memory is None:
91
+ return json.dumps({"error": "not_found", "ref_id": ref_id})
92
+ return json.dumps(memory.model_dump(mode="json", exclude={"chunk_refs"}))
93
+
94
+ @server.tool()
95
+ async def forget(
96
+ ref_id: str,
97
+ ctx: Context[Any, Any, Any] | None = None,
98
+ ) -> str:
99
+ """Delete a memory by ref_id. Removes from all indexes."""
100
+ svc = _service(ctx)
101
+ deleted = svc.forget(ref_id)
102
+ return json.dumps({"ref_id": ref_id, "deleted": deleted})
103
+
104
+ @server.tool()
105
+ async def promote(
106
+ ref_id: str,
107
+ type: str, # noqa: A002
108
+ content: str | None = None,
109
+ importance: float | None = None,
110
+ tags: list[str] | None = None,
111
+ ctx: Context[Any, Any, Any] | None = None,
112
+ ) -> str:
113
+ """Promote a short-term/working memory into a long-term type (decision, convention,
114
+ fact, code, procedure). Pass the distilled `content`; defaults to the original text.
115
+ Returns the new ref_id; the source memory is kept and linked via promoted_from.
116
+ """
117
+ svc = _service(ctx)
118
+ new_ref = svc.promote(
119
+ ref_id=ref_id,
120
+ type=MemoryType(type),
121
+ content=content,
122
+ importance=importance,
123
+ tags=tags,
124
+ )
125
+ if new_ref is None:
126
+ return json.dumps({"error": "not_found", "ref_id": ref_id})
127
+ return json.dumps({"ref_id": new_ref, "promoted_from": ref_id})
128
+
129
+ @server.tool()
130
+ async def consolidate(
131
+ session_id: str | None = None,
132
+ limit: int = 20,
133
+ ctx: Context[Any, Any, Any] | None = None,
134
+ ) -> str:
135
+ """Run a consolidation pass: purge expired short-term memories and return the
136
+ short-term/working memories most worth promoting (ranked by importance and how
137
+ often they were recalled). Call promote(ref_id, type, content) for the keepers.
138
+ """
139
+ svc = _service(ctx)
140
+ return json.dumps(svc.consolidate(session_id=session_id, limit=limit))
141
+
142
+ @server.tool()
143
+ async def forget_expired(
144
+ ctx: Context[Any, Any, Any] | None = None,
145
+ ) -> str:
146
+ """Delete every short-term/working memory whose retention window has lapsed."""
147
+ svc = _service(ctx)
148
+ return json.dumps({"deleted": svc.forget_expired()})
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """MCP tool registrations for the LLM-wiki bridge: wiki_export, wiki_import."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from mcp.server.fastmcp import FastMCP # noqa: TC002 — runtime import required by FastMCP introspection
12
+ from mcp.server.fastmcp.server import Context # noqa: TC002
13
+
14
+ from memkernel.wiki.sync import WikiExporter, WikiImporter
15
+
16
+
17
+ def _context(ctx: Context[Any, Any, Any] | None) -> Any:
18
+ if ctx is None:
19
+ raise RuntimeError("Tool called outside of MCP request context")
20
+ return ctx.request_context.lifespan_context
21
+
22
+
23
+ def _resolve_wiki_dir(wiki_dir: str | None, settings: Any) -> Path:
24
+ resolved = wiki_dir or settings.wiki_dir
25
+ if not resolved:
26
+ raise ValueError("No wiki directory: pass wiki_dir or set MEMKERNEL_WIKI_DIR")
27
+ return Path(resolved)
28
+
29
+
30
+ def register_wiki_tools(server: FastMCP[Any]) -> None:
31
+ """Register wiki_export and wiki_import on the server."""
32
+
33
+ @server.tool()
34
+ async def wiki_export(
35
+ wiki_dir: str | None = None,
36
+ ctx: Context[Any, Any, Any] | None = None,
37
+ ) -> str:
38
+ """Compile long-term memories (decision, convention, fact, procedure) that have no wiki
39
+ page yet into draft markdown pages under wiki_dir (default MEMKERNEL_WIKI_DIR), and
40
+ append them to index.md and log.md. Review and curate the drafts before marking stable.
41
+ """
42
+ app_ctx = _context(ctx)
43
+ exporter = WikiExporter(app_ctx.memory_service, _resolve_wiki_dir(wiki_dir, app_ctx.settings))
44
+ report = exporter.export()
45
+ return json.dumps({"written": report.written, "skipped": report.skipped})
46
+
47
+ @server.tool()
48
+ async def wiki_import(
49
+ wiki_dir: str | None = None,
50
+ ctx: Context[Any, Any, Any] | None = None,
51
+ ) -> str:
52
+ """Index every curated wiki page under wiki_dir (default MEMKERNEL_WIKI_DIR) as a
53
+ long-term memory so recall() can find it. Idempotent: unchanged pages dedup, changed
54
+ pages supersede their previous version.
55
+ """
56
+ app_ctx = _context(ctx)
57
+ importer = WikiImporter(app_ctx.memory_service, _resolve_wiki_dir(wiki_dir, app_ctx.settings))
58
+ report = importer.import_pages()
59
+ return json.dumps({"imported": report.imported, "skipped": report.skipped})
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,12 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Content-hash deduplication helpers."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+
9
+
10
+ def compute_content_hash(content: str) -> str:
11
+ """Return the SHA-256 hex digest of the UTF-8 encoded content string."""
12
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Memory lifecycle helpers — decay, retention and expiry.
4
+
5
+ Forgetting follows an Ebbinghaus-style model (as used by MemoryBank, Zhong et
6
+ al. 2023): a memory's retention window grows with every recall, so knowledge
7
+ that keeps being useful survives while untouched episodic noise fades.
8
+
9
+ effective_ttl = base_ttl * (1 + access_boost * access_count)
10
+
11
+ Long-term memories never expire; they are curated by consolidation instead.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from datetime import UTC, datetime
18
+ from typing import TYPE_CHECKING
19
+
20
+ from memkernel.memory.types import get_half_life_days, get_ttl_days
21
+
22
+ if TYPE_CHECKING:
23
+ from memkernel.config import MemKernelSettings
24
+ from memkernel.core.models import Memory, MemoryType
25
+
26
+
27
+ def effective_ttl_days(
28
+ memory_type: MemoryType,
29
+ access_count: int,
30
+ settings: MemKernelSettings,
31
+ ) -> float | None:
32
+ """Return the retention window in days after access-based reinforcement, or None for no expiry."""
33
+ base = get_ttl_days(memory_type, settings)
34
+ if base is None:
35
+ return None
36
+ return base * (1.0 + settings.access_boost * max(0, access_count))
37
+
38
+
39
+ def is_expired(
40
+ memory_type: MemoryType,
41
+ timestamp: datetime,
42
+ settings: MemKernelSettings,
43
+ access_count: int = 0,
44
+ last_accessed: datetime | None = None,
45
+ ) -> bool:
46
+ """Return True if the memory has exceeded its (reinforced) retention window.
47
+
48
+ The clock starts at the later of ``timestamp`` and ``last_accessed`` so a
49
+ recall refreshes the memory the way rehearsal does.
50
+ """
51
+ ttl_days = effective_ttl_days(memory_type, access_count, settings)
52
+ if ttl_days is None:
53
+ return False
54
+ anchor = timestamp
55
+ if last_accessed is not None and last_accessed > anchor:
56
+ anchor = last_accessed
57
+ age_days = (datetime.now(UTC) - anchor).total_seconds() / 86400.0
58
+ return age_days > ttl_days
59
+
60
+
61
+ def memory_is_expired(memory: Memory, settings: MemKernelSettings) -> bool:
62
+ """Convenience wrapper over :func:`is_expired` for a full Memory record."""
63
+ return is_expired(
64
+ memory.type,
65
+ memory.timestamp,
66
+ settings,
67
+ access_count=memory.access_count,
68
+ last_accessed=memory.last_accessed,
69
+ )
70
+
71
+
72
+ def recency_score(
73
+ memory_type: MemoryType,
74
+ timestamp: datetime,
75
+ settings: MemKernelSettings,
76
+ last_accessed: datetime | None = None,
77
+ now: datetime | None = None,
78
+ ) -> float:
79
+ """Return a recency factor in (0, 1]: exponential decay with a per-tier half-life.
80
+
81
+ Long-term memories have no recency decay (always 1.0) — a convention that
82
+ was set two years ago is as relevant as one set yesterday.
83
+ """
84
+ half_life = get_half_life_days(memory_type, settings)
85
+ if half_life is None or half_life <= 0:
86
+ return 1.0
87
+ anchor = timestamp
88
+ if last_accessed is not None and last_accessed > anchor:
89
+ anchor = last_accessed
90
+ current = now or datetime.now(UTC)
91
+ age_days = max(0.0, (current - anchor).total_seconds() / 86400.0)
92
+ return math.exp(-math.log(2.0) * age_days / half_life)
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """TTL and decay policies for each MemoryType / MemoryTier."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from memkernel.core.models import MemoryTier, MemoryType, tier_of
10
+
11
+ if TYPE_CHECKING:
12
+ from memkernel.config import MemKernelSettings
13
+
14
+
15
+ def get_ttl_days(memory_type: MemoryType, settings: MemKernelSettings) -> float | None:
16
+ """Return the base TTL in days for a given MemoryType, or None for no expiry.
17
+
18
+ Long-term types never expire. Short-term and working types read their
19
+ base TTL from settings so it is configurable at runtime.
20
+ """
21
+ tier = tier_of(memory_type)
22
+ if tier == MemoryTier.LONG_TERM:
23
+ return None
24
+ if memory_type == MemoryType.EPISODE:
25
+ return float(settings.episode_ttl_days)
26
+ if memory_type == MemoryType.OBSERVATION:
27
+ return float(settings.observation_ttl_days)
28
+ if memory_type == MemoryType.TASK:
29
+ return settings.task_ttl_hours / 24.0
30
+ return None # pragma: no cover — every non-long-term type is handled above
31
+
32
+
33
+ def get_half_life_days(memory_type: MemoryType, settings: MemKernelSettings) -> float | None:
34
+ """Return the recency half-life (days) used for recall scoring, or None for no recency decay."""
35
+ tier = tier_of(memory_type)
36
+ if tier == MemoryTier.WORKING:
37
+ return settings.task_ttl_hours / 24.0 / 2.0
38
+ if tier == MemoryTier.SHORT_TERM:
39
+ return float(settings.recency_half_life_days)
40
+ return None
memkernel/py.typed ADDED
File without changes
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,244 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """AST-aware code chunker implementing ChunkerPort.
4
+
5
+ Splits code files at syntactic boundaries (functions, classes, methods) using
6
+ tree-sitter. Falls back to a sliding-window token chunker for unsupported
7
+ languages or when tree-sitter is unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from memkernel.config import MemKernelSettings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Language configuration
23
+ # ---------------------------------------------------------------------------
24
+
25
+ # Maps normalised language name -> tree-sitter language module import path.
26
+ # MVP: Python only. Add tree-sitter-javascript / tree-sitter-typescript deps
27
+ # and extend this dict when JS/TS support is needed.
28
+ _LANG_MODULE: dict[str, str] = {
29
+ "python": "tree_sitter_python",
30
+ }
31
+
32
+ # Boundary node types per language — AST node kinds at whose boundaries we
33
+ # split. Nodes within chunk_size are emitted as chunks; oversized nodes are
34
+ # recursed into to find inner boundaries.
35
+ _BOUNDARY_NODES: dict[str, frozenset[str]] = {
36
+ "python": frozenset({"function_definition", "class_definition", "decorated_definition"}),
37
+ }
38
+
39
+ # Maps file extensions to normalised language names.
40
+ # Only extensions with a corresponding entry in _LANG_MODULE produce AST chunks.
41
+ _EXT_TO_LANG: dict[str, str] = {
42
+ ".py": "python",
43
+ }
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Token-based fallback (pure function, no tree-sitter dependency)
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _inner_boundary_nodes(node: Any, boundary_types: frozenset[str]) -> list[Any]:
52
+ """Return all boundary-type descendants of *node*, stopping at each one found.
53
+
54
+ The search is depth-first but does not descend into a node once that node
55
+ itself is a boundary — its own inner structure is handled by the recursive
56
+ _walk call that will be made for it later.
57
+ """
58
+ result: list[Any] = []
59
+ for child in node.children:
60
+ if child.type in boundary_types:
61
+ result.append(child)
62
+ else:
63
+ result.extend(_inner_boundary_nodes(child, boundary_types))
64
+ return result
65
+
66
+
67
+ def _token_chunks(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
68
+ """Sliding-window character chunker.
69
+
70
+ Returns ``[text]`` when ``len(text) <= chunk_size``.
71
+ Returns ``[]`` for empty input.
72
+ Each chunk is at most ``chunk_size`` characters.
73
+ Consecutive chunks share ``chunk_overlap`` characters at their boundary.
74
+ """
75
+ if not text:
76
+ return []
77
+ if len(text) <= chunk_size:
78
+ return [text]
79
+
80
+ chunks: list[str] = []
81
+ step = max(1, chunk_size - chunk_overlap)
82
+ start = 0
83
+ while start < len(text):
84
+ end = start + chunk_size
85
+ chunks.append(text[start:end])
86
+ start += step
87
+ return chunks
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # ASTChunker
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class ASTChunker:
96
+ """Chunk code files at AST syntactic boundaries with a token fallback.
97
+
98
+ Implements ``ChunkerPort``.
99
+ Language is auto-detected from ``metadata["source"]`` file extension.
100
+ """
101
+
102
+ def __init__(self, settings: MemKernelSettings) -> None:
103
+ self._settings = settings
104
+ # Lazy cache: language name -> tree-sitter Parser | None
105
+ self._parsers: dict[str, Any] = {}
106
+
107
+ # ------------------------------------------------------------------
108
+ # ChunkerPort
109
+ # ------------------------------------------------------------------
110
+
111
+ def chunk(self, content: str, metadata: dict[str, Any]) -> list[str]:
112
+ """Return a list of chunk strings for *content*.
113
+
114
+ Uses AST chunking when the source language is supported and
115
+ tree-sitter is available; otherwise falls back to token chunking.
116
+ """
117
+ if not content:
118
+ return []
119
+
120
+ source: str = metadata.get("source", "")
121
+ ext = Path(source).suffix.lower()
122
+ language = _EXT_TO_LANG.get(ext)
123
+
124
+ if language is None:
125
+ logger.debug("Unknown extension %r — using token chunker", ext)
126
+ return _token_chunks(content, self._settings.chunk_size, self._settings.chunk_overlap)
127
+
128
+ ast_result = self._ast_chunks(content, language)
129
+ if ast_result is not None:
130
+ return ast_result
131
+
132
+ logger.debug("AST chunking produced no splits for %r — using token chunker", source)
133
+ return _token_chunks(content, self._settings.chunk_size, self._settings.chunk_overlap)
134
+
135
+ # ------------------------------------------------------------------
136
+ # AST chunking internals
137
+ # ------------------------------------------------------------------
138
+
139
+ def _ast_chunks(self, content: str, language: str) -> list[str] | None:
140
+ """Attempt AST-based chunking. Returns None to signal fallback."""
141
+ parser = self._load_parser(language)
142
+ if parser is None:
143
+ return None
144
+
145
+ encoded = content.encode("utf-8")
146
+ try:
147
+ tree = parser.parse(encoded)
148
+ except Exception:
149
+ logger.debug("tree-sitter parse failed — falling back to token chunker", exc_info=True)
150
+ return None
151
+
152
+ boundary_types = _BOUNDARY_NODES.get(language, frozenset())
153
+ chunks: list[str] = []
154
+ self._walk(tree.root_node, encoded, boundary_types, chunks)
155
+
156
+ if not chunks:
157
+ return None
158
+ return chunks
159
+
160
+ def _walk(
161
+ self,
162
+ node: Any,
163
+ encoded: bytes,
164
+ boundary_types: frozenset[str],
165
+ chunks: list[str],
166
+ ) -> None:
167
+ """Depth-first walk; emit boundary nodes as chunks or recurse into them.
168
+
169
+ Uses the original UTF-8 byte sequence for slicing so that byte offsets
170
+ from tree-sitter nodes are always correct, even for non-ASCII source.
171
+ """
172
+ if node.type in boundary_types:
173
+ text = encoded[node.start_byte : node.end_byte].decode("utf-8")
174
+ if len(text) <= self._settings.chunk_size:
175
+ if text.strip():
176
+ chunks.append(text)
177
+ return
178
+ # Oversized boundary node: collect all inner boundary nodes at any
179
+ # depth, then interleave the gap bytes (preamble, docstrings, class
180
+ # attributes — anything between inner boundaries) with recursive
181
+ # boundary chunk emission. Gaps are token-chunked so no content is
182
+ # silently dropped regardless of the intermediate tree structure.
183
+ inner = sorted(
184
+ _inner_boundary_nodes(node, boundary_types),
185
+ key=lambda n: n.start_byte,
186
+ )
187
+ prev_end = node.start_byte
188
+ for inner_node in inner:
189
+ gap = encoded[prev_end : inner_node.start_byte].decode("utf-8")
190
+ if gap.strip():
191
+ for part in _token_chunks(gap, self._settings.chunk_size, self._settings.chunk_overlap):
192
+ if part.strip():
193
+ chunks.append(part)
194
+ self._walk(inner_node, encoded, boundary_types, chunks)
195
+ prev_end = inner_node.end_byte
196
+ # Trailing gap (anything after the last inner boundary)
197
+ gap = encoded[prev_end : node.end_byte].decode("utf-8")
198
+ if gap.strip():
199
+ for part in _token_chunks(gap, self._settings.chunk_size, self._settings.chunk_overlap):
200
+ if part.strip():
201
+ chunks.append(part)
202
+ return
203
+
204
+ for child in node.children:
205
+ self._walk(child, encoded, boundary_types, chunks)
206
+
207
+ # ------------------------------------------------------------------
208
+ # Parser loader
209
+ # ------------------------------------------------------------------
210
+
211
+ def _load_parser(self, language: str) -> Any | None:
212
+ """Lazy-load and cache a tree-sitter Parser for *language*.
213
+
214
+ Returns None on any import/init error so the caller can fall back
215
+ gracefully.
216
+ """
217
+ if language in self._parsers:
218
+ return self._parsers[language]
219
+
220
+ parser: Any | None = None
221
+ try:
222
+ import importlib
223
+
224
+ import tree_sitter
225
+
226
+ module_name = _LANG_MODULE[language]
227
+ lang_module = importlib.import_module(module_name)
228
+
229
+ # tree-sitter-python (and siblings) expose a ``language()``
230
+ # function that returns a Language object.
231
+ lang_obj = tree_sitter.Language(lang_module.language())
232
+ p = tree_sitter.Parser(lang_obj)
233
+ parser = p
234
+ logger.debug("Loaded tree-sitter parser for %r", language)
235
+ except (ImportError, OSError, ValueError, Exception):
236
+ logger.debug(
237
+ "Could not load tree-sitter parser for %r — will use token chunker",
238
+ language,
239
+ exc_info=True,
240
+ )
241
+ parser = None
242
+
243
+ self._parsers[language] = parser
244
+ return parser
@@ -0,0 +1,39 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Embedding adapter for MemKernel — sentence-transformers backend."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from memkernel.config import MemKernelSettings
11
+
12
+
13
+ class SentenceTransformerEmbedding:
14
+ """EmbeddingPort implementation backed by sentence-transformers."""
15
+
16
+ def __init__(self, settings: MemKernelSettings) -> None:
17
+ # Lazy import: sentence_transformers is heavy; defer until first use.
18
+ from sentence_transformers import SentenceTransformer
19
+
20
+ self._model = SentenceTransformer(settings.embedding_model)
21
+ raw_dim = self._model.get_sentence_embedding_dimension()
22
+ if raw_dim is None:
23
+ raise ValueError(
24
+ f"Model '{settings.embedding_model}' returned None for embedding dimension. "
25
+ "Ensure the model has a known output dimension."
26
+ )
27
+ self._dimension: int = raw_dim
28
+
29
+ def embed(self, texts: list[str]) -> list[list[float]]:
30
+ """Return one embedding vector per input text."""
31
+ import numpy as np
32
+
33
+ raw = self._model.encode(texts, convert_to_numpy=True)
34
+ return [row.tolist() for row in np.atleast_2d(raw)]
35
+
36
+ @property
37
+ def dimension(self) -> int:
38
+ """Dimensionality of the embedding vectors."""
39
+ return self._dimension