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
memkernel/__init__.py
ADDED
memkernel/api/app.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""FastAPI admin API factory and entry point.
|
|
4
|
+
|
|
5
|
+
Production:
|
|
6
|
+
uvicorn memkernel.api.app:app --reload
|
|
7
|
+
|
|
8
|
+
Tests inject a pre-built MemoryService via create_app(memory_service=svc).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import AsyncIterator
|
|
21
|
+
|
|
22
|
+
from memkernel.core.services import MemoryService
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app(memory_service: MemoryService | None = None) -> FastAPI:
|
|
28
|
+
"""FastAPI app factory.
|
|
29
|
+
|
|
30
|
+
Tests pass a pre-built MemoryService (with EphemeralClient, MockEmbedding).
|
|
31
|
+
Production calls with None — MemoryService is built from env at startup.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@asynccontextmanager
|
|
35
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
36
|
+
if memory_service is not None:
|
|
37
|
+
app.state.memory_service = memory_service
|
|
38
|
+
else:
|
|
39
|
+
app.state.memory_service = _build_service()
|
|
40
|
+
logger.info("MemoryService initialised for project %s", app.state.memory_service.project_id)
|
|
41
|
+
yield
|
|
42
|
+
|
|
43
|
+
application: FastAPI = FastAPI(
|
|
44
|
+
title="MemKernel Admin API",
|
|
45
|
+
version="0.1.0",
|
|
46
|
+
lifespan=lifespan,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from memkernel.api.routes.admin import router
|
|
50
|
+
|
|
51
|
+
application.include_router(router)
|
|
52
|
+
return application
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_service() -> MemoryService:
|
|
56
|
+
"""Construct MemoryService from environment settings (production only)."""
|
|
57
|
+
from memkernel.factory import build_service
|
|
58
|
+
|
|
59
|
+
return build_service()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Module-level app for `uvicorn memkernel.api.app:app`
|
|
63
|
+
# MemoryService is built lazily in the lifespan — safe to import without real deps.
|
|
64
|
+
app = create_app()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Admin API route handlers: /health, /stats, /collections."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Request
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from memkernel.core.services import MemoryService
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _svc(request: Request) -> MemoryService:
|
|
18
|
+
return request.app.state.memory_service # type: ignore[no-any-return]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/health")
|
|
22
|
+
async def health(request: Request) -> dict[str, Any]:
|
|
23
|
+
"""Server liveness check: status, collection count, memory count."""
|
|
24
|
+
svc = _svc(request)
|
|
25
|
+
return {
|
|
26
|
+
"status": "ok",
|
|
27
|
+
"project_id": svc.project_id,
|
|
28
|
+
"collection_count": svc.collection_count(),
|
|
29
|
+
"memory_count": svc.count(),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/stats")
|
|
34
|
+
async def stats(request: Request) -> dict[str, Any]:
|
|
35
|
+
"""Memory metrics: per-type breakdown, growth by time window, last recall latency."""
|
|
36
|
+
svc = _svc(request)
|
|
37
|
+
return svc.stats()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/collections")
|
|
41
|
+
async def collections(request: Request) -> list[dict[str, Any]]:
|
|
42
|
+
"""List all project collections with record counts."""
|
|
43
|
+
svc = _svc(request)
|
|
44
|
+
return svc.list_collections()
|
memkernel/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""MemKernel configuration via environment variables."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemKernelSettings(BaseSettings):
|
|
13
|
+
"""All settings are configurable via MEMKERNEL_ prefixed env vars."""
|
|
14
|
+
|
|
15
|
+
model_config = SettingsConfigDict(env_prefix="MEMKERNEL_", env_file=".env")
|
|
16
|
+
|
|
17
|
+
project_id: str = "default"
|
|
18
|
+
default_session_id: str = "default"
|
|
19
|
+
|
|
20
|
+
# ChromaDB
|
|
21
|
+
chroma_persist_path: str = ".memkernel/chroma"
|
|
22
|
+
chroma_collection_prefix: str = "memkernel"
|
|
23
|
+
|
|
24
|
+
# Embedding
|
|
25
|
+
embedding_model: str = "all-MiniLM-L6-v2"
|
|
26
|
+
embedding_dimension: int = 384
|
|
27
|
+
|
|
28
|
+
# Retrieval
|
|
29
|
+
search_top_k: int = 5
|
|
30
|
+
rrf_k: int = 60
|
|
31
|
+
preview_length: int = 200
|
|
32
|
+
|
|
33
|
+
# Chunking
|
|
34
|
+
chunk_size: int = 1500 # max chars per chunk
|
|
35
|
+
chunk_overlap: int = 200 # char overlap between token chunks (fallback only)
|
|
36
|
+
|
|
37
|
+
# Lifecycle / decay (see docs/architecture/memory-tiers.md)
|
|
38
|
+
episode_ttl_days: int = 30 # base TTL for short-term `episode` memories
|
|
39
|
+
observation_ttl_days: int = 7 # base TTL for short-term `observation` memories
|
|
40
|
+
task_ttl_hours: int = 24 # base TTL for working-memory `task` records
|
|
41
|
+
access_boost: float = 0.5 # each recall extends TTL by this fraction of the base TTL
|
|
42
|
+
recency_half_life_days: float = 7.0 # recency decay half-life for short-term memories
|
|
43
|
+
|
|
44
|
+
# Recall scoring: final = w_relevance * fused + w_recency * recency + w_importance * importance
|
|
45
|
+
recall_weight_relevance: float = 1.0
|
|
46
|
+
recall_weight_recency: float = 0.3
|
|
47
|
+
recall_weight_importance: float = 0.3
|
|
48
|
+
|
|
49
|
+
# LLM wiki bridge: directory holding the markdown wiki (index.md, decisions/, ...).
|
|
50
|
+
# Empty = no wiki configured; wiki_export/wiki_import then require an explicit path.
|
|
51
|
+
wiki_dir: str = ""
|
|
52
|
+
|
|
53
|
+
# Server
|
|
54
|
+
transport: Literal["stdio", "streamable-http"] = "stdio"
|
|
55
|
+
host: str = "127.0.0.1"
|
|
56
|
+
port: int = 8765
|
memkernel/core/models.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Domain models for MemKernel.
|
|
4
|
+
|
|
5
|
+
Memory is organised in three tiers (see docs/architecture/memory-tiers.md):
|
|
6
|
+
|
|
7
|
+
* ``working`` — the agent's scratchpad for the current task. Session-scoped,
|
|
8
|
+
expires within hours. Cognitive analogue: working memory.
|
|
9
|
+
* ``short_term`` — episodic memory: what happened, what was observed. Subject
|
|
10
|
+
to decay; frequently recalled items survive longer.
|
|
11
|
+
* ``long_term`` — semantic and procedural memory: decisions, conventions,
|
|
12
|
+
facts, procedures, code understanding. Never expires; curated
|
|
13
|
+
through consolidation and (optionally) an LLM wiki.
|
|
14
|
+
|
|
15
|
+
Each :class:`MemoryType` belongs to exactly one tier (``TYPE_TIER``), so callers
|
|
16
|
+
only choose a type; the tier, default importance and decay policy follow.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MemoryTier(StrEnum):
|
|
28
|
+
"""Lifecycle tier of a memory. Drives decay policy and recall weighting."""
|
|
29
|
+
|
|
30
|
+
WORKING = "working"
|
|
31
|
+
SHORT_TERM = "short_term"
|
|
32
|
+
LONG_TERM = "long_term"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryType(StrEnum):
|
|
36
|
+
"""Classification of stored memories. Determines tier, importance and TTL policy."""
|
|
37
|
+
|
|
38
|
+
# Long-term, semantic
|
|
39
|
+
DECISION = "decision"
|
|
40
|
+
CONVENTION = "convention"
|
|
41
|
+
FACT = "fact"
|
|
42
|
+
CODE = "code"
|
|
43
|
+
# Long-term, procedural
|
|
44
|
+
PROCEDURE = "procedure"
|
|
45
|
+
# Short-term, episodic
|
|
46
|
+
EPISODE = "episode"
|
|
47
|
+
OBSERVATION = "observation"
|
|
48
|
+
# Working memory
|
|
49
|
+
TASK = "task"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TYPE_TIER: dict[MemoryType, MemoryTier] = {
|
|
53
|
+
MemoryType.DECISION: MemoryTier.LONG_TERM,
|
|
54
|
+
MemoryType.CONVENTION: MemoryTier.LONG_TERM,
|
|
55
|
+
MemoryType.FACT: MemoryTier.LONG_TERM,
|
|
56
|
+
MemoryType.CODE: MemoryTier.LONG_TERM,
|
|
57
|
+
MemoryType.PROCEDURE: MemoryTier.LONG_TERM,
|
|
58
|
+
MemoryType.EPISODE: MemoryTier.SHORT_TERM,
|
|
59
|
+
MemoryType.OBSERVATION: MemoryTier.SHORT_TERM,
|
|
60
|
+
MemoryType.TASK: MemoryTier.WORKING,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Default importance (0..1) when the caller does not supply one.
|
|
64
|
+
# Mirrors the "importance" axis of Generative Agents (Park et al., 2023):
|
|
65
|
+
# durable, high-consequence knowledge scores high; raw observations score low.
|
|
66
|
+
DEFAULT_IMPORTANCE: dict[MemoryType, float] = {
|
|
67
|
+
MemoryType.DECISION: 0.9,
|
|
68
|
+
MemoryType.CONVENTION: 0.9,
|
|
69
|
+
MemoryType.PROCEDURE: 0.8,
|
|
70
|
+
MemoryType.FACT: 0.7,
|
|
71
|
+
MemoryType.CODE: 0.6,
|
|
72
|
+
MemoryType.TASK: 0.5,
|
|
73
|
+
MemoryType.EPISODE: 0.4,
|
|
74
|
+
MemoryType.OBSERVATION: 0.3,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tier_of(memory_type: MemoryType) -> MemoryTier:
|
|
79
|
+
"""Return the tier a memory type belongs to."""
|
|
80
|
+
return TYPE_TIER[memory_type]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Memory(BaseModel):
|
|
84
|
+
"""A stored unit of knowledge with full content and metadata."""
|
|
85
|
+
|
|
86
|
+
ref_id: str
|
|
87
|
+
content: str
|
|
88
|
+
type: MemoryType
|
|
89
|
+
project_id: str
|
|
90
|
+
session_id: str
|
|
91
|
+
timestamp: datetime
|
|
92
|
+
source: str = ""
|
|
93
|
+
content_hash: str
|
|
94
|
+
token_count: int
|
|
95
|
+
checkpoint: bool = False
|
|
96
|
+
chunk_refs: list[str] = []
|
|
97
|
+
# Tiered-memory metadata (all optional for backward compatibility with
|
|
98
|
+
# records written by memkernel 0.1.x, which carry none of these).
|
|
99
|
+
tier: MemoryTier = MemoryTier.LONG_TERM
|
|
100
|
+
importance: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
101
|
+
access_count: int = 0
|
|
102
|
+
last_accessed: datetime | None = None
|
|
103
|
+
tags: list[str] = []
|
|
104
|
+
supersedes: str | None = None
|
|
105
|
+
promoted_from: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ArtifactRef(BaseModel):
|
|
109
|
+
"""Lightweight reference returned by recall — no full content.
|
|
110
|
+
|
|
111
|
+
Matches spec FR-003: ref_id, preview, score, token_count, type, timestamp.
|
|
112
|
+
No checkpoint field — checkpoint filtering is query-side via checkpoint_only param.
|
|
113
|
+
``tier`` and ``importance`` are included so the caller can see why a result
|
|
114
|
+
ranked where it did without a second round trip.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
ref_id: str
|
|
118
|
+
preview: str
|
|
119
|
+
score: float
|
|
120
|
+
token_count: int
|
|
121
|
+
type: MemoryType
|
|
122
|
+
timestamp: datetime
|
|
123
|
+
tier: MemoryTier = MemoryTier.LONG_TERM
|
|
124
|
+
importance: float = 0.5
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SearchResult(BaseModel):
|
|
128
|
+
"""Internal pre-fusion search result from a single source."""
|
|
129
|
+
|
|
130
|
+
ref_id: str
|
|
131
|
+
score: float
|
|
132
|
+
rank: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SearchQuery(BaseModel):
|
|
136
|
+
"""Internal query representation for HybridSearchService."""
|
|
137
|
+
|
|
138
|
+
query_text: str
|
|
139
|
+
type_filter: MemoryType | None = None
|
|
140
|
+
tier_filter: MemoryTier | None = None
|
|
141
|
+
session_id_filter: str | None = None
|
|
142
|
+
checkpoint_only: bool = False
|
|
143
|
+
top_k: int = 5
|
memkernel/core/ports.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Protocol definitions for MemKernel adapters.
|
|
4
|
+
|
|
5
|
+
Domain logic in core/ depends ONLY on these protocols.
|
|
6
|
+
Concrete implementations live in storage/, retrieval/, etc.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from memkernel.core.models import Memory, SearchResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class VectorStorePort(Protocol):
|
|
19
|
+
"""Dense vector store backend (ChromaDB for MVP)."""
|
|
20
|
+
|
|
21
|
+
def upsert(
|
|
22
|
+
self,
|
|
23
|
+
ref_id: str,
|
|
24
|
+
content: str,
|
|
25
|
+
embedding: list[float],
|
|
26
|
+
metadata: dict[str, Any],
|
|
27
|
+
) -> None: ...
|
|
28
|
+
|
|
29
|
+
def search_dense(
|
|
30
|
+
self,
|
|
31
|
+
query_embedding: list[float],
|
|
32
|
+
top_k: int,
|
|
33
|
+
where: dict[str, Any] | None = None,
|
|
34
|
+
) -> list[SearchResult]: ...
|
|
35
|
+
|
|
36
|
+
def get_by_id(self, ref_id: str) -> Memory | None: ...
|
|
37
|
+
|
|
38
|
+
def delete(self, ref_id: str) -> bool: ...
|
|
39
|
+
|
|
40
|
+
def list_all(self, where: dict[str, Any] | None = None) -> list[Memory]: ...
|
|
41
|
+
|
|
42
|
+
def find_by_hash(self, content_hash: str, memory_type: str) -> Memory | None:
|
|
43
|
+
"""Find by content_hash + type (dedup key per ADR-5)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def update_metadata(self, ref_id: str, metadata: dict[str, Any]) -> None:
|
|
47
|
+
"""Fetch-and-merge: only overwrites caller-specified keys."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def count(self) -> int: ...
|
|
51
|
+
|
|
52
|
+
def list_collections(self) -> list[dict[str, Any]]:
|
|
53
|
+
"""List all collections visible to this adapter with their record counts."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@runtime_checkable
|
|
58
|
+
class EmbeddingPort(Protocol):
|
|
59
|
+
"""Text embedding model."""
|
|
60
|
+
|
|
61
|
+
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dimension(self) -> int: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class SparseIndexPort(Protocol):
|
|
69
|
+
"""BM25 sparse search index."""
|
|
70
|
+
|
|
71
|
+
def add(self, ref_id: str, content: str) -> None: ...
|
|
72
|
+
|
|
73
|
+
def remove(self, ref_id: str) -> None: ...
|
|
74
|
+
|
|
75
|
+
def search(self, query: str, top_k: int) -> list[SearchResult]: ...
|
|
76
|
+
|
|
77
|
+
def search_all(self, query: str) -> list[SearchResult]:
|
|
78
|
+
"""Score entire corpus, return all results with score > 0."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def rebuild(self, documents: list[tuple[str, str]]) -> None: ...
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def needs_rebuild(self) -> bool: ...
|
|
85
|
+
|
|
86
|
+
def mark_needs_rebuild(self) -> None: ...
|
|
87
|
+
|
|
88
|
+
def __len__(self) -> int: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@runtime_checkable
|
|
92
|
+
class ChunkerPort(Protocol):
|
|
93
|
+
"""Code/text chunking strategy (stub for future US3)."""
|
|
94
|
+
|
|
95
|
+
def chunk(self, content: str, metadata: dict[str, Any]) -> list[str]: ...
|