gobby 0.2.5__py3-none-any.whl → 0.2.6__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 (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/runner.py CHANGED
@@ -18,6 +18,7 @@ from gobby.servers.http import HTTPServer
18
18
  from gobby.servers.websocket import WebSocketConfig, WebSocketServer
19
19
  from gobby.sessions.lifecycle import SessionLifecycleManager
20
20
  from gobby.sessions.processor import SessionMessageProcessor
21
+ from gobby.storage.clones import LocalCloneManager
21
22
  from gobby.storage.database import DatabaseProtocol, LocalDatabase
22
23
  from gobby.storage.mcp import LocalMCPManager
23
24
  from gobby.storage.migrations import run_migrations
@@ -28,10 +29,10 @@ from gobby.storage.tasks import LocalTaskManager
28
29
  from gobby.storage.worktrees import LocalWorktreeManager
29
30
  from gobby.sync.memories import MemorySyncManager
30
31
  from gobby.sync.tasks import TaskSyncManager
31
- from gobby.tasks.expansion import TaskExpander
32
32
  from gobby.tasks.validation import TaskValidator
33
33
  from gobby.utils.logging import setup_file_logging
34
34
  from gobby.utils.machine_id import get_machine_id
35
+ from gobby.worktrees.git import WorktreeGitManager
35
36
 
36
37
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
37
38
 
@@ -43,7 +44,6 @@ class GobbyRunner:
43
44
 
44
45
  def __init__(self, config_path: Path | None = None, verbose: bool = False):
45
46
  setup_file_logging(verbose=verbose)
46
- # setup_mcp_logging(verbose=verbose) # Removed as per instruction
47
47
 
48
48
  config_file = str(config_path) if config_path else None
49
49
  self.config = load_config(config_file)
@@ -59,6 +59,16 @@ class GobbyRunner:
59
59
  self.task_manager = LocalTaskManager(self.database)
60
60
  self.session_task_manager = SessionTaskManager(self.database)
61
61
 
62
+ # Sync bundled skills to database
63
+ from gobby.skills.sync import sync_bundled_skills
64
+
65
+ try:
66
+ skill_result = sync_bundled_skills(self.database)
67
+ if skill_result["synced"] > 0:
68
+ logger.info(f"Synced {skill_result['synced']} bundled skills to database")
69
+ except Exception as e:
70
+ logger.warning(f"Failed to sync bundled skills: {e}")
71
+
62
72
  # Initialize LLM Service
63
73
  self.llm_service: LLMService | None = None # Added type hint
64
74
  try:
@@ -134,23 +144,11 @@ class GobbyRunner:
134
144
  poll_interval=self.config.message_tracking.poll_interval,
135
145
  )
136
146
 
137
- # Initialize Task Managers (Phase 7.1)
138
- self.task_expander: TaskExpander | None = None
147
+ # Initialize Task Validator (Phase 7.1)
139
148
  self.task_validator: TaskValidator | None = None
140
149
 
141
150
  if self.llm_service:
142
151
  gobby_tasks_config = self.config.gobby_tasks
143
- if gobby_tasks_config.expansion.enabled:
144
- try:
145
- self.task_expander = TaskExpander(
146
- llm_service=self.llm_service,
147
- config=gobby_tasks_config.expansion,
148
- task_manager=self.task_manager,
149
- mcp_manager=self.mcp_proxy,
150
- )
151
- except Exception as e:
152
- logger.error(f"Failed to initialize TaskExpander: {e}")
153
-
154
152
  if gobby_tasks_config.validation.enabled:
155
153
  try:
