gobby 0.2.9__py3-none-any.whl → 0.2.11__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 (134) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +2 -2
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +5 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,263 @@
1
+ """Hub manager for coordinating skill hub providers.
2
+
3
+ This module provides the HubManager class which manages hub configurations,
4
+ creates and caches provider instances, and coordinates operations across
5
+ multiple skill hubs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from gobby.skills.hubs.base import HubProvider
15
+
16
+ if TYPE_CHECKING:
17
+ from gobby.config.skills import HubConfig
18
+ from gobby.llm.service import LLMService
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Type alias for provider factory functions
23
+ ProviderFactory = type[HubProvider]
24
+
25
+
26
+ class HubManager:
27
+ """Manages skill hub providers and configurations.
28
+
29
+ The HubManager is responsible for:
30
+ - Storing hub configurations
31
+ - Creating and caching provider instances
32
+ - Resolving API keys for authenticated hubs
33
+ - Providing a unified interface for hub operations
34
+
35
+ Example usage:
36
+ ```python
37
+ configs = {
38
+ "clawdhub": HubConfig(type="clawdhub", base_url="https://clawdhub.com"),
39
+ "skillhub": HubConfig(type="skillhub", auth_key_name="SKILLHUB_KEY"),
40
+ }
41
+ api_keys = {"SKILLHUB_KEY": "secret123"}
42
+
43
+ manager = HubManager(configs=configs, api_keys=api_keys)
44
+
45
+ # List available hubs
46
+ for hub_name in manager.list_hubs():
47
+ print(hub_name)
48
+
49
+ # Get a provider
50
+ provider = manager.get_provider("clawdhub")
51
+ results = await provider.search("commit message")
52
+ ```
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ configs: dict[str, HubConfig] | None = None,
58
+ api_keys: dict[str, str] | None = None,
59
+ llm_service: LLMService | None = None,
60
+ ) -> None:
61
+ """Initialize the hub manager.
62
+
63
+ Args:
64
+ configs: Dictionary of hub configurations keyed by hub name
65
+ api_keys: Dictionary of API keys keyed by key name
66
+ llm_service: Optional LLM service for providers that need it
67
+ """
68
+ self._configs: dict[str, HubConfig] = configs or {}
69
+ self._api_keys: dict[str, str] = api_keys or {}
70
+ self._llm_service = llm_service
71
+ self._providers: dict[str, HubProvider] = {}
72
+ self._factories: dict[str, ProviderFactory] = {}
73
+
74
+ def register_provider_factory(
75
+ self,
76
+ hub_type: str,
77
+ factory: ProviderFactory,
78
+ ) -> None:
79
+ """Register a provider factory for a hub type.
80
+
81
+ Args:
82
+ hub_type: The hub type identifier (e.g., 'clawdhub', 'skillhub')
83
+ factory: The provider class to instantiate for this type
84
+ """
85
+ self._factories[hub_type] = factory
86
+ logger.debug(f"Registered provider factory for hub type: {hub_type}")
87
+
88
+ def list_hubs(self) -> list[str]:
89
+ """List all configured hub names.
90
+
91
+ Returns:
92
+ List of hub names
93
+ """
94
+ return list(self._configs.keys())
95
+
96
+ def has_hub(self, hub_name: str) -> bool:
97
+ """Check if a hub is configured.
98
+
99
+ Args:
100
+ hub_name: Name of the hub to check
101
+
102
+ Returns:
103
+ True if the hub exists, False otherwise
104
+ """
105
+ return hub_name in self._configs
106
+
107
+ def get_config(self, hub_name: str) -> HubConfig:
108
+ """Get the configuration for a hub.
109
+
110
+ Args:
111
+ hub_name: Name of the hub
112
+
113
+ Returns:
114
+ The hub configuration
115
+
116
+ Raises:
117
+ KeyError: If hub is not configured
118
+ """
119
+ if hub_name not in self._configs:
120
+ raise KeyError(f"Unknown hub: {hub_name}")
121
+ return self._configs[hub_name]
122
+
123
+ def _create_provider(self, hub_name: str) -> HubProvider:
124
+ """Create a provider instance for a hub.
125
+
126
+ This is the factory method that instantiates the correct provider
127
+ type based on the hub's configuration. It resolves auth tokens
128
+ and passes all necessary configuration to the provider.
129
+
130
+ Args:
131
+ hub_name: Name of the hub
132
+
133
+ Returns:
134
+ A new provider instance (not cached)
135
+
136
+ Raises:
137
+ KeyError: If hub is not configured
138
+ ValueError: If no factory is registered for the hub type
139
+ """
140
+ if hub_name not in self._configs:
141
+ raise KeyError(f"Unknown hub: {hub_name}")
142
+
143
+ config = self._configs[hub_name]
144
+ factory = self._factories.get(config.type)
145
+
146
+ if factory is None:
147
+ raise ValueError(f"No provider factory registered for hub type: {config.type}")
148
+
149
+ # Resolve auth token from api_keys if auth_key_name is set
150
+ auth_token: str | None = None
151
+ if config.auth_key_name:
152
+ auth_token = self._api_keys.get(config.auth_key_name)
153
+ if auth_token is None:
154
+ logger.warning(
155
+ f"Auth key '{config.auth_key_name}' not found in api_keys for hub '{hub_name}'"
156
+ )
157
+
158
+ # Determine base_url - use config value or derive from hub type
159
+ base_url = config.base_url or ""
160
+
161
+ # Build provider kwargs based on hub type
162
+ kwargs: dict[str, Any] = {
163
+ "hub_name": hub_name,
164
+ "base_url": base_url,
165
+ "auth_token": auth_token,
166
+ }
167
+
168
+ # Add type-specific config
169
+ if config.type == "github-collection":
170
+ kwargs["repo"] = config.repo
171
+ kwargs["branch"] = config.branch or "main"
172
+ kwargs["path"] = config.path
173
+ kwargs["llm_service"] = self._llm_service
174
+
175
+ # Create the provider
176
+ provider = factory(**kwargs)
177
+ logger.debug(f"Created provider for hub: {hub_name} (type: {config.type})")
178
+
179
+ return provider
180
+
181
+ def get_provider(self, hub_name: str) -> HubProvider:
182
+ """Get or create a provider for a hub.
183
+
184
+ Providers are cached after creation, so subsequent calls
185
+ return the same instance.
186
+
187
+ Args:
188
+ hub_name: Name of the hub
189
+
190
+ Returns:
191
+ The provider instance for this hub
192
+
193
+ Raises:
194
+ KeyError: If hub is not configured
195
+ ValueError: If no factory is registered for the hub type
196
+ """
197
+ if hub_name not in self._configs:
198
+ raise KeyError(f"Unknown hub: {hub_name}")
199
+
200
+ # Return cached provider if available
201
+ if hub_name in self._providers:
202
+ return self._providers[hub_name]
203
+
204
+ # Create and cache the provider
205
+ provider = self._create_provider(hub_name)
206
+ self._providers[hub_name] = provider
207
+
208
+ return provider
209
+
210
+ async def search_all(
211
+ self,
212
+ query: str,
213
+ limit: int = 20,
214
+ hub_names: list[str] | None = None,
215
+ ) -> list[dict[str, Any]]:
216
+ """Search across multiple hubs in parallel.
217
+
218
+ Uses asyncio.gather for concurrent searches across all providers,
219
+ improving performance when searching multiple hubs.
220
+
221
+ Args:
222
+ query: Search query string
223
+ limit: Maximum results per hub
224
+ hub_names: Specific hubs to search (None for all)
225
+
226
+ Returns:
227
+ Combined list of results from all hubs
228
+ """
229
+ hubs_to_search = hub_names or self.list_hubs()
230
+
231
+ # Filter to valid hubs only
232
+ valid_hubs = []
233
+ for hub_name in hubs_to_search:
234
+ if not self.has_hub(hub_name):
235
+ logger.warning(f"Skipping unknown hub: {hub_name}")
236
+ else:
237
+ valid_hubs.append(hub_name)
238
+
239
+ if not valid_hubs:
240
+ return []
241
+
242
+ async def search_hub(hub_name: str) -> list[dict[str, Any]]:
243
+ """Search a single hub and return results as dicts."""
244
+ try:
245
+ provider = self.get_provider(hub_name)
246
+ hub_results = await provider.search(query, limit=limit)
247
+ return [r.to_dict() for r in hub_results]
248
+ except Exception as e:
249
+ logger.error(f"Error searching hub {hub_name}: {e}")
250
+ return []
251
+
252
+ # Execute all searches in parallel
253
+ all_results = await asyncio.gather(
254
+ *[search_hub(hub_name) for hub_name in valid_hubs],
255
+ return_exceptions=False,
256
+ )
257
+
258
+ # Flatten results from all hubs
259
+ results: list[dict[str, Any]] = []
260
+ for hub_results in all_results:
261
+ results.extend(hub_results)
262
+
263
+ return results
@@ -0,0 +1,342 @@
1
+ """SkillHub provider implementation.
2
+
3
+ This module provides the SkillHubProvider class which connects to the
4
+ SkillHub REST API for skill search, listing, and download functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import tempfile
11
+ import zipfile
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from gobby.skills.hubs.base import DownloadResult, HubProvider, HubSkillDetails, HubSkillInfo
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class SkillHubProvider(HubProvider):
24
+ """Provider for SkillHub skill registry using REST API.
25
+
26
+ This provider connects to the SkillHub API to provide access to
27
+ skills in the SkillHub registry.
28
+
29
+ Authentication is via Bearer token in the Authorization header.
30
+
31
+ Example usage:
32
+ ```python
33
+ provider = SkillHubProvider(
34
+ hub_name="skillhub",
35
+ base_url="https://skillhub.dev",
36
+ auth_token="sk-your-api-key",
37
+ )
38
+
39
+ results = await provider.search("commit message")
40
+ for skill in results:
41
+ print(f"{skill.slug}: {skill.description}")
42
+ ```
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ hub_name: str,
48
+ base_url: str,
49
+ auth_token: str | None = None,
50
+ ) -> None:
51
+ """Initialize the SkillHub provider.
52
+
53
+ Args:
54
+ hub_name: The configured name for this hub instance
55
+ base_url: Base URL for the SkillHub API
56
+ auth_token: Optional API key for authentication
57
+ """
58
+ super().__init__(hub_name=hub_name, base_url=base_url, auth_token=auth_token)
59
+
60
+ @property
61
+ def provider_type(self) -> str:
62
+ """Return the provider type identifier."""
63
+ return "skillhub"
64
+
65
+ def _get_headers(self) -> dict[str, str]:
66
+ """Get HTTP headers for API requests.
67
+
68
+ Returns:
69
+ Dictionary of headers including Authorization if auth_token is set
70
+ """
71
+ headers: dict[str, str] = {
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json",
74
+ }
75
+
76
+ if self.auth_token:
77
+ headers["Authorization"] = f"Bearer {self.auth_token}"
78
+
79
+ return headers
80
+
81
+ async def _make_request(
82
+ self,
83
+ method: str,
84
+ endpoint: str,
85
+ params: dict[str, Any] | None = None,
86
+ json_data: dict[str, Any] | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Make an HTTP request to the SkillHub API.
89
+
90
+ Args:
91
+ method: HTTP method (GET, POST, etc.)
92
+ endpoint: API endpoint path
93
+ params: Query parameters
94
+ json_data: JSON body data for POST requests
95
+
96
+ Returns:
97
+ Parsed JSON response
98
+
99
+ Raises:
100
+ RuntimeError: If the request fails
101
+ """
102
+ url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
103
+
104
+ async with httpx.AsyncClient() as client:
105
+ try:
106
+ response = await client.request(
107
+ method=method,
108
+ url=url,
109
+ headers=self._get_headers(),
110
+ params=params,
111
+ json=json_data,
112
+ timeout=30.0,
113
+ )
114
+ response.raise_for_status()
115
+ result: dict[str, Any] = response.json()
116
+ return result
117
+ except httpx.HTTPStatusError as e:
118
+ logger.error(f"SkillHub API error: {e.response.status_code}")
119
+ raise RuntimeError(f"SkillHub API error: {e.response.status_code}") from e
120
+ except httpx.RequestError as e:
121
+ logger.error(f"SkillHub request failed: {e}")
122
+ raise RuntimeError(f"SkillHub request failed: {e}") from e
123
+
124
+ async def _download_and_extract(
125
+ self,
126
+ download_url: str,
127
+ target_dir: str | None = None,
128
+ ) -> str:
129
+ """Download and extract a skill from a URL.
130
+
131
+ Downloads a ZIP file from the given URL and extracts it safely,
132
+ preventing path traversal attacks.
133
+
134
+ Args:
135
+ download_url: URL to download the skill ZIP from
136
+ target_dir: Optional target directory to extract to
137
+
138
+ Returns:
139
+ Path to the extracted skill directory
140
+
141
+ Raises:
142
+ RuntimeError: If download or extraction fails
143
+ """
144
+ try:
145
+ async with httpx.AsyncClient() as client:
146
+ response = await client.get(
147
+ download_url,
148
+ headers=self._get_headers(),
149
+ timeout=60.0,
150
+ follow_redirects=True,
151
+ )
152
+ response.raise_for_status()
153
+ zip_content = response.content
154
+
155
+ except httpx.HTTPStatusError as e:
156
+ logger.error(f"Failed to download skill: {e.response.status_code}")
157
+ raise RuntimeError(f"Failed to download skill: {e.response.status_code}") from e
158
+ except httpx.RequestError as e:
159
+ logger.error(f"Download request failed: {e}")
160
+ raise RuntimeError(f"Download request failed: {e}") from e
161
+
162
+ # Determine extraction directory
163
+ if target_dir:
164
+ extract_path = Path(target_dir)
165
+ extract_path.mkdir(parents=True, exist_ok=True)
166
+ else:
167
+ extract_path = Path(tempfile.mkdtemp(prefix="skillhub_"))
168
+
169
+ # Extract ZIP safely
170
+ try:
171
+ zip_buffer = BytesIO(zip_content)
172
+ with zipfile.ZipFile(zip_buffer, "r") as zf:
173
+ # Check for path traversal attacks
174
+ for member in zf.namelist():
175
+ member_path = Path(member)
176
+ # Reject absolute paths or paths with ..
177
+ if member_path.is_absolute() or ".." in member_path.parts:
178
+ raise RuntimeError(f"Unsafe path in ZIP: {member}")
179
+
180
+ # Extract all files
181
+ zf.extractall(extract_path)
182
+
183
+ except zipfile.BadZipFile as e:
184
+ logger.error(f"Invalid ZIP file: {e}")
185
+ raise RuntimeError(f"Invalid ZIP file: {e}") from e
186
+
187
+ return str(extract_path)
188
+
189
+ async def discover(self) -> dict[str, Any]:
190
+ """Discover hub capabilities.
191
+
192
+ Returns:
193
+ Dictionary with hub info
194
+ """
195
+ return {
196
+ "hub_name": self.hub_name,
197
+ "provider_type": self.provider_type,
198
+ "base_url": self.base_url,
199
+ "authenticated": self.auth_token is not None,
200
+ }
201
+
202
+ async def search(
203
+ self,
204
+ query: str,
205
+ limit: int = 20,
206
+ ) -> list[HubSkillInfo]:
207
+ """Search for skills matching a query.
208
+
209
+ Args:
210
+ query: Search query string
211
+ limit: Maximum number of results
212
+
213
+ Returns:
214
+ List of matching skills with basic info
215
+ """
216
+ result = await self._make_request(
217
+ method="POST",
218
+ endpoint="/skills/search",
219
+ json_data={"query": query, "limit": limit},
220
+ )
221
+
222
+ skills = result.get("skills", [])
223
+ return [
224
+ HubSkillInfo(
225
+ slug=skill.get("slug", skill.get("name", "")),
226
+ display_name=skill.get("name", skill.get("slug", "")),
227
+ description=skill.get("description", ""),
228
+ hub_name=self.hub_name,
229
+ version=skill.get("version"),
230
+ score=skill.get("score"),
231
+ )
232
+ for skill in skills
233
+ ]
234
+
235
+ async def list_skills(
236
+ self,
237
+ limit: int = 50,
238
+ offset: int = 0,
239
+ ) -> list[HubSkillInfo]:
240
+ """List available skills from the hub.
241
+
242
+ Args:
243
+ limit: Maximum number of results
244
+ offset: Number of results to skip
245
+
246
+ Returns:
247
+ List of skills with basic info
248
+ """
249
+ result = await self._make_request(
250
+ method="GET",
251
+ endpoint="/skills/catalog",
252
+ params={"limit": limit, "offset": offset},
253
+ )
254
+
255
+ skills = result.get("skills", [])
256
+ return [
257
+ HubSkillInfo(
258
+ slug=skill.get("slug", skill.get("name", "")),
259
+ display_name=skill.get("name", skill.get("slug", "")),
260
+ description=skill.get("description", ""),
261
+ hub_name=self.hub_name,
262
+ version=skill.get("version"),
263
+ )
264
+ for skill in skills
265
+ ]
266
+
267
+ async def get_skill_details(
268
+ self,
269
+ slug: str,
270
+ ) -> HubSkillDetails | None:
271
+ """Get detailed information about a specific skill.
272
+
273
+ Args:
274
+ slug: The skill's unique identifier
275
+
276
+ Returns:
277
+ Detailed skill info, or None if not found
278
+ """
279
+ try:
280
+ result = await self._make_request(
281
+ method="GET",
282
+ endpoint=f"/skills/{slug}",
283
+ )
284
+
285
+ if not result:
286
+ return None
287
+
288
+ return HubSkillDetails(
289
+ slug=result.get("slug", slug),
290
+ display_name=result.get("name", slug),
291
+ description=result.get("description", ""),
292
+ hub_name=self.hub_name,
293
+ version=result.get("version"),
294
+ latest_version=result.get("latest_version", result.get("version")),
295
+ versions=result.get("versions", []),
296
+ )
297
+ except RuntimeError:
298
+ return None
299
+
300
+ async def download_skill(
301
+ self,
302
+ slug: str,
303
+ version: str | None = None,
304
+ target_dir: str | None = None,
305
+ ) -> DownloadResult:
306
+ """Download and extract a skill from the hub.
307
+
308
+ Args:
309
+ slug: The skill's unique identifier
310
+ version: Specific version to download (None for latest)
311
+ target_dir: Directory to extract to
312
+
313
+ Returns:
314
+ DownloadResult with success status, path, version, or error
315
+ """
316
+ # Get download URL from API
317
+ params: dict[str, Any] = {}
318
+ if version:
319
+ params["version"] = version
320
+
321
+ result = await self._make_request(
322
+ method="GET",
323
+ endpoint=f"/skills/{slug}/download",
324
+ params=params if params else None,
325
+ )
326
+
327
+ download_url = result.get("download_url", "")
328
+
329
+ if download_url:
330
+ path = await self._download_and_extract(download_url, target_dir)
331
+ return DownloadResult(
332
+ success=True,
333
+ slug=slug,
334
+ path=path,
335
+ version=result.get("version", version),
336
+ )
337
+
338
+ return DownloadResult(
339
+ success=False,
340
+ slug=slug,
341
+ error="No download URL provided",
342
+ )
gobby/storage/memories.py CHANGED
@@ -145,11 +145,11 @@ class LocalMemoryManager:
145
145
 
146
146
  now = datetime.now(UTC).isoformat()
147
147
  # Normalize content for consistent ID generation (avoid duplicates from
148
- # whitespace differences or project_id inconsistency)
148
+ # whitespace differences)
149
149
  normalized_content = content.strip()
150
- project_str = project_id if project_id else ""
151
- # Use delimiter to prevent collisions (e.g., "abc" + "def" vs "abcd" + "ef")
152
- memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
150
+ # Global dedup: ID based on content only (project_id stored but not in ID)
151
+ # This aligns with content_exists() which checks globally
152
+ memory_id = generate_prefixed_id("mm", normalized_content)
153
153
 
154
154
  # Check if memory already exists to avoid duplicate insert errors
155
155
  existing_row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))