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.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +3 -3
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/search/unified.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Unified search orchestration with fallback.
|
|
2
|
+
|
|
3
|
+
This module provides UnifiedSearcher, the main entry point for the unified
|
|
4
|
+
search layer. It orchestrates between embedding-based and TF-IDF backends
|
|
5
|
+
with automatic fallback and configurable search modes.
|
|
6
|
+
|
|
7
|
+
Example usage:
|
|
8
|
+
from gobby.search.unified import UnifiedSearcher
|
|
9
|
+
from gobby.search.models import SearchConfig
|
|
10
|
+
|
|
11
|
+
config = SearchConfig(mode="auto")
|
|
12
|
+
searcher = UnifiedSearcher(config)
|
|
13
|
+
|
|
14
|
+
await searcher.fit_async([
|
|
15
|
+
("id1", "hello world"),
|
|
16
|
+
("id2", "foo bar"),
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
results = await searcher.search_async("greeting", top_k=5)
|
|
20
|
+
# Returns: [("id1", 0.85), ...]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from gobby.search.backends import EmbeddingBackend, TFIDFBackend
|
|
30
|
+
from gobby.search.embeddings import is_embedding_available
|
|
31
|
+
from gobby.search.models import FallbackEvent, SearchConfig, SearchMode
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Type alias for fallback event callback
|
|
39
|
+
FallbackCallback = Callable[[FallbackEvent], None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnifiedSearcher:
|
|
43
|
+
"""Unified search with automatic fallback.
|
|
44
|
+
|
|
45
|
+
This class orchestrates between embedding-based and TF-IDF search
|
|
46
|
+
backends based on the configured mode and availability of embedding
|
|
47
|
+
providers.
|
|
48
|
+
|
|
49
|
+
Search Modes:
|
|
50
|
+
- tfidf: TF-IDF only (always works, no API needed)
|
|
51
|
+
- embedding: Embedding-based only (fails if unavailable)
|
|
52
|
+
- auto: Try embedding, fallback to TF-IDF if unavailable
|
|
53
|
+
- hybrid: Combine both with weighted scores
|
|
54
|
+
|
|
55
|
+
Fallback Behavior:
|
|
56
|
+
When in "auto" mode and embedding fails (no API key, connection error,
|
|
57
|
+
rate limit), the searcher will:
|
|
58
|
+
1. Emit a FallbackEvent via the event_callback
|
|
59
|
+
2. Log a warning (if notify_on_fallback is True)
|
|
60
|
+
3. Reindex items into TF-IDF (if not already indexed)
|
|
61
|
+
4. Return TF-IDF results for this and future searches
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
config = SearchConfig(mode="auto")
|
|
65
|
+
searcher = UnifiedSearcher(
|
|
66
|
+
config,
|
|
67
|
+
event_callback=lambda e: print(f"Fallback: {e}")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await searcher.fit_async([("id1", "content1")])
|
|
71
|
+
results = await searcher.search_async("query")
|
|
72
|
+
|
|
73
|
+
if searcher.is_using_fallback():
|
|
74
|
+
print("Using TF-IDF fallback")
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
config: SearchConfig | None = None,
|
|
80
|
+
event_callback: FallbackCallback | None = None,
|
|
81
|
+
):
|
|
82
|
+
"""Initialize UnifiedSearcher.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: Search configuration (defaults to SearchConfig())
|
|
86
|
+
event_callback: Optional callback for fallback events
|
|
87
|
+
"""
|
|
88
|
+
self._config = config or SearchConfig()
|
|
89
|
+
self._event_callback = event_callback
|
|
90
|
+
|
|
91
|
+
# Initialize backends lazily
|
|
92
|
+
self._tfidf_backend: TFIDFBackend | None = None
|
|
93
|
+
self._embedding_backend: EmbeddingBackend | None = None
|
|
94
|
+
|
|
95
|
+
# State tracking
|
|
96
|
+
self._items: list[tuple[str, str]] = [] # Cache for reindexing
|
|
97
|
+
self._fitted = False
|
|
98
|
+
self._fitted_mode: SearchMode | None = None # Track mode used during fit
|
|
99
|
+
self._using_fallback = False
|
|
100
|
+
self._fallback_reason: str | None = None
|
|
101
|
+
self._active_backend: str | None = None
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def config(self) -> SearchConfig:
|
|
105
|
+
"""Get the current configuration."""
|
|
106
|
+
return self._config
|
|
107
|
+
|
|
108
|
+
def _get_tfidf_backend(self) -> TFIDFBackend:
|
|
109
|
+
"""Get or create the TF-IDF backend."""
|
|
110
|
+
if self._tfidf_backend is None:
|
|
111
|
+
self._tfidf_backend = TFIDFBackend()
|
|
112
|
+
return self._tfidf_backend
|
|
113
|
+
|
|
114
|
+
def _get_embedding_backend(self) -> EmbeddingBackend:
|
|
115
|
+
"""Get or create the embedding backend."""
|
|
116
|
+
if self._embedding_backend is None:
|
|
117
|
+
self._embedding_backend = EmbeddingBackend(
|
|
118
|
+
model=self._config.embedding_model,
|
|
119
|
+
api_base=self._config.embedding_api_base,
|
|
120
|
+
api_key=self._config.embedding_api_key,
|
|
121
|
+
)
|
|
122
|
+
return self._embedding_backend
|
|
123
|
+
|
|
124
|
+
def _emit_fallback_event(
|
|
125
|
+
self,
|
|
126
|
+
reason: str,
|
|
127
|
+
error: Exception | None = None,
|
|
128
|
+
items_reindexed: int = 0,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Emit a fallback event and log if configured."""
|
|
131
|
+
event = FallbackEvent(
|
|
132
|
+
reason=reason,
|
|
133
|
+
original_error=error,
|
|
134
|
+
mode=self._config.mode,
|
|
135
|
+
items_reindexed=items_reindexed,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Log warning if configured
|
|
139
|
+
if self._config.notify_on_fallback:
|
|
140
|
+
logger.warning(f"Search fallback: {reason}")
|
|
141
|
+
|
|
142
|
+
# Call event callback if provided
|
|
143
|
+
if self._event_callback:
|
|
144
|
+
try:
|
|
145
|
+
self._event_callback(event)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Fallback callback error: {e}")
|
|
148
|
+
|
|
149
|
+
async def _fallback_to_tfidf(
|
|
150
|
+
self,
|
|
151
|
+
reason: str,
|
|
152
|
+
error: Exception | None = None,
|
|
153
|
+
items: list[tuple[str, str]] | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Switch to TF-IDF backend and reindex.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
reason: Human-readable reason for fallback
|
|
159
|
+
error: Optional exception that caused the fallback
|
|
160
|
+
items: Items to index. If None, uses cached self._items
|
|
161
|
+
"""
|
|
162
|
+
self._using_fallback = True
|
|
163
|
+
self._fallback_reason = reason
|
|
164
|
+
self._active_backend = "tfidf"
|
|
165
|
+
|
|
166
|
+
# Fit TF-IDF with provided items or cached items
|
|
167
|
+
fit_items = items if items is not None else self._items
|
|
168
|
+
items_reindexed = 0
|
|
169
|
+
if fit_items:
|
|
170
|
+
tfidf = self._get_tfidf_backend()
|
|
171
|
+
await tfidf.fit_async(fit_items)
|
|
172
|
+
items_reindexed = len(fit_items)
|
|
173
|
+
self._fitted = True
|
|
174
|
+
self._fitted_mode = SearchMode.TFIDF # Fallback always uses TF-IDF
|
|
175
|
+
|
|
176
|
+
self._emit_fallback_event(reason, error, items_reindexed)
|
|
177
|
+
|
|
178
|
+
async def fit_async(self, items: list[tuple[str, str]]) -> None:
|
|
179
|
+
"""Build or rebuild the search index.
|
|
180
|
+
|
|
181
|
+
Indexes items into the appropriate backend(s) based on mode:
|
|
182
|
+
- tfidf: TF-IDF only
|
|
183
|
+
- embedding: Embedding only (raises if unavailable)
|
|
184
|
+
- auto: Try embedding, fallback to TF-IDF if unavailable
|
|
185
|
+
- hybrid: Both TF-IDF and embedding
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
items: List of (item_id, content) tuples to index
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
RuntimeError: If mode is "embedding" and embedding unavailable
|
|
192
|
+
"""
|
|
193
|
+
self._items = items.copy()
|
|
194
|
+
self._fitted = False
|
|
195
|
+
self._fitted_mode = None
|
|
196
|
+
mode = self._config.get_mode_enum()
|
|
197
|
+
|
|
198
|
+
if mode == SearchMode.TFIDF:
|
|
199
|
+
# TF-IDF only
|
|
200
|
+
tfidf = self._get_tfidf_backend()
|
|
201
|
+
await tfidf.fit_async(items)
|
|
202
|
+
self._active_backend = "tfidf"
|
|
203
|
+
self._fitted = True
|
|
204
|
+
self._fitted_mode = mode
|
|
205
|
+
|
|
206
|
+
elif mode == SearchMode.EMBEDDING:
|
|
207
|
+
# Embedding only - fail if unavailable
|
|
208
|
+
if not is_embedding_available(
|
|
209
|
+
model=self._config.embedding_model,
|
|
210
|
+
api_key=self._config.embedding_api_key,
|
|
211
|
+
api_base=self._config.embedding_api_base,
|
|
212
|
+
):
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"Embedding unavailable for model {self._config.embedding_model}. "
|
|
215
|
+
"Set the appropriate API key or use mode='auto' for fallback."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
embedding = self._get_embedding_backend()
|
|
219
|
+
await embedding.fit_async(items)
|
|
220
|
+
self._active_backend = "embedding"
|
|
221
|
+
self._fitted = True
|
|
222
|
+
self._fitted_mode = mode
|
|
223
|
+
|
|
224
|
+
elif mode == SearchMode.AUTO:
|
|
225
|
+
# Try embedding, fallback to TF-IDF
|
|
226
|
+
if not is_embedding_available(
|
|
227
|
+
model=self._config.embedding_model,
|
|
228
|
+
api_key=self._config.embedding_api_key,
|
|
229
|
+
api_base=self._config.embedding_api_base,
|
|
230
|
+
):
|
|
231
|
+
# No embedding available - use TF-IDF
|
|
232
|
+
await self._fallback_to_tfidf(
|
|
233
|
+
f"Embedding unavailable (no API key for {self._config.embedding_model})",
|
|
234
|
+
items=items,
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
try:
|
|
238
|
+
embedding = self._get_embedding_backend()
|
|
239
|
+
await embedding.fit_async(items)
|
|
240
|
+
self._active_backend = "embedding"
|
|
241
|
+
self._fitted = True
|
|
242
|
+
self._fitted_mode = mode
|
|
243
|
+
except Exception as e:
|
|
244
|
+
# Embedding failed - fallback to TF-IDF
|
|
245
|
+
await self._fallback_to_tfidf(
|
|
246
|
+
f"Embedding indexing failed: {e}",
|
|
247
|
+
error=e,
|
|
248
|
+
items=items,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
elif mode == SearchMode.HYBRID:
|
|
252
|
+
# Both TF-IDF and embedding
|
|
253
|
+
tfidf = self._get_tfidf_backend()
|
|
254
|
+
await tfidf.fit_async(items)
|
|
255
|
+
|
|
256
|
+
if is_embedding_available(
|
|
257
|
+
model=self._config.embedding_model,
|
|
258
|
+
api_key=self._config.embedding_api_key,
|
|
259
|
+
api_base=self._config.embedding_api_base,
|
|
260
|
+
):
|
|
261
|
+
try:
|
|
262
|
+
embedding = self._get_embedding_backend()
|
|
263
|
+
await embedding.fit_async(items)
|
|
264
|
+
self._active_backend = "hybrid"
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.warning(f"Hybrid embedding indexing failed: {e}")
|
|
267
|
+
self._emit_fallback_event(
|
|
268
|
+
f"Hybrid mode embedding failed: {e}",
|
|
269
|
+
error=e,
|
|
270
|
+
)
|
|
271
|
+
self._active_backend = "tfidf"
|
|
272
|
+
else:
|
|
273
|
+
self._emit_fallback_event(
|
|
274
|
+
f"Hybrid mode: embedding unavailable for {self._config.embedding_model}"
|
|
275
|
+
)
|
|
276
|
+
self._active_backend = "tfidf"
|
|
277
|
+
|
|
278
|
+
self._fitted = True
|
|
279
|
+
self._fitted_mode = mode
|
|
280
|
+
|
|
281
|
+
async def search_async(
|
|
282
|
+
self,
|
|
283
|
+
query: str,
|
|
284
|
+
top_k: int = 10,
|
|
285
|
+
) -> list[tuple[str, float]]:
|
|
286
|
+
"""Search for items matching the query.
|
|
287
|
+
|
|
288
|
+
Uses the appropriate backend(s) based on mode and fallback state.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
query: Search query text
|
|
292
|
+
top_k: Maximum number of results to return
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of (item_id, similarity_score) tuples, sorted by
|
|
296
|
+
relevance (highest first). Returns an empty list if the
|
|
297
|
+
searcher has not been fitted.
|
|
298
|
+
"""
|
|
299
|
+
if not self._fitted:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
mode = self._config.get_mode_enum()
|
|
303
|
+
|
|
304
|
+
# Check for mode mismatch between fit and search
|
|
305
|
+
if self._fitted_mode is not None and self._fitted_mode != mode:
|
|
306
|
+
logger.warning(
|
|
307
|
+
f"Search mode changed from {self._fitted_mode.value} to {mode.value} "
|
|
308
|
+
"since last fit. Falling back to TF-IDF. Call fit_async() to reindex."
|
|
309
|
+
)
|
|
310
|
+
# Safe fallback to TF-IDF
|
|
311
|
+
if self._tfidf_backend is not None and not self._tfidf_backend.needs_refit():
|
|
312
|
+
return await self._tfidf_backend.search_async(query, top_k)
|
|
313
|
+
# TF-IDF not available, trigger fallback with reindexing
|
|
314
|
+
await self._fallback_to_tfidf(
|
|
315
|
+
f"Mode changed from {self._fitted_mode.value} to {mode.value}"
|
|
316
|
+
)
|
|
317
|
+
return await self._get_tfidf_backend().search_async(query, top_k)
|
|
318
|
+
|
|
319
|
+
# If we've already fallen back, use TF-IDF
|
|
320
|
+
if self._using_fallback:
|
|
321
|
+
return await self._get_tfidf_backend().search_async(query, top_k)
|
|
322
|
+
|
|
323
|
+
if mode == SearchMode.TFIDF:
|
|
324
|
+
return await self._get_tfidf_backend().search_async(query, top_k)
|
|
325
|
+
|
|
326
|
+
elif mode == SearchMode.EMBEDDING:
|
|
327
|
+
# Verify embedding backend is actually fitted - strict mode, no fallback
|
|
328
|
+
embedding_backend = self._get_embedding_backend()
|
|
329
|
+
if embedding_backend.needs_refit():
|
|
330
|
+
raise RuntimeError(
|
|
331
|
+
"Embedding backend unavailable or needs refit. "
|
|
332
|
+
"Call fit_async() first or use mode='auto' for fallback."
|
|
333
|
+
)
|
|
334
|
+
return await embedding_backend.search_async(query, top_k)
|
|
335
|
+
|
|
336
|
+
elif mode == SearchMode.AUTO:
|
|
337
|
+
# Try embedding, fallback to TF-IDF on error
|
|
338
|
+
embedding_backend = self._get_embedding_backend()
|
|
339
|
+
# Defensively check if embedding backend is fitted
|
|
340
|
+
if embedding_backend.needs_refit():
|
|
341
|
+
logger.warning(
|
|
342
|
+
"Embedding backend needs refit in AUTO mode. Falling back to TF-IDF."
|
|
343
|
+
)
|
|
344
|
+
await self._fallback_to_tfidf("Embedding backend not properly fitted")
|
|
345
|
+
return await self._get_tfidf_backend().search_async(query, top_k)
|
|
346
|
+
try:
|
|
347
|
+
return await embedding_backend.search_async(query, top_k)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
# Fallback to TF-IDF
|
|
350
|
+
await self._fallback_to_tfidf(f"Embedding search failed: {e}", error=e)
|
|
351
|
+
return await self._get_tfidf_backend().search_async(query, top_k)
|
|
352
|
+
|
|
353
|
+
elif mode == SearchMode.HYBRID:
|
|
354
|
+
return await self._search_hybrid(query, top_k)
|
|
355
|
+
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
async def _search_hybrid(
|
|
359
|
+
self,
|
|
360
|
+
query: str,
|
|
361
|
+
top_k: int,
|
|
362
|
+
) -> list[tuple[str, float]]:
|
|
363
|
+
"""Perform hybrid search combining TF-IDF and embedding scores."""
|
|
364
|
+
tfidf_weight, embedding_weight = self._config.get_normalized_weights()
|
|
365
|
+
|
|
366
|
+
# Get TF-IDF results
|
|
367
|
+
tfidf_results = await self._get_tfidf_backend().search_async(query, top_k * 2)
|
|
368
|
+
tfidf_scores = dict(tfidf_results)
|
|
369
|
+
|
|
370
|
+
# Try to get embedding results
|
|
371
|
+
embedding_scores: dict[str, float] = {}
|
|
372
|
+
if self._embedding_backend is not None and not self._using_fallback:
|
|
373
|
+
try:
|
|
374
|
+
embedding_results = await self._embedding_backend.search_async(query, top_k * 2)
|
|
375
|
+
embedding_scores = dict(embedding_results)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.warning(f"Hybrid embedding search failed: {e}")
|
|
378
|
+
self._emit_fallback_event(f"Hybrid search embedding failed: {e}", error=e)
|
|
379
|
+
# Continue with TF-IDF only for this search
|
|
380
|
+
|
|
381
|
+
# Combine scores
|
|
382
|
+
all_ids = set(tfidf_scores.keys()) | set(embedding_scores.keys())
|
|
383
|
+
combined: list[tuple[str, float]] = []
|
|
384
|
+
|
|
385
|
+
for item_id in all_ids:
|
|
386
|
+
tfidf_score = tfidf_scores.get(item_id, 0.0)
|
|
387
|
+
emb_score = embedding_scores.get(item_id, 0.0)
|
|
388
|
+
combined_score = (tfidf_weight * tfidf_score) + (embedding_weight * emb_score)
|
|
389
|
+
combined.append((item_id, combined_score))
|
|
390
|
+
|
|
391
|
+
# Sort by combined score descending
|
|
392
|
+
combined.sort(key=lambda x: x[1], reverse=True)
|
|
393
|
+
|
|
394
|
+
return combined[:top_k]
|
|
395
|
+
|
|
396
|
+
def get_active_backend(self) -> str:
|
|
397
|
+
"""Get the name of the currently active backend.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
One of "tfidf", "embedding", "hybrid", or "none" if not fitted.
|
|
401
|
+
"""
|
|
402
|
+
return self._active_backend or "none"
|
|
403
|
+
|
|
404
|
+
def is_using_fallback(self) -> bool:
|
|
405
|
+
"""Check if search is currently using TF-IDF fallback.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
True if using TF-IDF due to embedding failure.
|
|
409
|
+
"""
|
|
410
|
+
return self._using_fallback
|
|
411
|
+
|
|
412
|
+
def get_fallback_reason(self) -> str | None:
|
|
413
|
+
"""Get the reason for fallback, if any.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Human-readable fallback reason, or None if not using fallback.
|
|
417
|
+
"""
|
|
418
|
+
return self._fallback_reason
|
|
419
|
+
|
|
420
|
+
def needs_refit(self) -> bool:
|
|
421
|
+
"""Check if the search index needs rebuilding.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
True if fit_async() should be called before search_async().
|
|
425
|
+
"""
|
|
426
|
+
if not self._fitted:
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
mode = self._config.get_mode_enum()
|
|
430
|
+
|
|
431
|
+
if mode == SearchMode.TFIDF or self._using_fallback:
|
|
432
|
+
return self._get_tfidf_backend().needs_refit()
|
|
433
|
+
|
|
434
|
+
if mode == SearchMode.EMBEDDING:
|
|
435
|
+
return self._get_embedding_backend().needs_refit()
|
|
436
|
+
|
|
437
|
+
if mode == SearchMode.HYBRID:
|
|
438
|
+
tfidf_needs = self._get_tfidf_backend().needs_refit()
|
|
439
|
+
embedding_needs = (
|
|
440
|
+
self._embedding_backend.needs_refit() if self._embedding_backend else False
|
|
441
|
+
)
|
|
442
|
+
return tfidf_needs or embedding_needs
|
|
443
|
+
|
|
444
|
+
if mode == SearchMode.AUTO:
|
|
445
|
+
# _using_fallback case already handled above in the TFIDF branch
|
|
446
|
+
return self._get_embedding_backend().needs_refit()
|
|
447
|
+
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
def get_stats(self) -> dict[str, Any]:
|
|
451
|
+
"""Get statistics about the search backends.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Dict with unified statistics including active backend info.
|
|
455
|
+
"""
|
|
456
|
+
stats: dict[str, Any] = {
|
|
457
|
+
"mode": self._config.mode,
|
|
458
|
+
"fitted": self._fitted,
|
|
459
|
+
"fitted_mode": self._fitted_mode.value if self._fitted_mode else None,
|
|
460
|
+
"active_backend": self._active_backend,
|
|
461
|
+
"using_fallback": self._using_fallback,
|
|
462
|
+
"fallback_reason": self._fallback_reason,
|
|
463
|
+
"item_count": len(self._items),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if self._tfidf_backend:
|
|
467
|
+
stats["tfidf"] = self._tfidf_backend.get_stats()
|
|
468
|
+
|
|
469
|
+
if self._embedding_backend:
|
|
470
|
+
stats["embedding"] = self._embedding_backend.get_stats()
|
|
471
|
+
|
|
472
|
+
return stats
|
|
473
|
+
|
|
474
|
+
def clear(self) -> None:
|
|
475
|
+
"""Clear all search indexes and reset state."""
|
|
476
|
+
if self._tfidf_backend:
|
|
477
|
+
self._tfidf_backend.clear()
|
|
478
|
+
if self._embedding_backend:
|
|
479
|
+
self._embedding_backend.clear()
|
|
480
|
+
|
|
481
|
+
self._items = []
|
|
482
|
+
self._fitted = False
|
|
483
|
+
self._fitted_mode = None
|
|
484
|
+
self._using_fallback = False
|
|
485
|
+
self._fallback_reason = None
|
|
486
|
+
self._active_backend = None
|
|
487
|
+
|
|
488
|
+
def mark_update(self) -> None:
|
|
489
|
+
"""Mark that an item update occurred.
|
|
490
|
+
|
|
491
|
+
Call this after adding/updating/removing items to track
|
|
492
|
+
when a refit is needed.
|
|
493
|
+
"""
|
|
494
|
+
if self._tfidf_backend:
|
|
495
|
+
self._tfidf_backend.mark_update()
|
|
496
|
+
# Embedding backend tracks updates through fitted state
|
gobby/servers/http.py
CHANGED
|
@@ -62,11 +62,13 @@ class HTTPServer:
|
|
|
62
62
|
memory_manager: "MemoryManager | None" = None,
|
|
63
63
|
llm_service: "LLMService | None" = None,
|
|
64
64
|
memory_sync_manager: Any | None = None,
|
|
65
|
-
task_expander: Any | None = None,
|
|
66
65
|
task_validator: Any | None = None,
|
|
67
66
|
metrics_manager: Any | None = None,
|
|
68
67
|
agent_runner: Any | None = None,
|
|
69
68
|
worktree_storage: Any | None = None,
|
|
69
|
+
clone_storage: Any | None = None,
|
|
70
|
+
git_manager: Any | None = None,
|
|
71
|
+
project_id: str | None = None,
|
|
70
72
|
) -> None:
|
|
71
73
|
"""
|
|
72
74
|
Initialize HTTP server.
|
|
@@ -103,11 +105,13 @@ class HTTPServer:
|
|
|
103
105
|
self.websocket_server = websocket_server
|
|
104
106
|
self.llm_service = llm_service
|
|
105
107
|
self.memory_sync_manager = memory_sync_manager
|
|
106
|
-
self.task_expander = task_expander
|
|
107
108
|
self.task_validator = task_validator
|
|
108
109
|
self.metrics_manager = metrics_manager
|
|
109
110
|
self.agent_runner = agent_runner
|
|
110
111
|
self.worktree_storage = worktree_storage
|
|
112
|
+
self.clone_storage = clone_storage
|
|
113
|
+
self.git_manager = git_manager
|
|
114
|
+
self.project_id = project_id
|
|
111
115
|
|
|
112
116
|
# Initialize WebSocket broadcaster
|
|
113
117
|
# Note: websocket_server might be None if disabled
|
|
@@ -132,7 +136,7 @@ class HTTPServer:
|
|
|
132
136
|
self._mcp_db_manager = mcp_db_manager
|
|
133
137
|
if mcp_manager:
|
|
134
138
|
# Determine WebSocket port
|
|
135
|
-
ws_port =
|
|
139
|
+
ws_port = 60888
|
|
136
140
|
if config and hasattr(config, "websocket") and config.websocket:
|
|
137
141
|
ws_port = config.websocket.port
|
|
138
142
|
|
|
@@ -147,14 +151,17 @@ class HTTPServer:
|
|
|
147
151
|
# Create merge managers if db available
|
|
148
152
|
merge_storage = None
|
|
149
153
|
merge_resolver = None
|
|
154
|
+
inter_session_message_manager = None
|
|
150
155
|
if mcp_db_manager:
|
|
156
|
+
from gobby.storage.inter_session_messages import InterSessionMessageManager
|
|
151
157
|
from gobby.storage.merge_resolutions import MergeResolutionManager
|
|
152
158
|
from gobby.worktrees.merge.resolver import MergeResolver
|
|
153
159
|
|
|
154
160
|
merge_storage = MergeResolutionManager(mcp_db_manager.db)
|
|
155
161
|
merge_resolver = MergeResolver()
|
|
156
162
|
merge_resolver._llm_service = self.llm_service
|
|
157
|
-
|
|
163
|
+
inter_session_message_manager = InterSessionMessageManager(mcp_db_manager.db)
|
|
164
|
+
logger.debug("Merge resolution and inter-session messaging subsystems initialized")
|
|
158
165
|
|
|
159
166
|
# Setup internal registries (gobby-tasks, gobby-memory, etc.)
|
|
160
167
|
self._internal_manager = setup_internal_registries(
|
|
@@ -163,7 +170,6 @@ class HTTPServer:
|
|
|
163
170
|
memory_manager=memory_manager,
|
|
164
171
|
task_manager=task_manager,
|
|
165
172
|
sync_manager=task_sync_manager,
|
|
166
|
-
task_expander=self.task_expander,
|
|
167
173
|
task_validator=self.task_validator,
|
|
168
174
|
message_manager=message_manager,
|
|
169
175
|
local_session_manager=session_manager,
|
|
@@ -171,11 +177,13 @@ class HTTPServer:
|
|
|
171
177
|
llm_service=self.llm_service,
|
|
172
178
|
agent_runner=self.agent_runner,
|
|
173
179
|
worktree_storage=self.worktree_storage,
|
|
174
|
-
|
|
180
|
+
clone_storage=self.clone_storage,
|
|
181
|
+
git_manager=self.git_manager,
|
|
175
182
|
merge_storage=merge_storage,
|
|
176
183
|
merge_resolver=merge_resolver,
|
|
177
|
-
project_id=
|
|
184
|
+
project_id=self.project_id,
|
|
178
185
|
tool_proxy_getter=tool_proxy_getter,
|
|
186
|
+
inter_session_message_manager=inter_session_message_manager,
|
|
179
187
|
)
|
|
180
188
|
registry_count = len(self._internal_manager)
|
|
181
189
|
logger.debug(f"Internal registries initialized: {registry_count} registries")
|
|
@@ -235,6 +243,13 @@ class HTTPServer:
|
|
|
235
243
|
self._metrics = get_metrics_collector()
|
|
236
244
|
self._daemon: Any = None # Set externally by daemon
|
|
237
245
|
|
|
246
|
+
@property
|
|
247
|
+
def tool_proxy(self) -> Any:
|
|
248
|
+
"""Get the ToolProxyService instance for routing tool calls with error enrichment."""
|
|
249
|
+
if self._tools_handler is not None:
|
|
250
|
+
return self._tools_handler.tool_proxy
|
|
251
|
+
return None
|
|
252
|
+
|
|
238
253
|
def _resolve_project_id(self, project_id: str | None, cwd: str | None) -> str:
|
|
239
254
|
"""
|
|
240
255
|
Resolve project_id from cwd if not provided.
|
|
@@ -548,7 +563,7 @@ class HTTPServer:
|
|
|
548
563
|
|
|
549
564
|
|
|
550
565
|
async def create_server(
|
|
551
|
-
port: int =
|
|
566
|
+
port: int = 60887,
|
|
552
567
|
test_mode: bool = False,
|
|
553
568
|
mcp_manager: Any | None = None,
|
|
554
569
|
config: Any | None = None,
|