gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/llm/base.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Abstract base class for LLM providers.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Literal
7
+
8
+ # Auth mode type for providers
9
+ AuthMode = Literal["subscription", "api_key", "adc"]
10
+
11
+
12
+ class LLMProvider(ABC):
13
+ """
14
+ Abstract base class for LLM providers.
15
+
16
+ Defines the interface for generating summaries and synthesizing titles
17
+ across different providers (Claude, Codex, Gemini, LiteLLM).
18
+
19
+ Properties:
20
+ provider_name: Unique identifier for this provider (e.g., "claude", "codex")
21
+ auth_mode: How this provider authenticates ("subscription", "api_key", "adc")
22
+ """
23
+
24
+ @property
25
+ @abstractmethod
26
+ def provider_name(self) -> str:
27
+ """
28
+ Return the unique provider name.
29
+
30
+ Returns:
31
+ Provider name string (e.g., "claude", "codex", "gemini", "litellm")
32
+ """
33
+ pass
34
+
35
+ @property
36
+ def auth_mode(self) -> AuthMode:
37
+ """
38
+ Return the authentication mode for this provider.
39
+
40
+ Default implementation returns "subscription". Override in subclasses
41
+ that use different auth modes.
42
+
43
+ Returns:
44
+ Authentication mode: "subscription", "api_key", or "adc"
45
+ """
46
+ return "subscription"
47
+
48
+ @abstractmethod
49
+ async def generate_summary(
50
+ self, context: dict[str, Any], prompt_template: str | None = None
51
+ ) -> str:
52
+ """
53
+ Generate session summary.
54
+
55
+ Args:
56
+ context: Dictionary containing transcript turns, git status, etc.
57
+ prompt_template: Optional override for the prompt.
58
+
59
+ Returns:
60
+ Generated summary string.
61
+ """
62
+ pass
63
+
64
+ @abstractmethod
65
+ async def synthesize_title(
66
+ self, user_prompt: str, prompt_template: str | None = None
67
+ ) -> str | None:
68
+ """
69
+ Synthesize session title.
70
+
71
+ Args:
72
+ user_prompt: The first user message.
73
+ prompt_template: Optional override for the prompt.
74
+
75
+ Returns:
76
+ Synthesized title or None if failed.
77
+ """
78
+ pass
79
+
80
+ @abstractmethod
81
+ async def generate_text(
82
+ self,
83
+ prompt: str,
84
+ system_prompt: str | None = None,
85
+ model: str | None = None,
86
+ ) -> str:
87
+ """
88
+ Generate text from a prompt.
89
+
90
+ Args:
91
+ prompt: User prompt
92
+ system_prompt: Optional system prompt
93
+ model: Optional model override
94
+
95
+ Returns:
96
+ Generated text response
97
+ """
98
+ pass
99
+
100
+ @abstractmethod
101
+ async def describe_image(
102
+ self,
103
+ image_path: str,
104
+ context: str | None = None,
105
+ ) -> str:
106
+ """
107
+ Generate a text description of an image.
108
+
109
+ Used for multimodal memory support - converts images to text
110
+ descriptions that can be stored alongside memory content.
111
+
112
+ Args:
113
+ image_path: Path to the image file to describe
114
+ context: Optional context to guide the description
115
+ (e.g., "This is a screenshot of the settings page")
116
+
117
+ Returns:
118
+ Text description of the image suitable for memory storage
119
+ """
120
+ pass
gobby/llm/claude.py ADDED
@@ -0,0 +1,578 @@
1
+ """
2
+ Claude implementation of LLMProvider.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from claude_agent_sdk import (
15
+ AssistantMessage,
16
+ ClaudeAgentOptions,
17
+ ResultMessage,
18
+ TextBlock,
19
+ ToolResultBlock,
20
+ ToolUseBlock,
21
+ UserMessage,
22
+ create_sdk_mcp_server,
23
+ query,
24
+ )
25
+
26
+ from gobby.config.app import DaemonConfig
27
+ from gobby.llm.base import LLMProvider
28
+
29
+
30
+ @dataclass
31
+ class ToolCall:
32
+ """Represents a tool call made during generation."""
33
+
34
+ tool_name: str
35
+ """Full tool name (e.g., mcp__gobby-tasks__create_task)."""
36
+
37
+ server_name: str
38
+ """Extracted server name from the tool (e.g., gobby-tasks)."""
39
+
40
+ arguments: dict[str, Any]
41
+ """Arguments passed to the tool."""
42
+
43
+ result: str | None = None
44
+ """Result returned by the tool, if available."""
45
+
46
+
47
+ @dataclass
48
+ class MCPToolResult:
49
+ """Result of generate_with_mcp_tools."""
50
+
51
+ text: str
52
+ """Final text output from the generation."""
53
+
54
+ tool_calls: list[ToolCall] = field(default_factory=list)
55
+ """List of tool calls made during generation."""
56
+
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+ class ClaudeLLMProvider(LLMProvider):
62
+ """
63
+ Claude implementation of LLMProvider using claude_agent_sdk.
64
+
65
+ Uses subscription-based authentication through Claude CLI.
66
+ """
67
+
68
+ @property
69
+ def provider_name(self) -> str:
70
+ """Return provider name."""
71
+ return "claude"
72
+
73
+ def __init__(self, config: DaemonConfig):
74
+ """
75
+ Initialize ClaudeLLMProvider.
76
+
77
+ Args:
78
+ config: Client configuration.
79
+ """
80
+ self.config = config
81
+ self.logger = logger
82
+ self._claude_cli_path = self._find_cli_path()
83
+
84
+ def _find_cli_path(self) -> str | None:
85
+ """
86
+ Find Claude CLI path.
87
+
88
+ DO NOT resolve symlinks - npm manages the symlink atomically during upgrades.
89
+ Resolving causes race conditions when Claude Code is being reinstalled.
90
+ """
91
+ cli_path = shutil.which("claude")
92
+
93
+ if cli_path:
94
+ # Validate CLI exists and is executable
95
+ if not os.path.exists(cli_path):
96
+ self.logger.warning(f"Claude CLI not found: {cli_path}")
97
+ return None
98
+ elif not os.access(cli_path, os.X_OK):
99
+ self.logger.warning(f"Claude CLI not executable: {cli_path}")
100
+ return None
101
+ else:
102
+ self.logger.debug(f"Claude CLI found: {cli_path}")
103
+ return cli_path
104
+ else:
105
+ self.logger.warning("Claude CLI not found in PATH - LLM features disabled")
106
+ return None
107
+
108
+ def _verify_cli_path(self) -> str | None:
109
+ """
110
+ Verify CLI path is still valid and retry if needed.
111
+
112
+ Handles race condition when npm install updates Claude Code during hook execution.
113
+ Uses exponential backoff retry to wait for npm install to complete.
114
+
115
+ Returns:
116
+ Valid CLI path if found, None otherwise
117
+ """
118
+ cli_path = self._claude_cli_path
119
+
120
+ # Validate cached path still exists
121
+ # Retry with backoff if missing (may be in the middle of npm install)
122
+ if cli_path and not os.path.exists(cli_path):
123
+ self.logger.warning(
124
+ f"Cached CLI path no longer exists (may have been reinstalled): {cli_path}"
125
+ )
126
+ # Try to find CLI again with retry logic for npm install race condition
127
+ max_retries = 3
128
+ retry_delays = [0.5, 1.0, 2.0] # Exponential backoff
129
+
130
+ for attempt, delay in enumerate(retry_delays, 1):
131
+ cli_path = shutil.which("claude")
132
+ if cli_path and os.path.exists(cli_path):
133
+ self.logger.debug(
134
+ f"Found Claude CLI at new location after {attempt} attempt(s): {cli_path}"
135
+ )
136
+ self._claude_cli_path = cli_path
137
+ break
138
+
139
+ if attempt < max_retries:
140
+ self.logger.debug(
141
+ f"Claude CLI not found, waiting {delay}s before retry {attempt + 1}/{max_retries}"
142
+ )
143
+ time.sleep(delay)
144
+ else:
145
+ self.logger.warning(f"Claude CLI not found in PATH after {max_retries} retries")
146
+ cli_path = None
147
+
148
+ return cli_path
149
+
150
+ async def generate_summary(
151
+ self, context: dict[str, Any], prompt_template: str | None = None
152
+ ) -> str:
153
+ """
154
+ Generate session summary using Claude.
155
+ """
156
+ cli_path = self._verify_cli_path()
157
+ if not cli_path:
158
+ return "Session summary unavailable (Claude CLI not found)"
159
+
160
+ # Build formatted context for prompt template
161
+ # Transform list/dict values to strings for template substitution
162
+ formatted_context = {
163
+ "transcript_summary": context.get("transcript_summary", ""),
164
+ "last_messages": json.dumps(context.get("last_messages", []), indent=2),
165
+ "git_status": context.get("git_status", ""),
166
+ "file_changes": context.get("file_changes", ""),
167
+ **{
168
+ k: v
169
+ for k, v in context.items()
170
+ if k not in ["transcript_summary", "last_messages", "git_status", "file_changes"]
171
+ },
172
+ }
173
+
174
+ # Build prompt - prompt_template is required
175
+ if not prompt_template:
176
+ raise ValueError(
177
+ "prompt_template is required for generate_summary. "
178
+ "Configure 'session_summary.prompt' in ~/.gobby/config.yaml"
179
+ )
180
+ prompt = prompt_template.format(**formatted_context)
181
+
182
+ # Configure Claude Agent SDK
183
+ options = ClaudeAgentOptions(
184
+ system_prompt="You are a session summary generator. Create comprehensive, actionable summaries.",
185
+ max_turns=1,
186
+ model=self.config.session_summary.model,
187
+ allowed_tools=[],
188
+ permission_mode="default",
189
+ cli_path=cli_path,
190
+ )
191
+
192
+ # Run async query
193
+ async def _run_query() -> str:
194
+ summary_text = ""
195
+ async for message in query(prompt=prompt, options=options):
196
+ if isinstance(message, AssistantMessage):
197
+ for block in message.content:
198
+ if isinstance(block, TextBlock):
199
+ summary_text += block.text
200
+ return summary_text
201
+
202
+ try:
203
+ return await _run_query()
204
+ except Exception as e:
205
+ self.logger.error(f"Failed to generate summary with Claude: {e}")
206
+ return f"Session summary generation failed: {e}"
207
+
208
+ async def synthesize_title(
209
+ self, user_prompt: str, prompt_template: str | None = None
210
+ ) -> str | None:
211
+ """
212
+ Synthesize session title using Claude.
213
+ """
214
+ cli_path = self._verify_cli_path()
215
+ if not cli_path:
216
+ return None
217
+
218
+ # Build prompt - prompt_template is required
219
+ if not prompt_template:
220
+ raise ValueError(
221
+ "prompt_template is required for synthesize_title. "
222
+ "Configure 'title_synthesis.prompt' in ~/.gobby/config.yaml"
223
+ )
224
+ prompt = prompt_template.format(user_prompt=user_prompt)
225
+
226
+ # Configure Claude Agent SDK
227
+ options = ClaudeAgentOptions(
228
+ system_prompt="You are a session title generator. Create concise, descriptive titles.",
229
+ max_turns=1,
230
+ model=self.config.title_synthesis.model,
231
+ allowed_tools=[],
232
+ permission_mode="default",
233
+ cli_path=cli_path,
234
+ )
235
+
236
+ # Run async query
237
+ async def _run_query() -> str:
238
+ title_text = ""
239
+ async for message in query(prompt=prompt, options=options):
240
+ if isinstance(message, AssistantMessage):
241
+ for block in message.content:
242
+ if isinstance(block, TextBlock):
243
+ title_text = block.text
244
+ return title_text.strip()
245
+
246
+ try:
247
+ # Retry logic for title synthesis
248
+ max_retries = 3
249
+ for attempt in range(max_retries):
250
+ try:
251
+ return await _run_query()
252
+ except Exception as e:
253
+ if attempt < max_retries - 1:
254
+ self.logger.warning(
255
+ f"Title synthesis failed (attempt {attempt + 1}), retrying: {e}"
256
+ )
257
+ await asyncio.sleep(1)
258
+ else:
259
+ raise e
260
+ # This should be unreachable, but mypy can't prove it
261
+ return None # pragma: no cover
262
+ except Exception as e:
263
+ self.logger.error(f"Failed to synthesize title with Claude: {e}")
264
+ return None
265
+
266
+ async def generate_text(
267
+ self,
268
+ prompt: str,
269
+ system_prompt: str | None = None,
270
+ model: str | None = None,
271
+ ) -> str:
272
+ """
273
+ Generate text using Claude.
274
+ """
275
+ cli_path = self._verify_cli_path()
276
+ if not cli_path:
277
+ return "Generation unavailable (Claude CLI not found)"
278
+
279
+ # Configure Claude Agent SDK
280
+ # Use tools=[] to disable all tools for pure text generation
281
+ options = ClaudeAgentOptions(
282
+ system_prompt=system_prompt or "You are a helpful assistant.",
283
+ max_turns=1,
284
+ model=model or "claude-haiku-4-5",
285
+ tools=[], # Explicitly disable all tools
286
+ allowed_tools=[],
287
+ permission_mode="default",
288
+ cli_path=cli_path,
289
+ )
290
+
291
+ # Run async query
292
+ async def _run_query() -> str:
293
+ result_text = ""
294
+ message_count = 0
295
+ async for message in query(prompt=prompt, options=options):
296
+ message_count += 1
297
+ self.logger.debug(
298
+ f"generate_text message {message_count}: {type(message).__name__}"
299
+ )
300
+ if isinstance(message, AssistantMessage):
301
+ for block in message.content:
302
+ if isinstance(block, TextBlock):
303
+ self.logger.debug(f" TextBlock: {block.text[:100]}...")
304
+ result_text += block.text
305
+ elif isinstance(block, ToolUseBlock):
306
+ self.logger.debug(f" ToolUseBlock: {block.name}")
307
+ elif isinstance(message, ResultMessage):
308
+ # ResultMessage contains the final result from the agent
309
+ self.logger.debug(
310
+ f" ResultMessage: result={message.result}, type={type(message.result)}"
311
+ )
312
+ if message.result:
313
+ result_text = message.result
314
+ if message_count == 0:
315
+ self.logger.warning("generate_text: No messages received from Claude SDK")
316
+ elif not result_text:
317
+ self.logger.warning(f"generate_text: {message_count} messages but no text content")
318
+ return result_text
319
+
320
+ try:
321
+ return await _run_query()
322
+ except Exception as e:
323
+ self.logger.error(f"Failed to generate text with Claude: {e}", exc_info=True)
324
+ return f"Generation failed: {e}"
325
+
326
+ async def generate_with_mcp_tools(
327
+ self,
328
+ prompt: str,
329
+ allowed_tools: list[str],
330
+ system_prompt: str | None = None,
331
+ model: str | None = None,
332
+ max_turns: int = 10,
333
+ tool_functions: dict[str, list[Any]] | None = None,
334
+ ) -> MCPToolResult:
335
+ """
336
+ Generate text with access to MCP tools.
337
+
338
+ This method enables the agent to call MCP tools during generation,
339
+ tracking all tool calls made and returning them alongside the final text.
340
+
341
+ Args:
342
+ prompt: User prompt to process.
343
+ allowed_tools: List of allowed MCP tool patterns.
344
+ Tools should be in format "mcp__{server}__{tool}" or patterns
345
+ like "mcp__gobby-tasks__*" for all tools from a server.
346
+ system_prompt: Optional system prompt.
347
+ model: Optional model override (default: claude-sonnet-4-5).
348
+ max_turns: Maximum number of agentic turns (default: 10).
349
+ tool_functions: Optional dict mapping server names to lists of tool
350
+ functions for in-process MCP servers. Example:
351
+ {"gobby-tasks": [create_task_func, update_task_func]}
352
+
353
+ Returns:
354
+ MCPToolResult containing final text and list of tool calls made.
355
+
356
+ Example:
357
+ >>> result = await provider.generate_with_mcp_tools(
358
+ ... prompt="Create a task called 'Fix bug'",
359
+ ... allowed_tools=["mcp__gobby-tasks__create_task"],
360
+ ... system_prompt="You are a task manager.",
361
+ ... tool_functions={"gobby-tasks": [create_task]}
362
+ ... )
363
+ >>> print(result.text)
364
+ >>> for call in result.tool_calls:
365
+ ... print(f"Called {call.tool_name} with {call.arguments}")
366
+ """
367
+ cli_path = self._verify_cli_path()
368
+ if not cli_path:
369
+ return MCPToolResult(
370
+ text="Generation unavailable (Claude CLI not found)",
371
+ tool_calls=[],
372
+ )
373
+
374
+ # Build mcp_servers config
375
+ # Can be a dict of server configs OR a path to .mcp.json file
376
+ from pathlib import Path
377
+
378
+ mcp_servers_config: dict[str, Any] | str | None = None
379
+
380
+ # Add in-process tool functions if provided
381
+ if tool_functions:
382
+ mcp_servers_config = {}
383
+ for server_name, tools in tool_functions.items():
384
+ mcp_servers_config[server_name] = create_sdk_mcp_server(
385
+ name=server_name,
386
+ tools=tools,
387
+ )
388
+
389
+ # If no tool_functions provided but we have allowed gobby tools,
390
+ # use the .mcp.json config file (avoids in-process config issues)
391
+ if not tool_functions and any("gobby" in t for t in allowed_tools):
392
+ # Look for .mcp.json in the current working directory or gobby project
393
+ cwd_config = Path.cwd() / ".mcp.json"
394
+ if cwd_config.exists():
395
+ mcp_servers_config = str(cwd_config)
396
+ else:
397
+ # Try the gobby project root
398
+ gobby_root = Path(__file__).parent.parent.parent.parent
399
+ gobby_config = gobby_root / ".mcp.json"
400
+ if gobby_config.exists():
401
+ mcp_servers_config = str(gobby_config)
402
+
403
+ # Configure Claude Agent SDK with MCP tools
404
+ options = ClaudeAgentOptions(
405
+ system_prompt=system_prompt or "You are a helpful assistant with access to MCP tools.",
406
+ max_turns=max_turns,
407
+ model=model or "claude-sonnet-4-5",
408
+ allowed_tools=allowed_tools,
409
+ permission_mode="bypassPermissions",
410
+ cli_path=cli_path,
411
+ mcp_servers=mcp_servers_config if mcp_servers_config is not None else {},
412
+ )
413
+
414
+ # Track tool calls and results
415
+ tool_calls: list[ToolCall] = []
416
+ pending_tool_calls: dict[str, ToolCall] = {} # Map tool_use_id -> ToolCall
417
+
418
+ def _parse_server_name(full_tool_name: str) -> str:
419
+ """Extract server name from mcp__{server}__{tool} format."""
420
+ if full_tool_name.startswith("mcp__"):
421
+ parts = full_tool_name.split("__")
422
+ if len(parts) >= 2:
423
+ return parts[1]
424
+ return "unknown"
425
+
426
+ # Run async query
427
+ async def _run_query() -> str:
428
+ result_text = ""
429
+ async for message in query(prompt=prompt, options=options):
430
+ if isinstance(message, ResultMessage):
431
+ # Final result from the agent
432
+ if message.result:
433
+ result_text = message.result
434
+ self.logger.debug(f"ResultMessage: result={message.result}")
435
+
436
+ elif isinstance(message, AssistantMessage):
437
+ for block in message.content:
438
+ if isinstance(block, TextBlock):
439
+ result_text += block.text
440
+ elif isinstance(block, ToolUseBlock):
441
+ # Track tool use
442
+ tool_call = ToolCall(
443
+ tool_name=block.name,
444
+ server_name=_parse_server_name(block.name),
445
+ arguments=block.input if isinstance(block.input, dict) else {},
446
+ )
447
+ tool_calls.append(tool_call)
448
+ pending_tool_calls[block.id] = tool_call
449
+ self.logger.debug(
450
+ f"ToolUseBlock: tool={block.name}, input={block.input}"
451
+ )
452
+
453
+ elif isinstance(message, UserMessage):
454
+ # UserMessage may contain tool results
455
+ # UserMessage.content can be str | list[...], check first
456
+ if isinstance(message.content, list):
457
+ for block in message.content:
458
+ if isinstance(block, ToolResultBlock):
459
+ # Match result to pending tool call
460
+ if block.tool_use_id in pending_tool_calls:
461
+ pending_tool_calls[block.tool_use_id].result = str(
462
+ block.content
463
+ )
464
+ self.logger.debug(
465
+ f"ToolResultBlock: id={block.tool_use_id}, content={block.content}"
466
+ )
467
+
468
+ return result_text
469
+
470
+ try:
471
+ final_text = await _run_query()
472
+ return MCPToolResult(text=final_text, tool_calls=tool_calls)
473
+ except ExceptionGroup as eg:
474
+ # Handle Python 3.11+ ExceptionGroup from TaskGroup
475
+ errors: list[str] = []
476
+ for exc in eg.exceptions:
477
+ errors.append(f"{type(exc).__name__}: {exc}")
478
+ self.logger.error(f"TaskGroup sub-exception: {type(exc).__name__}: {exc}")
479
+ return MCPToolResult(
480
+ text=f"Generation failed: {'; '.join(errors)}",
481
+ tool_calls=tool_calls,
482
+ )
483
+ except Exception as e:
484
+ self.logger.error(f"Failed to generate with MCP tools: {e}", exc_info=True)
485
+ return MCPToolResult(
486
+ text=f"Generation failed: {e}",
487
+ tool_calls=tool_calls,
488
+ )
489
+
490
+ async def describe_image(
491
+ self,
492
+ image_path: str,
493
+ context: str | None = None,
494
+ ) -> str:
495
+ """
496
+ Generate a text description of an image using Claude's vision capabilities.
497
+
498
+ Uses the Anthropic API directly for vision support.
499
+
500
+ Args:
501
+ image_path: Path to the image file to describe
502
+ context: Optional context to guide the description
503
+
504
+ Returns:
505
+ Text description of the image
506
+ """
507
+ import base64
508
+ import mimetypes
509
+ from pathlib import Path
510
+
511
+ import anthropic
512
+
513
+ # Validate image exists
514
+ path = Path(image_path)
515
+ if not path.exists():
516
+ return f"Image not found: {image_path}"
517
+
518
+ # Read and encode image
519
+ try:
520
+ image_data = path.read_bytes()
521
+ image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
522
+ except Exception as e:
523
+ self.logger.error(f"Failed to read image {image_path}: {e}")
524
+ return f"Failed to read image: {e}"
525
+
526
+ # Determine media type
527
+ mime_type, _ = mimetypes.guess_type(str(path))
528
+ if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
529
+ # Default to png for unknown types
530
+ mime_type = "image/png"
531
+
532
+ # Build prompt
533
+ prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
534
+ if context:
535
+ prompt = f"{context}\n\n{prompt}"
536
+
537
+ # Use Anthropic API for vision
538
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
539
+ if not api_key:
540
+ return "Image description unavailable (ANTHROPIC_API_KEY not set)"
541
+
542
+ try:
543
+ client = anthropic.AsyncAnthropic(api_key=api_key)
544
+ # Type annotation to satisfy mypy
545
+ image_block: anthropic.types.ImageBlockParam = {
546
+ "type": "image",
547
+ "source": {
548
+ "type": "base64",
549
+ "media_type": mime_type, # type: ignore[typeddict-item]
550
+ "data": image_base64,
551
+ },
552
+ }
553
+ text_block: anthropic.types.TextBlockParam = {
554
+ "type": "text",
555
+ "text": prompt,
556
+ }
557
+ message = await client.messages.create(
558
+ model="claude-haiku-4-5-latest", # Use haiku for cost efficiency
559
+ max_tokens=1024,
560
+ messages=[
561
+ {
562
+ "role": "user",
563
+ "content": [image_block, text_block],
564
+ }
565
+ ],
566
+ )
567
+
568
+ # Extract text from response
569
+ result = ""
570
+ for block in message.content:
571
+ if hasattr(block, "text"):
572
+ result += block.text
573
+
574
+ return result if result else "No description generated"
575
+
576
+ except Exception as e:
577
+ self.logger.error(f"Failed to describe image with Claude: {e}")
578
+ return f"Image description failed: {e}"