candlekeep 0.1.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.
candlekeep/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Candlekeep: RAG knowledge base server."""
2
+ import sys
3
+
4
+ # Check Python version
5
+ if sys.version_info < (3, 10):
6
+ raise RuntimeError(
7
+ f"Candlekeep requires Python 3.10+, found {sys.version_info.major}.{sys.version_info.minor}"
8
+ )
9
+
10
+ # Check SQLite support (ChromaDB dependency)
11
+ try:
12
+ import sqlite3
13
+ except ImportError:
14
+ # Try pysqlite3-binary as fallback
15
+ try:
16
+ import pysqlite3 as sqlite3
17
+ sys.modules['sqlite3'] = sqlite3
18
+ except ImportError:
19
+ raise RuntimeError(
20
+ "Candlekeep requires Python with SQLite support (ChromaDB dependency)."
21
+ )
22
+
23
+ # ChromaDB requires SQLite >= 3.35.0
24
+ if sqlite3.sqlite_version_info < (3, 35, 0):
25
+ try:
26
+ __import__("pysqlite3")
27
+ sys.modules["sqlite3"] = sys.modules.pop("pysqlite3")
28
+ except ImportError:
29
+ raise RuntimeError(
30
+ f"ChromaDB requires SQLite >= 3.35.0, found {sqlite3.sqlite_version}\n"
31
+ "Fix: pip install pysqlite3-binary"
32
+ )
33
+
34
+ from candlekeep.mcp.server import mcp, main
35
+
36
+ __all__ = ["mcp", "main"]
candlekeep/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for candlekeep command."""
2
+ from candlekeep.mcp.server import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
candlekeep/config.py ADDED
@@ -0,0 +1,150 @@
1
+ """Configuration for candlekeep with ChromaDB authentication."""
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Literal
6
+ from urllib.parse import urlparse
7
+
8
+ EmbeddingModel = Literal["minilm", "bge-small", "nomic"]
9
+ DeviceType = Literal["auto", "cpu", "mps", "cuda"]
10
+
11
+ EMBEDDING_MODELS = {
12
+ "minilm": "sentence-transformers/all-MiniLM-L6-v2",
13
+ "bge-small": "BAAI/bge-small-en-v1.5",
14
+ "nomic": "nomic-ai/nomic-embed-text-v1.5",
15
+ }
16
+
17
+
18
+ def detect_device() -> str:
19
+ """Detect best available compute device."""
20
+ import torch
21
+ if torch.cuda.is_available():
22
+ return "cuda"
23
+ if torch.backends.mps.is_available():
24
+ return "mps"
25
+ return "cpu"
26
+
27
+
28
+ def load_dotenv(env_file: Path | None = None) -> None:
29
+ """Load .env file into environment variables."""
30
+ if env_file is None:
31
+ for candidate in [Path(".env"), Path(__file__).parent.parent.parent / ".env"]:
32
+ if candidate.exists():
33
+ env_file = candidate
34
+ break
35
+ if env_file is None or not env_file.exists():
36
+ return
37
+
38
+ for line in env_file.read_text().splitlines():
39
+ line = line.strip()
40
+ if line and not line.startswith("#") and "=" in line:
41
+ if line.startswith("export "):
42
+ line = line[7:]
43
+ key, _, value = line.partition("=")
44
+ key, value = key.strip(), value.strip().strip('"').strip("'")
45
+ if key and key not in os.environ:
46
+ os.environ[key] = value
47
+
48
+
49
+ load_dotenv()
50
+
51
+
52
+ def get_data_dir() -> Path:
53
+ """Get cross-platform data directory."""
54
+ if os.name == "nt":
55
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
56
+ elif os.name == "posix" and "darwin" in os.uname().sysname.lower():
57
+ base = Path.home() / "Library" / "Application Support"
58
+ else:
59
+ base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
60
+ return base / "candlekeep"
61
+
62
+
63
+ @dataclass
64
+ class Settings:
65
+ """Candlekeep configuration settings."""
66
+
67
+ # ChromaDB connection
68
+ chroma_url: str = field(default_factory=lambda: os.getenv("CHROMA_URL", "http://localhost:8000"))
69
+ chroma_auth_token: str = field(default_factory=lambda: os.getenv("CHROMA_AUTH_TOKEN", ""))
70
+
71
+ # Embedding settings
72
+ embedding_model: EmbeddingModel = field(default_factory=lambda: os.getenv("CANDLEKEEP_EMBEDDING", "bge-small"))
73
+ embedding_cache_size: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_EMBEDDING_CACHE_SIZE", "500")))
74
+
75
+ # Inference device
76
+ device: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_DEVICE", "auto"))
77
+
78
+ # Document processing
79
+ chunk_size: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_CHUNK_SIZE", "512")))
80
+ chunk_overlap: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_CHUNK_OVERLAP", "50")))
81
+
82
+ # Personality
83
+ spice: bool = field(default_factory=lambda: os.getenv("CANDLEKEEP_SPICE", "false").lower() == "true")
84
+
85
+ # Structural Integrity (Bardic Knowledge)
86
+ bardic_knowledge: bool = field(default_factory=lambda: os.getenv("CANDLEKEEP_BARDIC_KNOWLEDGE", "true").lower() == "true")
87
+
88
+ # Sparse backend for hybrid path (bm25 or colbert)
89
+ sparse_backend: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_SPARSE_BACKEND", "bm25"))
90
+
91
+ # Transport settings (multi-agent HTTP mode)
92
+ transport: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_TRANSPORT", "stdio"))
93
+ http_host: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_HTTP_HOST", "127.0.0.1"))
94
+ http_port: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_HTTP_PORT", "8111")))
95
+ mcp_token: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_MCP_TOKEN", ""))
96
+
97
+ # Rate limiting (HTTP mode only, per MCP session)
98
+ rate_limit_search: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_SEARCH", "30")))
99
+ rate_limit_write: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_WRITE", "5")))
100
+ rate_limit_window: int = field(default_factory=lambda: int(os.getenv("CANDLEKEEP_RATE_LIMIT_WINDOW", "60")))
101
+
102
+ # LLM / Vision provider selection (anthropic, openai, bedrock, openai_compat)
103
+ llm_provider: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_LLM_PROVIDER", ""))
104
+ vlm_provider: str = field(default_factory=lambda: os.getenv("CANDLEKEEP_VLM_PROVIDER", ""))
105
+
106
+ # Data directory
107
+ data_dir: Path = field(default_factory=get_data_dir)
108
+
109
+ def __post_init__(self):
110
+ if self.device == "auto":
111
+ self.device = detect_device()
112
+ self.data_dir = Path(self.data_dir)
113
+ self.data_dir.mkdir(parents=True, exist_ok=True)
114
+ (self.data_dir / "models").mkdir(exist_ok=True)
115
+ (self.data_dir / "chroma").mkdir(exist_ok=True)
116
+
117
+ @classmethod
118
+ def from_env(cls) -> "Settings":
119
+ """Create settings from environment variables."""
120
+ return cls()
121
+
122
+ @property
123
+ def chroma_host(self) -> str:
124
+ """Extract host from CHROMA_URL."""
125
+ parsed = urlparse(self.chroma_url)
126
+ return parsed.hostname or "localhost"
127
+
128
+ @property
129
+ def chroma_port(self) -> int:
130
+ """Extract port from CHROMA_URL."""
131
+ parsed = urlparse(self.chroma_url)
132
+ return parsed.port or 8000
133
+
134
+ @property
135
+ def chroma_ssl(self) -> bool:
136
+ """Check if HTTPS is used."""
137
+ parsed = urlparse(self.chroma_url)
138
+ return parsed.scheme == "https"
139
+
140
+ @property
141
+ def models_dir(self) -> Path:
142
+ return self.data_dir / "models"
143
+
144
+ @property
145
+ def chroma_dir(self) -> Path:
146
+ return self.data_dir / "chroma"
147
+
148
+ @property
149
+ def embedding_model_name(self) -> str:
150
+ return EMBEDDING_MODELS[self.embedding_model]
@@ -0,0 +1 @@
1
+ """Database layer for candlekeep."""
@@ -0,0 +1,115 @@
1
+ """Embedding manager with model caching and selection."""
2
+ import threading
3
+ from collections import OrderedDict
4
+ from sentence_transformers import SentenceTransformer
5
+ from candlekeep.config import Settings, EMBEDDING_MODELS, EmbeddingModel
6
+
7
+
8
+ class EmbeddingManager:
9
+ _instance: "EmbeddingManager | None" = None
10
+ _model: SentenceTransformer | None = None
11
+ _current_model: str | None = None
12
+
13
+ def __init__(self, settings: Settings | None = None):
14
+ self.settings = settings or Settings.from_env()
15
+ self._cache = OrderedDict()
16
+ self._cache_lock = threading.Lock()
17
+ self._hits = 0
18
+ self._misses = 0
19
+ self._max_cache_size = self.settings.embedding_cache_size
20
+
21
+
22
+ @classmethod
23
+ def get_instance(cls, settings: Settings | None = None) -> "EmbeddingManager":
24
+ if cls._instance is None:
25
+ cls._instance = cls(settings)
26
+ return cls._instance
27
+
28
+ def get_model(self, model_name: EmbeddingModel | None = None) -> SentenceTransformer:
29
+ """Load model from local cache. Exits if not found locally."""
30
+ model_name = model_name or self.settings.embedding_model
31
+ model_id = EMBEDDING_MODELS[model_name]
32
+
33
+ if self._model is not None and self._current_model == model_id:
34
+ return self._model
35
+
36
+ cache_dir = self.settings.models_dir
37
+ # Check if model exists locally before loading
38
+ if not any(cache_dir.glob(f"models--{model_id.replace('/', '--')}*")):
39
+ import sys
40
+ print(f"[candlekeep] ❌ Model '{model_id}' not found locally in {cache_dir}. "
41
+ f"Run ./scripts/setup.sh to download models.", file=sys.stderr)
42
+ sys.exit(1)
43
+
44
+ trust_remote_code = model_name == "nomic"
45
+ self._model = SentenceTransformer(
46
+ model_id,
47
+ cache_folder=str(cache_dir),
48
+ trust_remote_code=trust_remote_code,
49
+ local_files_only=True,
50
+ device=self.settings.device,
51
+ )
52
+
53
+ # Promote to float64 on CPU for better determinism and to match reranker precision
54
+ if self.settings.device == "cpu":
55
+ try:
56
+ import torch
57
+ self._model.to(torch.float64)
58
+ except Exception as e:
59
+ # Fallback to float32 if conversion fails (e.g. meta tensors in CI)
60
+ import sys
61
+ print(f"[candlekeep] ⚠ Could not promote embedding model to float64: {e}. "
62
+ f"Continuing with float32.", file=sys.stderr)
63
+
64
+ self._current_model = model_id
65
+ return self._model
66
+
67
+ def embed(self, texts: list[str] | str, model_name: EmbeddingModel | None = None, is_query: bool = False) -> list[list[float]]:
68
+ """Generate embeddings for texts."""
69
+ model_name = model_name or self.settings.embedding_model
70
+ model = self.get_model(model_name)
71
+ if isinstance(texts, str):
72
+ texts = [texts]
73
+
74
+ # Nomic requires prefixes
75
+ if model_name == "nomic":
76
+ prefix = "search_query: " if is_query else "search_document: "
77
+ texts = [prefix + t for t in texts]
78
+
79
+ embeddings = model.encode(texts, convert_to_numpy=True)
80
+ return embeddings.tolist()
81
+
82
+ def embed_query(self, query: str, model_name: EmbeddingModel | None = None) -> list[float]:
83
+ """Generate embedding for a single query with LRU cache."""
84
+ model_name = model_name or self.settings.embedding_model
85
+
86
+ if self._max_cache_size == 0:
87
+ return self.embed([query], model_name, is_query=True)[0]
88
+
89
+ cache_key = (query, model_name)
90
+ with self._cache_lock:
91
+ if cache_key in self._cache:
92
+ self._hits += 1
93
+ self._cache.move_to_end(cache_key)
94
+ return self._cache[cache_key]
95
+
96
+ # Cache miss - compute embedding
97
+ embedding = self.embed([query], model_name, is_query=True)[0]
98
+
99
+ with self._cache_lock:
100
+ self._misses += 1
101
+ self._cache[cache_key] = embedding
102
+ self._cache.move_to_end(cache_key)
103
+ if len(self._cache) > self._max_cache_size:
104
+ self._cache.popitem(last=False)
105
+
106
+ return embedding
107
+
108
+ def get_cache_stats(self) -> dict:
109
+ """Get embedding cache hit/miss statistics."""
110
+ with self._cache_lock:
111
+ return {
112
+ "embedding_cache_hits": self._hits,
113
+ "embedding_cache_misses": self._misses,
114
+ "embedding_cache_size": len(self._cache)
115
+ }
@@ -0,0 +1,86 @@
1
+ """Abstract database interface for candlekeep."""
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass
4
+ from typing import List, Dict, Any
5
+
6
+
7
+ @dataclass
8
+ class SearchResult:
9
+ """Search result from vector database."""
10
+ text: str
11
+ metadata: Dict[str, Any]
12
+ score: float
13
+ doc_id: str
14
+
15
+
16
+ @dataclass
17
+ class Chunk:
18
+ """Document chunk for indexing."""
19
+ text: str
20
+ metadata: Dict[str, Any]
21
+ chunk_index: int
22
+
23
+
24
+ class VectorDatabase(ABC):
25
+ """Abstract interface for vector database operations."""
26
+
27
+ @abstractmethod
28
+ def search(self, query: str, n_results: int = 5, category: str | None = None) -> List[SearchResult]:
29
+ """Search for similar documents."""
30
+ pass
31
+
32
+ @abstractmethod
33
+ def add_documents(self, chunks: List[Chunk], collection: str = "default") -> int:
34
+ """Add document chunks to database."""
35
+ pass
36
+
37
+ @abstractmethod
38
+ def delete_by_source(self, source: str) -> int:
39
+ """Delete all chunks from a source."""
40
+ pass
41
+
42
+ @abstractmethod
43
+ def clear(self) -> None:
44
+ """Clear all documents from database."""
45
+ pass
46
+
47
+ @abstractmethod
48
+ def get_stats(self) -> Dict[str, Any]:
49
+ """Get database statistics."""
50
+ pass
51
+
52
+ @abstractmethod
53
+ def list_documents(self) -> List[Dict[str, Any]]:
54
+ """List all indexed documents."""
55
+ pass
56
+
57
+ @abstractmethod
58
+ def get_chunks_by_source(self, source: str) -> List[SearchResult]:
59
+ """Get all chunks from a specific source document."""
60
+ pass
61
+
62
+ @abstractmethod
63
+ def get_all_chunks(self) -> List[SearchResult]:
64
+ """Get all chunks from the database."""
65
+ pass
66
+
67
+ @abstractmethod
68
+ def get_embeddings(self, texts: List[str]) -> List[List[float]]:
69
+ """Get embeddings for a list of texts."""
70
+ pass
71
+
72
+ @abstractmethod
73
+ def embed_query(self, query: str) -> List[float]:
74
+ """Get cached embedding for a single query."""
75
+ pass
76
+
77
+ @abstractmethod
78
+ def get_stored_embeddings_by_source(self, source: str) -> Dict[int, List[float]]:
79
+
80
+ """Get stored embeddings for all chunks from a source document.
81
+
82
+ Returns a dict mapping chunk_index to the embedding vector that was
83
+ computed at ingestion time. This avoids re-computing embeddings at
84
+ query time for similarity-weighted expansion.
85
+ """
86
+ pass