gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.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