156
154
  self.task_validator = TaskValidator(
@@ -163,6 +161,26 @@ class GobbyRunner:
163
161
  # Initialize Worktree Storage (Phase 7 - Subagents)
164
162
  self.worktree_storage = LocalWorktreeManager(self.database)
165
163
 
164
+ # Initialize Clone Storage (local git clones for isolated development)
165
+ self.clone_storage = LocalCloneManager(self.database)
166
+
167
+ # Initialize Git Manager for current project (if in a git repo)
168
+ self.git_manager: WorktreeGitManager | None = None
169
+ self.project_id: str | None = None
170
+ try:
171
+ cwd = Path.cwd()
172
+ project_json = cwd / ".gobby" / "project.json"
173
+ if project_json.exists():
174
+ import json
175
+
176
+ project_data = json.loads(project_json.read_text())
177
+ repo_path = project_data.get("repo_path", str(cwd))
178
+ self.project_id = project_data.get("id")
179
+ self.git_manager = WorktreeGitManager(repo_path)
180
+ logger.info(f"Git manager initialized for project: {self.project_id}")
181
+ except Exception as e:
182
+ logger.debug(f"Could not initialize git manager: {e}")
183
+
166
184
  # Initialize Agent Runner (Phase 7 - Subagents)
167
185
  # Create executor registry for lazy executor creation
168
186
  self.executor_registry = ExecutorRegistry(config=self.config)
@@ -196,6 +214,7 @@ class GobbyRunner:
196
214
  # HTTP Server
197
215
  self.http_server = HTTPServer(
198
216
  port=self.config.daemon_port,
217
+ test_mode=self.config.test_mode,
199
218
  mcp_manager=self.mcp_proxy,
200
219
  mcp_db_manager=self.mcp_db_manager,
201
220
  config=self.config,
@@ -207,11 +226,13 @@ class GobbyRunner:
207
226
  llm_service=self.llm_service,
208
227
  message_processor=self.message_processor,
209
228
  memory_sync_manager=self.memory_sync_manager,
210
- task_expander=self.task_expander,
211
229
  task_validator=self.task_validator,
212
230
  metrics_manager=self.metrics_manager,
213
231
  agent_runner=self.agent_runner,
214
232
  worktree_storage=self.worktree_storage,
233
+ clone_storage=self.clone_storage,
234
+ git_manager=self.git_manager,
235
+ project_id=self.project_id,
215
236
  )
216
237
 
217
238
  # Ensure message_processor property is set (redundant but explicit):
gobby/search/__init__.py CHANGED
@@ -1,23 +1,65 @@
1
1
  """
2
- Shared search backend abstraction.
2
+ Unified search backend abstraction.
3
3
 
4
- Provides pluggable search backends for semantic search:
5
- - TF-IDF (default) - Built-in local search using scikit-learn (sklearn)
4
+ Provides a unified search layer with multiple backends:
5
+ - TF-IDF (default) - Built-in local search using scikit-learn
6
+ - Embedding - LiteLLM-based semantic search (OpenAI, Ollama, etc.)
7
+ - Unified - Orchestrates between backends with automatic fallback
6
8
 
7
- Usage:
8
- from gobby.search import SearchBackend, get_search_backend, TFIDFSearcher
9
+ Basic usage (sync TF-IDF):
10
+ from gobby.search import TFIDFSearcher
9
11
 
10
- backend = get_search_backend("tfidf")
12
+ backend = TFIDFSearcher()
11
13
  backend.fit([(id, content) for id, content in items])
12
14
  results = backend.search("query text", top_k=10)
15
+
16
+ Unified search (async with fallback):
17
+ from gobby.search import UnifiedSearcher, SearchConfig
18
+
19
+ config = SearchConfig(mode="auto") # auto, tfidf, embedding, hybrid
20
+ searcher = UnifiedSearcher(config)
21
+ await searcher.fit_async([(id, content) for id, content in items])
22
+ results = await searcher.search_async("query text", top_k=10)
23
+
24
+ if searcher.is_using_fallback():
25
+ print(f"Using fallback: {searcher.get_fallback_reason()}")
13
26
  """
14
27
 
28
+ # Sync search backends (backwards compatibility)
29
+ # Async backends
30
+ from gobby.search.backends import AsyncSearchBackend, EmbeddingBackend, TFIDFBackend
31
+
32
+ # Embedding utilities
33
+ from gobby.search.embeddings import (
34
+ generate_embedding,
35
+ generate_embeddings,
36
+ is_embedding_available,
37
+ )
38
+
39
+ # Unified search (async with fallback)
40
+ from gobby.search.models import FallbackEvent, SearchConfig, SearchMode
15
41
  from gobby.search.protocol import SearchBackend, SearchResult, get_search_backend
16
42
  from gobby.search.tfidf import TFIDFSearcher
43
+ from gobby.search.unified import UnifiedSearcher
17
44
 
18
45
  __all__ = [
46
+ # Sync backends (backwards compatible)
19
47
  "SearchBackend",
20
48
  "SearchResult",
21
49
  "TFIDFSearcher",
22
50
  "get_search_backend",
51
+ # Models
52
+ "SearchConfig",
53
+ "SearchMode",
54
+ "FallbackEvent",
55
+ # Unified searcher
56
+ "UnifiedSearcher",
57
+ # Async backends
58
+ "AsyncSearchBackend",
59
+ "TFIDFBackend",
60
+ "EmbeddingBackend",
61
+ # Embedding utilities
62
+ "generate_embedding",
63
+ "generate_embeddings",
64
+ "is_embedding_available",
23
65
  ]
@@ -0,0 +1,159 @@
1
+ """Search backend abstractions.
2
+
3
+ This module provides the protocol and implementations for search backends
4
+ used by UnifiedSearcher:
5
+
6
+ - AsyncSearchBackend: Protocol for async search backends
7
+ - TFIDFBackend: TF-IDF based search (always available)
8
+ - EmbeddingBackend: Embedding-based search (requires API)
9
+
10
+ Usage:
11
+ from gobby.search.backends import AsyncSearchBackend, TFIDFBackend
12
+
13
+ backend: AsyncSearchBackend = TFIDFBackend()
14
+ await backend.fit_async([("id1", "content1"), ("id2", "content2")])
15
+ results = await backend.search_async("query", top_k=10)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Protocol, runtime_checkable
21
+
22
+ # Re-export sync TFIDFSearcher for backwards compatibility
23
+ from gobby.search.tfidf import TFIDFSearcher
24
+
25
+ __all__ = [
26
+ "AsyncSearchBackend",
27
+ "TFIDFBackend",
28
+ "EmbeddingBackend",
29
+ "TFIDFSearcher",
30
+ ]
31
+
32
+
33
+ @runtime_checkable
34
+ class AsyncSearchBackend(Protocol):
35
+ """Protocol for async search backends.
36
+
37
+ All search backends must implement this interface. The protocol
38
+ uses async methods to support embedding-based backends that need
39
+ to call external APIs.
40
+
41
+ Methods:
42
+ fit_async: Build/rebuild the search index
43
+ search_async: Find relevant items for a query
44
+ needs_refit: Check if index needs rebuilding
45
+ get_stats: Get backend statistics
46
+ clear: Clear the search index
47
+ """
48
+
49
+ async def fit_async(self, items: list[tuple[str, str]]) -> None:
50
+ """Build or rebuild the search index.
51
+
52
+ Args:
53
+ items: List of (item_id, content) tuples to index
54
+ """
55
+ ...
56
+
57
+ async def search_async(
58
+ self,
59
+ query: str,
60
+ top_k: int = 10,
61
+ ) -> list[tuple[str, float]]:
62
+ """Search for items matching the query.
63
+
64
+ Args:
65
+ query: Search query text
66
+ top_k: Maximum number of results to return
67
+
68
+ Returns:
69
+ List of (item_id, similarity_score) tuples, sorted by
70
+ relevance (highest similarity first).
71
+ """
72
+ ...
73
+
74
+ def needs_refit(self) -> bool:
75
+ """Check if the search index needs rebuilding.
76
+
77
+ Returns:
78
+ True if fit_async() should be called before search_async()
79
+ """
80
+ ...
81
+
82
+ def get_stats(self) -> dict[str, Any]:
83
+ """Get statistics about the search index.
84
+
85
+ Returns:
86
+ Dict with backend-specific statistics
87
+ """
88
+ ...
89
+
90
+ def clear(self) -> None:
91
+ """Clear the search index."""
92
+ ...
93
+
94
+
95
+ class TFIDFBackend:
96
+ """Async wrapper around TFIDFSearcher.
97
+
98
+ Provides the AsyncSearchBackend interface for TF-IDF search.
99
+ This is a thin wrapper that delegates to the sync TFIDFSearcher.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ ngram_range: tuple[int, int] = (1, 2),
105
+ max_features: int = 10000,
106
+ min_df: int = 1,
107
+ stop_words: str | None = "english",
108
+ refit_threshold: int = 10,
109
+ ):
110
+ """Initialize TF-IDF backend.
111
+
112
+ Args:
113
+ ngram_range: Min/max n-gram sizes for tokenization
114
+ max_features: Maximum vocabulary size
115
+ min_df: Minimum document frequency for inclusion
116
+ stop_words: Language for stop words (None to disable)
117
+ refit_threshold: Number of updates before automatic refit
118
+ """
119
+ self._searcher = TFIDFSearcher(
120
+ ngram_range=ngram_range,
121
+ max_features=max_features,
122
+ min_df=min_df,
123
+ stop_words=stop_words,
124
+ refit_threshold=refit_threshold,
125
+ )
126
+
127
+ async def fit_async(self, items: list[tuple[str, str]]) -> None:
128
+ """Build or rebuild the search index."""
129
+ self._searcher.fit(items)
130
+
131
+ async def search_async(
132
+ self,
133
+ query: str,
134
+ top_k: int = 10,
135
+ ) -> list[tuple[str, float]]:
136
+ """Search for items matching the query."""
137
+ return self._searcher.search(query, top_k)
138
+
139
+ def needs_refit(self) -> bool:
140
+ """Check if the search index needs rebuilding."""
141
+ return self._searcher.needs_refit()
142
+
143
+ def get_stats(self) -> dict[str, Any]:
144
+ """Get statistics about the search index."""
145
+ stats = self._searcher.get_stats()
146
+ stats["backend_type"] = "tfidf"
147
+ return stats
148
+
149
+ def clear(self) -> None:
150
+ """Clear the search index."""
151
+ self._searcher.clear()
152
+
153
+ def mark_update(self) -> None:
154
+ """Mark that an item update occurred."""
155
+ self._searcher.mark_update()
156
+
157
+
158
+ # Import EmbeddingBackend - needs to be at end to avoid circular imports
159
+ from gobby.search.backends.embedding import EmbeddingBackend # noqa: E402
@@ -0,0 +1,225 @@
1
+ """Embedding-based search backend.
2
+
3
+ This module provides embedding-based semantic search using cosine similarity.
4
+ It stores embeddings in memory and uses LiteLLM for embedding generation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import math
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from gobby.search.models import SearchConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
20
+ """Compute cosine similarity between two vectors.
21
+
22
+ Args:
23
+ vec1: First vector
24
+ vec2: Second vector
25
+
26
+ Returns:
27
+ Cosine similarity score between -1 and 1
28
+ """
29
+ if len(vec1) != len(vec2):
30
+ return 0.0
31
+
32
+ dot_product = sum(a * b for a, b in zip(vec1, vec2, strict=True))
33
+ norm1 = math.sqrt(sum(a * a for a in vec1))
34
+ norm2 = math.sqrt(sum(b * b for b in vec2))
35
+
36
+ if norm1 == 0 or norm2 == 0:
37
+ return 0.0
38
+
39
+ return dot_product / (norm1 * norm2)
40
+
41
+
42
+ class EmbeddingBackend:
43
+ """Embedding-based search backend using LiteLLM.
44
+
45
+ This backend generates embeddings for indexed items and uses
46
+ cosine similarity for search. Embeddings are stored in memory.
47
+
48
+ Supports all providers supported by LiteLLM:
49
+ - OpenAI (text-embedding-3-small)
50
+ - Ollama (openai/nomic-embed-text with api_base)
51
+ - Azure, Gemini, Mistral, etc.
52
+
53
+ Example:
54
+ backend = EmbeddingBackend(
55
+ model="text-embedding-3-small",
56
+ api_key="sk-..."
57
+ )
58
+ await backend.fit_async([("id1", "hello"), ("id2", "world")])
59
+ results = await backend.search_async("greeting", top_k=5)
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ model: str = "text-embedding-3-small",
65
+ api_base: str | None = None,
66
+ api_key: str | None = None,
67
+ ):
68
+ """Initialize embedding backend.
69
+
70
+ Args:
71
+ model: LiteLLM model string
72
+ api_base: Optional API base URL for custom endpoints
73
+ api_key: Optional API key (uses env var if not set)
74
+ """
75
+ self._model = model
76
+ self._api_base = api_base
77
+ self._api_key = api_key
78
+
79
+ # Item storage
80
+ self._item_ids: list[str] = []
81
+ self._item_embeddings: list[list[float]] = []
82
+ self._item_contents: dict[str, str] = {} # For reindexing
83
+ self._fitted = False
84
+
85
+ @classmethod
86
+ def from_config(cls, config: SearchConfig) -> EmbeddingBackend:
87
+ """Create an EmbeddingBackend from a SearchConfig.
88
+
89
+ Args:
90
+ config: SearchConfig with model and API settings
91
+
92
+ Returns:
93
+ Configured EmbeddingBackend instance
94
+ """
95
+ return cls(
96
+ model=config.embedding_model,
97
+ api_base=config.embedding_api_base,
98
+ api_key=config.embedding_api_key,
99
+ )
100
+
101
+ async def fit_async(self, items: list[tuple[str, str]]) -> None:
102
+ """Build or rebuild the search index.
103
+
104
+ Generates embeddings for all items and stores them in memory.
105
+
106
+ Args:
107
+ items: List of (item_id, content) tuples to index
108
+
109
+ Raises:
110
+ RuntimeError: If embedding generation fails
111
+ """
112
+ if not items:
113
+ self._item_ids = []
114
+ self._item_embeddings = []
115
+ self._item_contents = {}
116
+ self._fitted = False
117
+ logger.debug("Embedding index cleared (no items)")
118
+ return
119
+
120
+ from gobby.search.embeddings import generate_embeddings
121
+
122
+ # Store contents for potential reindexing
123
+ self._item_ids = [item_id for item_id, _ in items]
124
+ self._item_contents = dict(items)
125
+ contents = [content for _, content in items]
126
+
127
+ # Generate embeddings in batch
128
+ try:
129
+ self._item_embeddings = await generate_embeddings(
130
+ texts=contents,
131
+ model=self._model,
132
+ api_base=self._api_base,
133
+ api_key=self._api_key,
134
+ )
135
+ self._fitted = True
136
+ logger.info(f"Embedding index built with {len(items)} items")
137
+ except Exception as e:
138
+ # Clear stale state to prevent inconsistent data
139
+ self._item_ids = []
140
+ self._item_contents = {}
141
+ self._item_embeddings = []
142
+ self._fitted = False
143
+ logger.error(f"Failed to build embedding index: {e}")
144
+ raise
145
+
146
+ async def search_async(
147
+ self,
148
+ query: str,
149
+ top_k: int = 10,
150
+ ) -> list[tuple[str, float]]:
151
+ """Search for items matching the query.
152
+
153
+ Generates an embedding for the query and finds items with
154
+ highest cosine similarity.
155
+
156
+ Args:
157
+ query: Search query text
158
+ top_k: Maximum number of results to return
159
+
160
+ Returns:
161
+ List of (item_id, similarity_score) tuples, sorted by
162
+ similarity descending.
163
+
164
+ Raises:
165
+ RuntimeError: If embedding generation fails
166
+ """
167
+ if not self._fitted or not self._item_embeddings:
168
+ return []
169
+
170
+ from gobby.search.embeddings import generate_embedding
171
+
172
+ # Generate query embedding
173
+ try:
174
+ query_embedding = await generate_embedding(
175
+ text=query,
176
+ model=self._model,
177
+ api_base=self._api_base,
178
+ api_key=self._api_key,
179
+ )
180
+ except Exception as e:
181
+ logger.error(f"Failed to embed query: {e}")
182
+ raise
183
+
184
+ # Compute similarities
185
+ similarities: list[tuple[str, float]] = []
186
+ for item_id, item_embedding in zip(self._item_ids, self._item_embeddings, strict=True):
187
+ similarity = _cosine_similarity(query_embedding, item_embedding)
188
+ if similarity > 0:
189
+ similarities.append((item_id, similarity))
190
+
191
+ # Sort by similarity descending
192
+ similarities.sort(key=lambda x: x[1], reverse=True)
193
+
194
+ return similarities[:top_k]
195
+
196
+ def needs_refit(self) -> bool:
197
+ """Check if the search index needs rebuilding."""
198
+ return not self._fitted
199
+
200
+ def get_stats(self) -> dict[str, Any]:
201
+ """Get statistics about the search index."""
202
+ return {
203
+ "backend_type": "embedding",
204
+ "fitted": self._fitted,
205
+ "item_count": len(self._item_ids),
206
+ "model": self._model,
207
+ "has_api_base": self._api_base is not None,
208
+ }
209
+
210
+ def clear(self) -> None:
211
+ """Clear the search index."""
212
+ self._item_ids = []
213
+ self._item_embeddings = []
214
+ self._item_contents = {}
215
+ self._fitted = False
216
+
217
+ def get_item_contents(self) -> dict[str, str]:
218
+ """Get stored item contents.
219
+
220
+ Useful for reindexing into a different backend (e.g., TF-IDF fallback).
221
+
222
+ Returns:
223
+ Dict mapping item_id to content
224
+ """
225
+ return self._item_contents.copy()