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,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,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,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
|