docsif 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.
Files changed (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. sif/utils/text.py +128 -0
sif/config/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Configuration management for SIF.
2
+
3
+ This package provides configuration handling:
4
+ - Settings management via Pydantic Settings
5
+ - Environment variable support
6
+ - Configuration file handling
7
+ - Default values and validation
8
+ """
9
+
10
+ from sif.config.constants import (
11
+ APP_NAME,
12
+ APP_VERSION,
13
+ DEFAULT_CHUNK_OVERLAP,
14
+ DEFAULT_CHUNK_SIZE,
15
+ DEFAULT_DB_PATH,
16
+ DEFAULT_MODEL_PATH,
17
+ )
18
+ from sif.config.settings import Settings, get_settings
19
+
20
+
21
+ __all__ = [
22
+ "APP_NAME",
23
+ "APP_VERSION",
24
+ "DEFAULT_CHUNK_OVERLAP",
25
+ "DEFAULT_CHUNK_SIZE",
26
+ "DEFAULT_DB_PATH",
27
+ "DEFAULT_MODEL_PATH",
28
+ "Settings",
29
+ "get_settings",
30
+ ]
@@ -0,0 +1,52 @@
1
+ """Constants for SIF."""
2
+
3
+ # Application info
4
+ APP_NAME = "sif"
5
+ APP_VERSION = "0.1.0"
6
+ APP_DESCRIPTION = (
7
+ "A local CLI search engine for indexing and searching documents (SIF: Search / Index / Find)"
8
+ )
9
+
10
+ # Default paths
11
+ DEFAULT_DB_PATH = "~/.local/share/sif/sif.db"
12
+ DEFAULT_MODEL_PATH = "~/.local/share/sif/models"
13
+ DEFAULT_CONFIG_PATH = "~/.config/sif"
14
+
15
+ # Chunking defaults
16
+ DEFAULT_CHUNK_SIZE = 512
17
+ DEFAULT_CHUNK_OVERLAP = 128
18
+ MIN_CHUNK_SIZE = 100
19
+ MAX_CHUNK_SIZE = 2048
20
+
21
+ # Search defaults
22
+ DEFAULT_SEARCH_LIMIT = 10
23
+ MAX_SEARCH_LIMIT = 100
24
+ DEFAULT_BM25_K1 = 1.5
25
+ DEFAULT_BM25_B = 0.75
26
+ DEFAULT_VECTOR_WEIGHT = 0.7
27
+ DEFAULT_RRF_K = 60
28
+
29
+ # Embedding defaults
30
+ DEFAULT_EMBEDDING_DIM = 1024
31
+ DEFAULT_MAX_TOKENS = 512
32
+ DEFAULT_BATCH_SIZE = 32
33
+
34
+ # MCP server defaults
35
+ DEFAULT_MCP_HOST = "127.0.0.1"
36
+ DEFAULT_MCP_PORT = 8080
37
+ MCP_VERSION = "1.0"
38
+
39
+ # File patterns
40
+ MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mdown", ".mkd", ".mkdn"}
41
+ DEFAULT_IGNORE_PATTERNS = [
42
+ ".git",
43
+ "__pycache__",
44
+ "node_modules",
45
+ ".obsidian",
46
+ ".trash",
47
+ "*.tmp",
48
+ "*.temp",
49
+ ]
50
+
51
+ # Database schema versions
52
+ CURRENT_SCHEMA_VERSION = 1
sif/config/settings.py ADDED
@@ -0,0 +1,217 @@
1
+ """Configuration settings for SIF."""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field, field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from sif.config.constants import APP_NAME, DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ """Application settings using Pydantic Settings.
14
+
15
+ Settings are loaded from environment variables and .env files.
16
+ Environment variables should be prefixed with SIF_.
17
+ """
18
+
19
+ model_config = SettingsConfigDict(
20
+ env_prefix="SIF_",
21
+ env_file=".env",
22
+ env_file_encoding="utf-8",
23
+ extra="ignore",
24
+ )
25
+
26
+ # Application settings
27
+ app_name: str = Field(default=APP_NAME, description="Application name")
28
+ debug: bool = Field(default=False, description="Debug mode")
29
+ log_level: str = Field(default="INFO", description="Logging level")
30
+
31
+ # Database settings
32
+ db_path: Path | None = Field(
33
+ default=None,
34
+ description="Path to SQLite database file",
35
+ )
36
+
37
+ # Model settings
38
+ model_path: Path | None = Field(
39
+ default=None,
40
+ description="Path to embedding model file",
41
+ )
42
+ model_name: str = Field(
43
+ default="Qwen/Qwen3-Embedding-0.6B",
44
+ description="Embedding model name",
45
+ )
46
+ embedding_dim: int = Field(
47
+ default=1024,
48
+ ge=1,
49
+ description="Embedding dimension",
50
+ )
51
+ max_tokens: int = Field(
52
+ default=512,
53
+ ge=1,
54
+ description="Maximum tokens per input",
55
+ )
56
+ batch_size: int = Field(
57
+ default=32,
58
+ ge=1,
59
+ description="Batch size for inference",
60
+ )
61
+ model_type: str = Field(
62
+ default="modelscope",
63
+ description="Embedding model type (gguf, sentence_transformers, openai, huggingface, modelscope)",
64
+ )
65
+ n_gpu_layers: int = Field(
66
+ default=0,
67
+ ge=0,
68
+ description="Number of GPU layers for GGUF models",
69
+ )
70
+ api_key: str | None = Field(
71
+ default=None,
72
+ description="API key for remote embedding models",
73
+ repr=False,
74
+ )
75
+ api_base: str | None = Field(
76
+ default=None,
77
+ description="Base URL for remote embedding API",
78
+ )
79
+
80
+ # Reranker settings
81
+ reranker_model_name: str = Field(
82
+ default="Qwen/Qwen3-Reranker-0.6B",
83
+ description="Reranker model name",
84
+ )
85
+ reranker_model_path: Path | None = Field(
86
+ default=None,
87
+ description="Path to local reranker model file",
88
+ )
89
+ reranker_model_type: str = Field(
90
+ default="sentence_transformers",
91
+ description="Reranker model type (gguf, sentence_transformers)",
92
+ )
93
+ reranker_batch_size: int = Field(
94
+ default=32,
95
+ ge=1,
96
+ description="Batch size for reranker inference",
97
+ )
98
+
99
+ # Chunking settings
100
+ chunk_size: int = Field(
101
+ default=DEFAULT_CHUNK_SIZE,
102
+ ge=100,
103
+ description="Default chunk size in tokens",
104
+ )
105
+ chunk_overlap: int = Field(
106
+ default=DEFAULT_CHUNK_OVERLAP,
107
+ ge=0,
108
+ description="Default chunk overlap in tokens",
109
+ )
110
+
111
+ # Search settings
112
+ default_search_type: str = Field(
113
+ default="hybrid",
114
+ description="Default search type (bm25, vector, hybrid)",
115
+ )
116
+ default_limit: int = Field(
117
+ default=10,
118
+ ge=1,
119
+ le=100,
120
+ description="Default search result limit",
121
+ )
122
+
123
+ # MCP server settings
124
+ mcp_host: str = Field(
125
+ default="127.0.0.1",
126
+ description="MCP HTTP server host",
127
+ )
128
+ mcp_port: int = Field(
129
+ default=8080,
130
+ ge=1,
131
+ le=65535,
132
+ description="MCP HTTP server port",
133
+ )
134
+ mcp_transport: str = Field(
135
+ default="stdio",
136
+ description="MCP transport type (stdio, http)",
137
+ )
138
+
139
+ # Cache settings
140
+ cache_dir: Path | None = Field(
141
+ default=None,
142
+ description="Cache directory path",
143
+ )
144
+ cache_embeddings: bool = Field(
145
+ default=True,
146
+ description="Whether to cache embeddings",
147
+ )
148
+
149
+ @field_validator("model_type")
150
+ @classmethod
151
+ def validate_model_type(cls, v: str) -> str:
152
+ """Validate model_type is one of the supported backends."""
153
+ valid_types = {"sentence_transformers", "gguf", "openai", "modelscope", "huggingface"}
154
+ if v not in valid_types:
155
+ raise ValueError(f"Invalid model_type: {v}. Must be one of {sorted(valid_types)}")
156
+ return v
157
+
158
+ @field_validator("api_base")
159
+ @classmethod
160
+ def validate_api_base(cls, v: str | None) -> str | None:
161
+ """Validate api_base is an HTTP or HTTPS URL."""
162
+ if v is None:
163
+ return None
164
+ v_lower = v.lower()
165
+ if not v_lower.startswith("http://") and not v_lower.startswith("https://"):
166
+ raise ValueError("api_base must be an HTTP or HTTPS URL")
167
+ return v
168
+
169
+ @field_validator("db_path", "cache_dir", mode="before")
170
+ @classmethod
171
+ def expand_path(cls, v: str | Path | None) -> Path | None:
172
+ """Expand user home directory in paths."""
173
+ if v is None:
174
+ return None
175
+ path = Path(v).expanduser()
176
+ return path
177
+
178
+ @field_validator("log_level")
179
+ @classmethod
180
+ def validate_log_level(cls, v: str) -> str:
181
+ """Validate log level."""
182
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
183
+ v_upper = v.upper()
184
+ if v_upper not in valid_levels:
185
+ raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
186
+ return v_upper
187
+
188
+ def get_db_path(self) -> Path:
189
+ """Get the database path, creating default if not set."""
190
+ if self.db_path:
191
+ return self.db_path
192
+
193
+ from platformdirs import user_data_dir # noqa: PLC0415
194
+
195
+ data_dir = Path(user_data_dir(self.app_name))
196
+ data_dir.mkdir(parents=True, exist_ok=True)
197
+ return data_dir / "sif.db"
198
+
199
+ def get_cache_dir(self) -> Path:
200
+ """Get the cache directory, creating default if not set."""
201
+ if self.cache_dir:
202
+ return self.cache_dir
203
+
204
+ from platformdirs import user_cache_dir # noqa: PLC0415
205
+
206
+ cache_dir = Path(user_cache_dir(self.app_name))
207
+ cache_dir.mkdir(parents=True, exist_ok=True)
208
+ return cache_dir
209
+
210
+
211
+ @lru_cache
212
+ def get_settings() -> Settings:
213
+ """Get cached settings instance.
214
+
215
+ Uses lru_cache to ensure settings are loaded only once.
216
+ """
217
+ return Settings()
sif/core/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Core domain entities and business logic for SIF.
2
+
3
+ This package contains the core domain models and business logic
4
+ that define the fundamental concepts of the SIF system.
5
+ """
6
+
7
+ from sif.core.collection import Collection, CollectionManager
8
+ from sif.core.context import Context, ContextManager, ContextType
9
+ from sif.core.document import Document, DocumentChunk, DocumentMetadata
10
+
11
+
12
+ __all__ = [
13
+ "Collection",
14
+ "CollectionManager",
15
+ "Context",
16
+ "ContextManager",
17
+ "ContextType",
18
+ "Document",
19
+ "DocumentChunk",
20
+ "DocumentMetadata",
21
+ ]
sif/core/collection.py ADDED
@@ -0,0 +1,161 @@
1
+ """Collection domain entity and manager."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Protocol
6
+
7
+
8
+ @dataclass
9
+ class Collection:
10
+ """Domain entity representing a document collection.
11
+
12
+ A collection is a logical grouping of documents, typically
13
+ corresponding to a directory or set of directories.
14
+ """
15
+
16
+ id: str
17
+ name: str
18
+ description: str | None = None
19
+ paths: list[str] = field(default_factory=list)
20
+ document_count: int = 0
21
+ chunk_count: int = 0
22
+ metadata: dict[str, Any] = field(default_factory=dict)
23
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
24
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
25
+ last_indexed_at: datetime | None = None
26
+
27
+ def add_path(self, path: str) -> None:
28
+ """Add a path to the collection if not already present."""
29
+ if path not in self.paths:
30
+ self.paths.append(path)
31
+ self.updated_at = datetime.now(timezone.utc)
32
+
33
+ def remove_path(self, path: str) -> None:
34
+ """Remove a path from the collection."""
35
+ if path in self.paths:
36
+ self.paths.remove(path)
37
+ self.updated_at = datetime.now(timezone.utc)
38
+
39
+ def update_metadata(self, **kwargs: Any) -> None:
40
+ """Update collection metadata."""
41
+ self.metadata.update(kwargs)
42
+ self.updated_at = datetime.now(timezone.utc)
43
+
44
+ def mark_indexed(self) -> None:
45
+ """Mark the collection as indexed."""
46
+ self.last_indexed_at = datetime.now(timezone.utc)
47
+ self.updated_at = datetime.now(timezone.utc)
48
+
49
+
50
+ class CollectionRepository(Protocol):
51
+ """Protocol for collection repository operations."""
52
+
53
+ def get_by_id(self, collection_id: str) -> Collection | None:
54
+ """Get a collection by ID."""
55
+ ...
56
+
57
+ def get_by_name(self, name: str) -> Collection | None:
58
+ """Get a collection by name."""
59
+ ...
60
+
61
+ def list_all(self) -> list[Collection]:
62
+ """List all collections."""
63
+ ...
64
+
65
+ def create(self, collection: Collection) -> Collection:
66
+ """Create a new collection."""
67
+ ...
68
+
69
+ def update(self, collection: Collection) -> Collection:
70
+ """Update an existing collection."""
71
+ ...
72
+
73
+ def delete(self, collection_id: str) -> bool:
74
+ """Delete a collection by ID."""
75
+ ...
76
+
77
+ def exists(self, name: str) -> bool:
78
+ """Check if a collection with the given name exists."""
79
+ ...
80
+
81
+
82
+ class CollectionManager:
83
+ """Manager for collection operations.
84
+
85
+ This class provides high-level operations for managing collections,
86
+ coordinating between repositories and other services.
87
+ """
88
+
89
+ def __init__(self, repository: CollectionRepository) -> None:
90
+ self._repository = repository
91
+
92
+ def create_collection(
93
+ self,
94
+ name: str,
95
+ description: str | None = None,
96
+ paths: list[str] | None = None,
97
+ metadata: dict[str, Any] | None = None,
98
+ ) -> Collection:
99
+ """Create a new collection."""
100
+ import uuid # noqa: PLC0415
101
+
102
+ if self._repository.exists(name):
103
+ raise ValueError(f"Collection '{name}' already exists")
104
+
105
+ collection = Collection(
106
+ id=str(uuid.uuid4()),
107
+ name=name,
108
+ description=description,
109
+ paths=paths or [],
110
+ metadata=metadata or {},
111
+ )
112
+ return self._repository.create(collection)
113
+
114
+ def get_collection(self, collection_id: str) -> Collection | None:
115
+ """Get a collection by ID."""
116
+ return self._repository.get_by_id(collection_id)
117
+
118
+ def list_collections(self) -> list[Collection]:
119
+ """List all collections."""
120
+ return self._repository.list_all()
121
+
122
+ def update_collection(
123
+ self,
124
+ collection_id: str,
125
+ name: str | None = None,
126
+ description: str | None = None,
127
+ ) -> Collection:
128
+ """Update a collection."""
129
+ collection = self._repository.get_by_id(collection_id)
130
+ if not collection:
131
+ raise ValueError(f"Collection '{collection_id}' not found")
132
+
133
+ if name is not None:
134
+ collection.name = name
135
+ if description is not None:
136
+ collection.description = description
137
+
138
+ collection.updated_at = datetime.now(timezone.utc)
139
+ return self._repository.update(collection)
140
+
141
+ def delete_collection(self, collection_id: str) -> bool:
142
+ """Delete a collection."""
143
+ return self._repository.delete(collection_id)
144
+
145
+ def add_path(self, collection_id: str, path: str) -> Collection:
146
+ """Add a path to a collection."""
147
+ collection = self._repository.get_by_id(collection_id)
148
+ if not collection:
149
+ raise ValueError(f"Collection '{collection_id}' not found")
150
+
151
+ collection.add_path(path)
152
+ return self._repository.update(collection)
153
+
154
+ def remove_path(self, collection_id: str, path: str) -> Collection:
155
+ """Remove a path from a collection."""
156
+ collection = self._repository.get_by_id(collection_id)
157
+ if not collection:
158
+ raise ValueError(f"Collection '{collection_id}' not found")
159
+
160
+ collection.remove_path(path)
161
+ return self._repository.update(collection)
sif/core/context.py ADDED
@@ -0,0 +1,167 @@
1
+ """Context domain entity and manager."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from enum import Enum, auto
6
+ from typing import Protocol
7
+
8
+
9
+ class ContextType(Enum):
10
+ """Type of context."""
11
+
12
+ COLLECTION = auto()
13
+ PATH = auto()
14
+ DOCUMENT = auto()
15
+ GLOBAL = auto()
16
+
17
+
18
+ @dataclass
19
+ class Context:
20
+ """Domain entity representing contextual information.
21
+
22
+ Context provides descriptive metadata that can be attached to
23
+ collections, paths, or documents to improve search relevance.
24
+ """
25
+
26
+ id: str
27
+ content: str
28
+ context_type: ContextType
29
+ target_id: str
30
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
31
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
32
+
33
+ def update_content(self, content: str) -> None:
34
+ """Update the context content."""
35
+ self.content = content
36
+ self.updated_at = datetime.now(timezone.utc)
37
+
38
+
39
+ class ContextRepository(Protocol):
40
+ """Protocol for context repository operations."""
41
+
42
+ def get_by_id(self, context_id: str) -> Context | None:
43
+ """Get a context by ID."""
44
+ ...
45
+
46
+ def get_by_target(self, target_id: str, context_type: ContextType) -> Context | None:
47
+ """Get context by target ID and type."""
48
+ ...
49
+
50
+ def list_by_type(self, context_type: ContextType) -> list[Context]:
51
+ """List all contexts of a specific type."""
52
+ ...
53
+
54
+ def list_all(self) -> list[Context]:
55
+ """List all contexts."""
56
+ ...
57
+
58
+ def create(self, context: Context) -> Context:
59
+ """Create a new context."""
60
+ ...
61
+
62
+ def update(self, context: Context) -> Context:
63
+ """Update an existing context."""
64
+ ...
65
+
66
+ def delete(self, context_id: str) -> bool:
67
+ """Delete a context by ID."""
68
+ ...
69
+
70
+ def delete_by_target(self, target_id: str) -> bool:
71
+ """Delete all contexts for a target."""
72
+ ...
73
+
74
+
75
+ class ContextManager:
76
+ """Manager for context operations.
77
+
78
+ Provides high-level operations for managing contextual information
79
+ attached to collections, paths, and documents.
80
+ """
81
+
82
+ def __init__(self, repository: ContextRepository) -> None:
83
+ self._repository = repository
84
+
85
+ def add_context(
86
+ self,
87
+ target_id: str,
88
+ content: str,
89
+ context_type: ContextType,
90
+ ) -> Context:
91
+ """Add context to a target."""
92
+ import uuid # noqa: PLC0415
93
+
94
+ # Check if context already exists for this target
95
+ existing = self._repository.get_by_target(target_id, context_type)
96
+ if existing:
97
+ existing.update_content(content)
98
+ return self._repository.update(existing)
99
+
100
+ context = Context(
101
+ id=str(uuid.uuid4()),
102
+ content=content,
103
+ context_type=context_type,
104
+ target_id=target_id,
105
+ )
106
+ return self._repository.create(context)
107
+
108
+ def get_context(self, context_id: str) -> Context | None:
109
+ """Get context by ID."""
110
+ return self._repository.get_by_id(context_id)
111
+
112
+ def get_context_for_target(self, target_id: str, context_type: ContextType) -> Context | None:
113
+ """Get context for a specific target."""
114
+ return self._repository.get_by_target(target_id, context_type)
115
+
116
+ def update_context(self, context_id: str, content: str) -> Context:
117
+ """Update context content."""
118
+ context = self._repository.get_by_id(context_id)
119
+ if not context:
120
+ raise ValueError(f"Context '{context_id}' not found")
121
+
122
+ context.update_content(content)
123
+ return self._repository.update(context)
124
+
125
+ def remove_context(self, context_id: str) -> bool:
126
+ """Remove a context."""
127
+ return self._repository.delete(context_id)
128
+
129
+ def list_contexts(self, context_type: ContextType | None = None) -> list[Context]:
130
+ """List contexts, optionally filtered by type."""
131
+ if context_type:
132
+ return self._repository.list_by_type(context_type)
133
+ return self._repository.list_all()
134
+
135
+ def build_search_context(
136
+ self,
137
+ collection_ids: list[str] | None = None,
138
+ path_prefixes: list[str] | None = None,
139
+ ) -> str:
140
+ """Build a combined context string for search.
141
+
142
+ This aggregates all relevant contexts for the given collections
143
+ and paths to provide additional context for search queries.
144
+ """
145
+ contexts: list[str] = []
146
+
147
+ # Get global context
148
+ global_contexts = self._repository.list_by_type(ContextType.GLOBAL)
149
+ contexts.extend([c.content for c in global_contexts])
150
+
151
+ # Get collection contexts
152
+ if collection_ids:
153
+ for collection_id in collection_ids:
154
+ ctx = self._repository.get_by_target(collection_id, ContextType.COLLECTION)
155
+ if ctx:
156
+ contexts.append(ctx.content)
157
+
158
+ # Get path contexts
159
+ if path_prefixes:
160
+ all_path_contexts = self._repository.list_by_type(ContextType.PATH)
161
+ for ctx in all_path_contexts:
162
+ for prefix in path_prefixes:
163
+ if ctx.target_id.startswith(prefix):
164
+ contexts.append(ctx.content)
165
+ break
166
+
167
+ return "\n\n".join(contexts) if contexts else ""