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/skills/search.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Skill search using unified search backend.
|
|
2
|
+
|
|
3
|
+
This module provides skill search functionality using the UnifiedSearcher
|
|
4
|
+
for TF-IDF, embedding, or hybrid search with automatic fallback.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Indexes skills by name, description, tags, and category
|
|
8
|
+
- Post-search filtering by category and tags
|
|
9
|
+
- Automatic fallback from embedding to TF-IDF when API unavailable
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from gobby.search import SearchConfig, UnifiedSearcher
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from gobby.storage.skills import Skill
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SearchFilters:
|
|
28
|
+
"""Filters to apply to search results.
|
|
29
|
+
|
|
30
|
+
Filters are applied AFTER similarity ranking, so results maintain
|
|
31
|
+
their relevance ordering within the filtered set.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
category: Filter by skill category (exact match)
|
|
35
|
+
tags_any: Filter to skills with ANY of these tags
|
|
36
|
+
tags_all: Filter to skills with ALL of these tags
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
category: str | None = None
|
|
40
|
+
tags_any: list[str] | None = None
|
|
41
|
+
tags_all: list[str] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SkillSearchResult:
|
|
46
|
+
"""A search result containing a skill ID and relevance score.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
skill_id: ID of the matching skill
|
|
50
|
+
skill_name: Name of the matching skill (for display)
|
|
51
|
+
similarity: Relevance score in range [0, 1]
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
skill_id: str
|
|
55
|
+
skill_name: str
|
|
56
|
+
similarity: float
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
"""Convert to dictionary representation."""
|
|
60
|
+
return {
|
|
61
|
+
"skill_id": self.skill_id,
|
|
62
|
+
"skill_name": self.skill_name,
|
|
63
|
+
"similarity": self.similarity,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _SkillMeta:
|
|
69
|
+
"""Internal metadata about a skill for filtering."""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
category: str | None
|
|
73
|
+
tags: list[str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SkillSearch:
|
|
77
|
+
"""Search skills using unified search with automatic fallback.
|
|
78
|
+
|
|
79
|
+
Uses UnifiedSearcher to provide skill search with:
|
|
80
|
+
- TF-IDF mode (always available)
|
|
81
|
+
- Embedding mode (requires API key)
|
|
82
|
+
- Auto mode (embedding with TF-IDF fallback)
|
|
83
|
+
- Hybrid mode (combines both with weighted scores)
|
|
84
|
+
|
|
85
|
+
Example usage:
|
|
86
|
+
```python
|
|
87
|
+
from gobby.skills.search import SkillSearch
|
|
88
|
+
from gobby.search import SearchConfig
|
|
89
|
+
|
|
90
|
+
# Basic auto mode (embedding with fallback)
|
|
91
|
+
config = SearchConfig(mode="auto")
|
|
92
|
+
search = SkillSearch(config)
|
|
93
|
+
await search.index_skills_async(skills)
|
|
94
|
+
results = await search.search_async("git commit", top_k=5)
|
|
95
|
+
|
|
96
|
+
# Check if using fallback
|
|
97
|
+
if search.is_using_fallback():
|
|
98
|
+
print(f"Using TF-IDF fallback: {search.get_fallback_reason()}")
|
|
99
|
+
```
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
config: SearchConfig | None = None,
|
|
105
|
+
refit_threshold: int = 10,
|
|
106
|
+
):
|
|
107
|
+
"""Initialize skill search.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
config: Search configuration (defaults to auto mode)
|
|
111
|
+
refit_threshold: Number of updates before automatic refit
|
|
112
|
+
"""
|
|
113
|
+
if config is None:
|
|
114
|
+
config = SearchConfig(mode="auto")
|
|
115
|
+
|
|
116
|
+
self._config = config
|
|
117
|
+
self._refit_threshold = refit_threshold
|
|
118
|
+
|
|
119
|
+
# Initialize unified searcher
|
|
120
|
+
self._searcher = UnifiedSearcher(self._config)
|
|
121
|
+
|
|
122
|
+
# Skill metadata tracking
|
|
123
|
+
self._skill_names: dict[str, str] = {} # skill_id -> skill_name
|
|
124
|
+
self._skill_meta: dict[str, _SkillMeta] = {} # skill_id -> metadata
|
|
125
|
+
self._skill_items: list[tuple[str, str]] = [] # (skill_id, content) for reindexing
|
|
126
|
+
|
|
127
|
+
# State tracking
|
|
128
|
+
self._indexed = False
|
|
129
|
+
self._pending_updates = 0
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def mode(self) -> str:
|
|
133
|
+
"""Return the current search mode."""
|
|
134
|
+
return self._config.mode
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def tfidf_weight(self) -> float:
|
|
138
|
+
"""Return the TF-IDF weight for hybrid search."""
|
|
139
|
+
return self._config.tfidf_weight
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def embedding_weight(self) -> float:
|
|
143
|
+
"""Return the embedding weight for hybrid search."""
|
|
144
|
+
return self._config.embedding_weight
|
|
145
|
+
|
|
146
|
+
def _build_search_content(self, skill: Skill) -> str:
|
|
147
|
+
"""Build searchable content from skill fields.
|
|
148
|
+
|
|
149
|
+
Combines name, description, tags, and category into a single
|
|
150
|
+
string for indexing.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
skill: Skill to extract content from
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Combined search content string
|
|
157
|
+
"""
|
|
158
|
+
parts = [
|
|
159
|
+
skill.name,
|
|
160
|
+
skill.description,
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# Add tags from metadata
|
|
164
|
+
tags = skill.get_tags()
|
|
165
|
+
if tags:
|
|
166
|
+
parts.extend(tags)
|
|
167
|
+
|
|
168
|
+
# Add category from metadata
|
|
169
|
+
category = skill.get_category()
|
|
170
|
+
if category:
|
|
171
|
+
parts.append(category)
|
|
172
|
+
|
|
173
|
+
return " ".join(parts)
|
|
174
|
+
|
|
175
|
+
def index_skills(self, skills: list[Skill]) -> None:
|
|
176
|
+
"""Build search index from skills (sync wrapper).
|
|
177
|
+
|
|
178
|
+
For async usage, prefer index_skills_async() instead.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
skills: List of skills to index
|
|
182
|
+
"""
|
|
183
|
+
import asyncio
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
loop = asyncio.get_running_loop()
|
|
187
|
+
except RuntimeError:
|
|
188
|
+
loop = None
|
|
189
|
+
|
|
190
|
+
if loop and loop.is_running():
|
|
191
|
+
# Can't use asyncio.run() inside a running loop
|
|
192
|
+
# Use ThreadPoolExecutor to run in a separate thread and block until complete
|
|
193
|
+
import concurrent.futures
|
|
194
|
+
|
|
195
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
196
|
+
# Defer coroutine construction to the executor thread
|
|
197
|
+
future = executor.submit(lambda: asyncio.run(self.index_skills_async(skills)))
|
|
198
|
+
future.result()
|
|
199
|
+
else:
|
|
200
|
+
asyncio.run(self.index_skills_async(skills))
|
|
201
|
+
|
|
202
|
+
async def index_skills_async(self, skills: list[Skill]) -> None:
|
|
203
|
+
"""Build search index from skills.
|
|
204
|
+
|
|
205
|
+
Indexes skills using the configured search mode (auto, tfidf,
|
|
206
|
+
embedding, or hybrid).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
skills: List of skills to index
|
|
210
|
+
"""
|
|
211
|
+
if not skills:
|
|
212
|
+
self._skill_names.clear()
|
|
213
|
+
self._skill_meta.clear()
|
|
214
|
+
self._skill_items = []
|
|
215
|
+
self._indexed = False
|
|
216
|
+
self._pending_updates = 0
|
|
217
|
+
self._searcher.clear()
|
|
218
|
+
logger.debug("Skill search index cleared (no skills)")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
# Build (skill_id, content) tuples and metadata
|
|
222
|
+
items: list[tuple[str, str]] = []
|
|
223
|
+
self._skill_names.clear()
|
|
224
|
+
self._skill_meta.clear()
|
|
225
|
+
|
|
226
|
+
for skill in skills:
|
|
227
|
+
content = self._build_search_content(skill)
|
|
228
|
+
items.append((skill.id, content))
|
|
229
|
+
self._skill_names[skill.id] = skill.name
|
|
230
|
+
self._skill_meta[skill.id] = _SkillMeta(
|
|
231
|
+
name=skill.name,
|
|
232
|
+
category=skill.get_category(),
|
|
233
|
+
tags=skill.get_tags(),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Store for potential reindexing
|
|
237
|
+
self._skill_items = items
|
|
238
|
+
|
|
239
|
+
# Index using unified searcher
|
|
240
|
+
await self._searcher.fit_async(items)
|
|
241
|
+
self._indexed = True
|
|
242
|
+
self._pending_updates = 0
|
|
243
|
+
logger.info(f"Skill search index built with {len(skills)} skills")
|
|
244
|
+
|
|
245
|
+
async def search_async(
|
|
246
|
+
self,
|
|
247
|
+
query: str,
|
|
248
|
+
top_k: int = 10,
|
|
249
|
+
filters: SearchFilters | None = None,
|
|
250
|
+
) -> list[SkillSearchResult]:
|
|
251
|
+
"""Search for skills matching the query.
|
|
252
|
+
|
|
253
|
+
Uses the configured search mode with automatic fallback.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
query: Search query text
|
|
257
|
+
top_k: Maximum number of results to return
|
|
258
|
+
filters: Optional filters to apply after ranking
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of SkillSearchResult objects, sorted by similarity descending
|
|
262
|
+
"""
|
|
263
|
+
if not self._indexed:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
# Get more results than top_k if filtering
|
|
267
|
+
search_limit = top_k * 3 if filters else top_k
|
|
268
|
+
raw_results = await self._searcher.search_async(query, top_k=search_limit)
|
|
269
|
+
|
|
270
|
+
# Build results with filtering
|
|
271
|
+
results = []
|
|
272
|
+
for skill_id, similarity in raw_results:
|
|
273
|
+
if filters and not self._passes_filters(skill_id, filters):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
skill_name = self._skill_names.get(skill_id, skill_id)
|
|
277
|
+
results.append(
|
|
278
|
+
SkillSearchResult(
|
|
279
|
+
skill_id=skill_id,
|
|
280
|
+
skill_name=skill_name,
|
|
281
|
+
similarity=similarity,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if len(results) >= top_k:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
return results
|
|
289
|
+
|
|
290
|
+
def search(
|
|
291
|
+
self,
|
|
292
|
+
query: str,
|
|
293
|
+
top_k: int = 10,
|
|
294
|
+
filters: SearchFilters | None = None,
|
|
295
|
+
) -> list[SkillSearchResult]:
|
|
296
|
+
"""Search for skills matching the query (sync wrapper).
|
|
297
|
+
|
|
298
|
+
For async usage, prefer search_async().
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
query: Search query text
|
|
302
|
+
top_k: Maximum number of results to return
|
|
303
|
+
filters: Optional filters to apply after ranking
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of SkillSearchResult objects, sorted by similarity descending
|
|
307
|
+
"""
|
|
308
|
+
import asyncio
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
loop = asyncio.get_running_loop()
|
|
312
|
+
except RuntimeError:
|
|
313
|
+
loop = None
|
|
314
|
+
|
|
315
|
+
if loop and loop.is_running():
|
|
316
|
+
# Can't use asyncio.run() inside a running loop
|
|
317
|
+
# This is a best-effort sync wrapper; prefer search_async() in async contexts
|
|
318
|
+
import concurrent.futures
|
|
319
|
+
|
|
320
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
321
|
+
# Defer coroutine construction to the executor thread
|
|
322
|
+
future = executor.submit(
|
|
323
|
+
lambda: asyncio.run(self.search_async(query, top_k, filters))
|
|
324
|
+
)
|
|
325
|
+
return future.result()
|
|
326
|
+
else:
|
|
327
|
+
return asyncio.run(self.search_async(query, top_k, filters))
|
|
328
|
+
|
|
329
|
+
def _passes_filters(self, skill_id: str, filters: SearchFilters) -> bool:
|
|
330
|
+
"""Check if a skill passes the given filters.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
skill_id: ID of the skill to check
|
|
334
|
+
filters: Filters to apply
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
True if skill passes all filters
|
|
338
|
+
"""
|
|
339
|
+
meta = self._skill_meta.get(skill_id)
|
|
340
|
+
if not meta:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
# Check category filter
|
|
344
|
+
if filters.category is not None:
|
|
345
|
+
if meta.category != filters.category:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
# Check tags_any filter (skill must have at least one of the tags)
|
|
349
|
+
if filters.tags_any is not None:
|
|
350
|
+
if not any(tag in meta.tags for tag in filters.tags_any):
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
# Check tags_all filter (skill must have all of the tags)
|
|
354
|
+
if filters.tags_all is not None:
|
|
355
|
+
if not all(tag in meta.tags for tag in filters.tags_all):
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def add_skill(self, skill: Skill) -> None:
|
|
361
|
+
"""Mark that a skill was added (requires reindex).
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
skill: The skill that was added
|
|
365
|
+
"""
|
|
366
|
+
self._pending_updates += 1
|
|
367
|
+
self._skill_names[skill.id] = skill.name
|
|
368
|
+
self._skill_meta[skill.id] = _SkillMeta(
|
|
369
|
+
name=skill.name,
|
|
370
|
+
category=skill.get_category(),
|
|
371
|
+
tags=skill.get_tags(),
|
|
372
|
+
)
|
|
373
|
+
self._searcher.mark_update()
|
|
374
|
+
|
|
375
|
+
def update_skill(self, skill: Skill) -> None:
|
|
376
|
+
"""Mark that a skill was updated (requires reindex).
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
skill: The skill that was updated
|
|
380
|
+
"""
|
|
381
|
+
self._pending_updates += 1
|
|
382
|
+
self._skill_names[skill.id] = skill.name
|
|
383
|
+
self._skill_meta[skill.id] = _SkillMeta(
|
|
384
|
+
name=skill.name,
|
|
385
|
+
category=skill.get_category(),
|
|
386
|
+
tags=skill.get_tags(),
|
|
387
|
+
)
|
|
388
|
+
self._searcher.mark_update()
|
|
389
|
+
|
|
390
|
+
def remove_skill(self, skill_id: str) -> None:
|
|
391
|
+
"""Mark that a skill was removed (requires reindex).
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
skill_id: ID of the skill that was removed
|
|
395
|
+
"""
|
|
396
|
+
self._pending_updates += 1
|
|
397
|
+
self._skill_names.pop(skill_id, None)
|
|
398
|
+
self._skill_meta.pop(skill_id, None)
|
|
399
|
+
self._searcher.mark_update()
|
|
400
|
+
|
|
401
|
+
def needs_reindex(self) -> bool:
|
|
402
|
+
"""Check if the search index needs rebuilding.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
True if index_skills() should be called
|
|
406
|
+
"""
|
|
407
|
+
if not self._indexed:
|
|
408
|
+
return True
|
|
409
|
+
return self._pending_updates >= self._refit_threshold or self._searcher.needs_refit()
|
|
410
|
+
|
|
411
|
+
def is_using_fallback(self) -> bool:
|
|
412
|
+
"""Check if search is using TF-IDF fallback.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
True if using TF-IDF due to embedding failure
|
|
416
|
+
"""
|
|
417
|
+
return self._searcher.is_using_fallback()
|
|
418
|
+
|
|
419
|
+
def get_fallback_reason(self) -> str | None:
|
|
420
|
+
"""Get the reason for fallback, if any.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Human-readable fallback reason, or None if not using fallback
|
|
424
|
+
"""
|
|
425
|
+
return self._searcher.get_fallback_reason()
|
|
426
|
+
|
|
427
|
+
def get_active_backend(self) -> str:
|
|
428
|
+
"""Get the name of the currently active backend.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
One of "tfidf", "embedding", "hybrid", or "none"
|
|
432
|
+
"""
|
|
433
|
+
return self._searcher.get_active_backend()
|
|
434
|
+
|
|
435
|
+
def get_stats(self) -> dict[str, Any]:
|
|
436
|
+
"""Get statistics about the search index.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Dict with index statistics
|
|
440
|
+
"""
|
|
441
|
+
stats: dict[str, Any] = {
|
|
442
|
+
"indexed": self._indexed,
|
|
443
|
+
"skill_count": len(self._skill_names),
|
|
444
|
+
"pending_updates": self._pending_updates,
|
|
445
|
+
"refit_threshold": self._refit_threshold,
|
|
446
|
+
"active_backend": self.get_active_backend(),
|
|
447
|
+
"using_fallback": self.is_using_fallback(),
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Add unified searcher stats
|
|
451
|
+
searcher_stats = self._searcher.get_stats()
|
|
452
|
+
stats.update(searcher_stats)
|
|
453
|
+
|
|
454
|
+
return stats
|
|
455
|
+
|
|
456
|
+
def clear(self) -> None:
|
|
457
|
+
"""Clear the search index."""
|
|
458
|
+
self._searcher.clear()
|
|
459
|
+
self._skill_names.clear()
|
|
460
|
+
self._skill_meta.clear()
|
|
461
|
+
self._skill_items = []
|
|
462
|
+
self._indexed = False
|
|
463
|
+
self._pending_updates = 0
|
gobby/skills/sync.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Skill synchronization for bundled skills.
|
|
2
|
+
|
|
3
|
+
This module provides sync_bundled_skills() which loads skills from the
|
|
4
|
+
bundled install/shared/skills/ directory and syncs them to the database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from gobby.skills.loader import SkillLoader
|
|
12
|
+
from gobby.storage.database import DatabaseProtocol
|
|
13
|
+
from gobby.storage.skills import LocalSkillManager
|
|
14
|
+
|
|
15
|
+
__all__ = ["sync_bundled_skills", "get_bundled_skills_path"]
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_bundled_skills_path() -> Path:
|
|
21
|
+
"""Get the path to bundled skills directory.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path to src/gobby/install/shared/skills/
|
|
25
|
+
"""
|
|
26
|
+
# Navigate from this file to install/shared/skills/
|
|
27
|
+
# This file: src/gobby/skills/sync.py
|
|
28
|
+
# Target: src/gobby/install/shared/skills/
|
|
29
|
+
return Path(__file__).parent.parent / "install" / "shared" / "skills"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
|
|
33
|
+
"""Sync bundled skills from install/shared/skills/ to the database.
|
|
34
|
+
|
|
35
|
+
This function:
|
|
36
|
+
1. Loads all skills from the bundled skills directory
|
|
37
|
+
2. For each skill, checks if it already exists in the database
|
|
38
|
+
3. If not, creates it with source_type='filesystem' and project_id=None (global)
|
|
39
|
+
4. If exists, skips it (idempotent)
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
db: Database connection
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dict with success status and counts:
|
|
46
|
+
- success: bool
|
|
47
|
+
- synced: int - number of skills added
|
|
48
|
+
- skipped: int - number of skills already present
|
|
49
|
+
- errors: list[str] - any error messages
|
|
50
|
+
"""
|
|
51
|
+
skills_path = get_bundled_skills_path()
|
|
52
|
+
|
|
53
|
+
result: dict[str, Any] = {
|
|
54
|
+
"success": True,
|
|
55
|
+
"synced": 0,
|
|
56
|
+
"skipped": 0,
|
|
57
|
+
"errors": [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if not skills_path.exists():
|
|
61
|
+
logger.warning(f"Bundled skills path not found: {skills_path}")
|
|
62
|
+
result["errors"].append(f"Skills path not found: {skills_path}")
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
# Load skills using SkillLoader with 'filesystem' source type
|
|
66
|
+
loader = SkillLoader(default_source_type="filesystem")
|
|
67
|
+
storage = LocalSkillManager(db)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# validate=False for bundled skills since they're trusted and may have
|
|
71
|
+
# version formats like "2.0" instead of strict semver "2.0.0"
|
|
72
|
+
parsed_skills = loader.load_directory(skills_path, validate=False)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to load bundled skills: {e}")
|
|
75
|
+
result["success"] = False
|
|
76
|
+
result["errors"].append(f"Failed to load skills: {e}")
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
for parsed in parsed_skills:
|
|
80
|
+
try:
|
|
81
|
+
# Check if skill already exists (global scope)
|
|
82
|
+
existing = storage.get_by_name(parsed.name, project_id=None)
|
|
83
|
+
|
|
84
|
+
if existing is not None:
|
|
85
|
+
logger.debug(f"Skill '{parsed.name}' already exists, skipping")
|
|
86
|
+
result["skipped"] += 1
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Create the skill in the database
|
|
90
|
+
storage.create_skill(
|
|
91
|
+
name=parsed.name,
|
|
92
|
+
description=parsed.description,
|
|
93
|
+
content=parsed.content,
|
|
94
|
+
version=parsed.version,
|
|
95
|
+
license=parsed.license,
|
|
96
|
+
compatibility=parsed.compatibility,
|
|
97
|
+
allowed_tools=parsed.allowed_tools,
|
|
98
|
+
metadata=parsed.metadata,
|
|
99
|
+
source_path=parsed.source_path,
|
|
100
|
+
source_type="filesystem",
|
|
101
|
+
source_ref=None,
|
|
102
|
+
project_id=None, # Global scope
|
|
103
|
+
enabled=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
logger.info(f"Synced bundled skill: {parsed.name}")
|
|
107
|
+
result["synced"] += 1
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
error_msg = f"Failed to sync skill '{parsed.name}': {e}"
|
|
111
|
+
logger.error(error_msg)
|
|
112
|
+
result["errors"].append(error_msg)
|
|
113
|
+
|
|
114
|
+
total = result["synced"] + result["skipped"]
|
|
115
|
+
logger.info(
|
|
116
|
+
f"Skill sync complete: {result['synced']} synced, {result['skipped']} skipped, {total} total"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return result
|