gobby 0.2.5__py3-none-any.whl → 0.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
gobby/tasks/research.py DELETED
@@ -1,421 +0,0 @@
1
- """
2
- Agentic codebase research for task expansion.
3
- """
4
-
5
- import ast
6
- import logging
7
- import os
8
- import re
9
- import shlex
10
- from pathlib import Path
11
- from typing import Any
12
-
13
- from gobby.config.app import TaskExpansionConfig
14
- from gobby.llm import LLMService
15
- from gobby.storage.tasks import Task
16
- from gobby.utils.project_context import find_project_root
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class TaskResearchAgent:
22
- """
23
- Agent that autonomously researches the codebase to gather context for a task.
24
-
25
- Implements a simple ReAct loop:
26
- 1. Think: Analyze current context and decide next action
27
- 2. Act: Execute a tool (glob, grep, read_file)
28
- 3. Observe: Add tool output to context
29
- 4. Repeat until done or timeout
30
- """
31
-
32
- def __init__(
33
- self,
34
- config: TaskExpansionConfig,
35
- llm_service: LLMService,
36
- mcp_manager: Any | None = None,
37
- ):
38
- self.config = config
39
- self.llm_service = llm_service
40
- self.mcp_manager = mcp_manager
41
- self.max_steps = 10
42
- self.root = find_project_root()
43
- # Search tool discovery happens effectively at runtime now via _build_prompt
44
- # but we keep the helper method if we want to pre-check.
45
-
46
- async def run(
47
- self,
48
- task: Task,
49
- enable_web_search: bool = False,
50
- ) -> dict[str, Any]:
51
- """
52
- Run the research loop.
53
-
54
- Args:
55
- task: The task to research.
56
-
57
- Returns:
58
- Dictionary containing gathered context (files, snippets, findings).
59
- """
60
- if not self.root:
61
- logger.warning("No project root found, skipping research")
62
- return {"relevant_files": [], "findings": "No project root found"}
63
-
64
- logger.info(f"Starting research for task {task.id}: {task.title}")
65
-
66
- # Initialize context
67
- context: dict[str, Any] = {
68
- "task": task,
69
- "history": [],
70
- "found_files": set(),
71
- "snippets": {},
72
- }
73
-
74
- # Select provider (use research_model if configured, else default)
75
- model = self.config.research_model or self.config.model
76
- provider = self.llm_service.get_provider(self.config.provider)
77
-
78
- for step in range(self.max_steps):
79
- # 1. Generate Thought/Action
80
- prompt = await self._build_step_prompt(context, step, enable_web_search) # Made async
81
- response = await provider.generate_text(
82
- prompt=prompt,
83
- system_prompt=self.config.research_system_prompt,
84
- model=model,
85
- )
86
-
87
- # Parse action
88
- action = self._parse_action(response)
89
- context["history"].append(
90
- {"role": "model", "content": response, "parsed_action": action}
91
- )
92
-
93
- if not action or action["tool"] == "done":
94
- reason = action.get("reason", "No action") if action else "Failed to parse action"
95
- logger.info(f"Research finished: {reason}")
96
- break
97
-
98
- # 2. Execute Action
99
- tool_output = await self._execute_tool(action)
100
-
101
- # 3. Observe
102
- context["history"].append({"role": "tool", "content": tool_output})
103
- logger.debug(f"Step {step} tool {action['tool']} output len: {len(tool_output)}")
104
-
105
- return self._summarize_results(context)
106
-
107
- async def _build_step_prompt(
108
- self,
109
- context: dict[str, Any],
110
- step: int,
111
- enable_web_search: bool = False,
112
- ) -> str:
113
- task = context["task"]
114
- history = context["history"]
115
-
116
- prompt = f"""Task: {task.title}
117
- Description: {task.description}
118
-
119
- You are researching this task to identify relevant files and implementation details.
120
- You have access to the following tools:
121
-
122
- 1. glob(pattern): Find files matching a pattern (e.g. "src/**/*.py")
123
- 2. grep(pattern, path): Search for text in files (e.g. "def login", "src/")
124
- 3. read_file(path): Read the content of a file
125
- 4. done(reason): Finish research
126
- """
127
- # Add search tool if available and enabled
128
- # Check both config global enable AND request-specific enable
129
- # Note: config.web_research_enabled is the global "allowed" switch.
130
- # enable_web_search is the per-request "requested" switch.
131
- # We need BOTH to be true.
132
- can_use_search = self.config.web_research_enabled and enable_web_search
133
-
134
- if self.mcp_manager and can_use_search:
135
- # Dynamically check for search tool
136
- # We prefer 'search_web' if available, else others
137
- tools = await self.mcp_manager.list_tools() # Assuming this API
138
- # Flatten tools list
139
- all_tools = []
140
- for _server, server_tools in tools.items():
141
- all_tools.extend(server_tools)
142
-
143
- for t in all_tools:
144
- if t.name in ("search_web", "google_search", "brave_search"):
145
- prompt += f"5. {t.name}(query): {t.description[:100]}...\n"
146
- break
147
-
148
- prompt += f"""
149
- Current Context:
150
- Found Files: {list(context["found_files"])}
151
- Snippets: {list(context["snippets"].keys())}
152
-
153
- History:
154
- """
155
- # Add limited history (last 5 turns to save context)
156
- recent_history = history[-5:]
157
- for item in recent_history:
158
- if item["role"] == "model":
159
- prompt += f"Agent: {item['content']}\n"
160
- elif item["role"] == "tool":
161
- # Truncate tool output
162
- content = item["content"]
163
- if len(content) > 500:
164
- content = content[:500] + "... (truncated)"
165
- prompt += f"Tool: {content}\n"
166
-
167
- prompt += f"\nStep {step + 1}/{self.max_steps}. What is your next move? Respond with THOUGHT followed by ACTION."
168
- return prompt
169
-
170
- def _parse_action(self, response: str) -> dict[str, Any] | None:
171
- """
172
- Parse LLM response for ACTION: tool_name(args).
173
-
174
- Uses multiple parsing strategies in order of robustness:
175
- 1. ast.literal_eval for Python-style tuple syntax
176
- 2. shlex for shell-like quoting (handles commas in quotes)
177
- 3. Simple comma split as last resort
178
- """
179
- # Check for explicit "ACTION: done" first (tighter than substring match)
180
- # Matches: "ACTION: done", "ACTION: done(reason)", "ACTION: done("reason")"
181
- done_match = re.search(
182
- r"^ACTION:\s*done(?:\s*\(([^)]*)\))?",
183
- response,
184
- re.IGNORECASE | re.MULTILINE,
185
- )
186
- if done_match:
187
- reason = done_match.group(1)
188
- if reason:
189
- reason = reason.strip().strip("'\"")
190
- return {"tool": "done", "reason": reason or response}
191
-
192
- # Parse ACTION: tool_name(args) pattern
193
- # Use DOTALL to handle args spanning multiple lines
194
- match = re.search(r"ACTION:\s*(\w+)\((.*)\)", response, re.IGNORECASE | re.DOTALL)
195
- if not match:
196
- return None
197
-
198
- tool = match.group(1).lower()
199
- args_str = match.group(2).strip()
200
-
201
- # Handle done tool explicitly (in case it matched the general pattern)
202
- if tool == "done":
203
- return {"tool": "done", "reason": args_str.strip("'\"") or response}
204
-
205
- # If no args, return empty args list
206
- if not args_str:
207
- return {"tool": tool, "args": []}
208
-
209
- # Try multiple parsing strategies in order of robustness
210
- args = None
211
- parse_errors = []
212
-
213
- # Strategy 1: ast.literal_eval as tuple
214
- # Handles: "arg1", "arg2" → ('arg1', 'arg2')
215
- # Handles escaped quotes, nested structures, etc.
216
- try:
217
- # Wrap in parens with trailing comma to make it a tuple
218
- parsed = ast.literal_eval(f"({args_str},)")
219
- args = [str(a) for a in parsed]
220
- except (ValueError, SyntaxError) as e:
221
- parse_errors.append(f"ast.literal_eval: {e}")
222
-
223
- # Strategy 2: shlex-based parsing (handles shell-like quoting)
224
- # Handles: "arg with spaces", 'single quotes', arg\ with\ escapes
225
- if args is None:
226
- try:
227
- lexer = shlex.shlex(args_str, posix=True)
228
- lexer.whitespace = ","
229
- lexer.whitespace_split = True
230
- args = [token.strip() for token in lexer]
231
- except ValueError as e:
232
- parse_errors.append(f"shlex: {e}")
233
-
234
- # Strategy 3: Simple comma split as last resort
235
- if args is None:
236
- args = [a.strip().strip("'\"") for a in args_str.split(",")]
237
- if not args or all(not a for a in args):
238
- logger.error(
239
- f"All parsing strategies failed for args: {args_str!r}. Errors: {parse_errors}"
240
- )
241
- return None
242
-
243
- if parse_errors:
244
- logger.debug(
245
- f"Argument parsing recovered after failures: {parse_errors}. Final args: {args}"
246
- )
247
-
248
- return {"tool": tool, "args": args}
249
-
250
- async def _execute_tool(self, action: dict[str, Any]) -> str:
251
- tool = action["tool"]
252
- args = action.get("args", [])
253
-
254
- try:
255
- if tool == "glob":
256
- if not args:
257
- return "Error: Missing pattern"
258
- return self._glob(args[0])
259
- elif tool == "grep":
260
- if len(args) < 2:
261
- return "Error: Missing pattern or path"
262
- return self._grep(args[0], args[1])
263
- elif tool == "read_file":
264
- if not args:
265
- return "Error: Missing path"
266
- return self._read_file(args[0])
267
- elif tool == "done":
268
- return "Done"
269
-
270
- # Check for MCP search tools
271
- # We strictly check if the tool is one of the search tools we support
272
- # The enable_web_search check was done at prompt time, but good to enforce here too
273
- # However, execute_tool doesn't receive the flag currently.
274
- # We rely on the model only calling it if presented in prompt.
275
- if self.mcp_manager:
276
- if tool in ("search_web", "google_search", "brave_search"):
277
- if not args:
278
- return "Error: Missing query"
279
- # Call via MCP manager
280
- # self.mcp_manager.call_tool returns Result object or dict
281
- result = await self.mcp_manager.call_tool(tool, {"query": args[0]})
282
- # Format result - assume it returns text or structured content
283
- return str(result)
284
-
285
- return f"Error: Unknown tool {tool}"
286
- except Exception as e:
287
- return f"Error executing {tool}: {e}"
288
-
289
- def _glob(self, pattern: str) -> str:
290
- if not self.root:
291
- return "No root"
292
- # Security: ensure pattern doesn't traverse up
293
- if ".." in pattern:
294
- return "Error: .. not allowed"
295
-
296
- matches = []
297
- try:
298
- # Use rglob if ** in pattern, else glob
299
- # Simplified: Use fnmatch on all files walking from root (safer but slower)
300
- # Or use pathlib.glob
301
- # Let's use pathlib glob
302
- for path in self.root.glob(pattern):
303
- if path.is_file():
304
- matches.append(str(path.relative_to(self.root)))
305
- if len(matches) > 50: # Limit results
306
- break
307
- except Exception as e:
308
- return f"Glob error: {e}"
309
-
310
- return "\n".join(matches) or "No matches found"
311
-
312
- def _grep(self, pattern: str, path_str: str) -> str:
313
- if not self.root:
314
- return "No root"
315
- search_path = (self.root / path_str).resolve()
316
- if self.root not in search_path.parents and search_path != self.root:
317
- return "Error: Path outside root"
318
-
319
- # Simple recursive grep
320
- # Limit to text files
321
- results = []
322
-
323
- is_dir = search_path.is_dir()
324
-
325
- # If dir, walk. If file, search.
326
- files_to_search = []
327
- if is_dir:
328
- for root, _, files in os.walk(search_path):
329
- for f in files:
330
- # Skip hidden and non-text (basic heuristic)
331
- if f.startswith("."):
332
- continue
333
- if f.endswith((".pyc", ".png", ".jpg")):
334
- continue
335
- files_to_search.append(Path(root) / f)
336
- else:
337
- if search_path.exists():
338
- files_to_search.append(search_path)
339
-
340
- count = 0
341
- for fpath in files_to_search:
342
- if count > 20:
343
- break # Limit files matched
344
- try:
345
- rel_path = fpath.relative_to(self.root)
346
- with open(fpath, encoding="utf-8", errors="ignore") as fp:
347
- content = fp.read()
348
- if pattern in content:
349
- # Extract snippet (one line context)
350
- lines = content.splitlines()
351
- for i, line in enumerate(lines):
352
- if pattern in line:
353
- results.append(f"{rel_path}:{i + 1}: {line.strip()}")
354
- break # One match per file for brevity in overview
355
- count += 1
356
- except Exception:
357
- continue # nosec B112 - skip files we can't read
358
-
359
- return "\n".join(results) or "No matches found"
360
-
361
- def _read_file(self, path_str: str) -> str:
362
- if not self.root:
363
- return "No root"
364
- path = (self.root / path_str).resolve()
365
- if self.root not in path.parents and path != self.root:
366
- return "Error: Path outside root"
367
-
368
- if not path.exists():
369
- return "Error: File not found"
370
-
371
- try:
372
- with open(path, encoding="utf-8") as f:
373
- content = f.read()
374
- # Limit size
375
- if len(content) > 5000:
376
- return content[:5000] + "\n...(truncated)"
377
- return content
378
- except Exception as e:
379
- return f"Read error: {e}"
380
-
381
- def _summarize_results(self, context: dict[str, Any]) -> dict[str, Any]:
382
- """Convert agent history into structured context."""
383
- # Extract files that were read or found relevant
384
- found_files = set()
385
- web_search_results: list[dict[str, Any]] = []
386
-
387
- # Process history to extract files and web search results
388
- history = context["history"]
389
- i = 0
390
- while i < len(history):
391
- item = history[i]
392
- if item["role"] == "model":
393
- action = item.get("parsed_action")
394
- if action:
395
- tool = action["tool"]
396
- args = action.get("args", [])
397
-
398
- if tool == "read_file" and args:
399
- found_files.add(args[0])
400
-
401
- # Capture web search results (action followed by tool output)
402
- if tool in ("search_web", "google_search", "brave_search") and args:
403
- query = args[0]
404
- # Look for the tool output in the next item
405
- if i + 1 < len(history) and history[i + 1]["role"] == "tool":
406
- result = history[i + 1]["content"]
407
- web_search_results.append(
408
- {
409
- "tool": tool,
410
- "query": query,
411
- "result": result[:2000] if len(result) > 2000 else result,
412
- }
413
- )
414
- i += 1
415
-
416
- return {
417
- "relevant_files": list(found_files),
418
- "findings": "Agent research completed.",
419
- "web_research": web_search_results,
420
- "raw_history": history,
421
- }