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
@@ -0,0 +1,166 @@
1
+ """Local project storage manager."""
2
+
3
+ import logging
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from gobby.storage.database import DatabaseProtocol
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class Project:
16
+ """Project data model."""
17
+
18
+ id: str
19
+ name: str
20
+ repo_path: str | None
21
+ github_url: str | None
22
+ created_at: str
23
+ updated_at: str
24
+ github_repo: str | None = None # GitHub repo in "owner/repo" format
25
+ linear_team_id: str | None = None # Linear team ID for project sync
26
+
27
+ @classmethod
28
+ def from_row(cls, row: Any) -> "Project":
29
+ """Create Project from database row."""
30
+ return cls(
31
+ id=row["id"],
32
+ name=row["name"],
33
+ repo_path=row["repo_path"],
34
+ github_url=row["github_url"],
35
+ created_at=row["created_at"],
36
+ updated_at=row["updated_at"],
37
+ github_repo=row["github_repo"] if "github_repo" in row.keys() else None,
38
+ linear_team_id=row["linear_team_id"] if "linear_team_id" in row.keys() else None,
39
+ )
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Convert to dictionary."""
43
+ return {
44
+ "id": self.id,
45
+ "name": self.name,
46
+ "repo_path": self.repo_path,
47
+ "github_url": self.github_url,
48
+ "github_repo": self.github_repo,
49
+ "linear_team_id": self.linear_team_id,
50
+ "created_at": self.created_at,
51
+ "updated_at": self.updated_at,
52
+ }
53
+
54
+
55
+ class LocalProjectManager:
56
+ """Manager for local project storage."""
57
+
58
+ def __init__(self, db: DatabaseProtocol):
59
+ """Initialize with database connection."""
60
+ self.db = db
61
+
62
+ def create(
63
+ self,
64
+ name: str,
65
+ repo_path: str | None = None,
66
+ github_url: str | None = None,
67
+ ) -> Project:
68
+ """
69
+ Create a new project.
70
+
71
+ Args:
72
+ name: Unique project name
73
+ repo_path: Local repository path
74
+ github_url: GitHub repository URL
75
+
76
+ Returns:
77
+ Created Project instance
78
+ """
79
+ project_id = str(uuid.uuid4())
80
+ now = datetime.now(UTC).isoformat()
81
+
82
+ self.db.execute(
83
+ """
84
+ INSERT INTO projects (id, name, repo_path, github_url, created_at, updated_at)
85
+ VALUES (?, ?, ?, ?, ?, ?)
86
+ """,
87
+ (project_id, name, repo_path, github_url, now, now),
88
+ )
89
+
90
+ return Project(
91
+ id=project_id,
92
+ name=name,
93
+ repo_path=repo_path,
94
+ github_url=github_url,
95
+ created_at=now,
96
+ updated_at=now,
97
+ )
98
+
99
+ def get(self, project_id: str) -> Project | None:
100
+ """Get project by ID."""
101
+ row = self.db.fetchone("SELECT * FROM projects WHERE id = ?", (project_id,))
102
+ return Project.from_row(row) if row else None
103
+
104
+ def get_by_name(self, name: str) -> Project | None:
105
+ """Get project by name."""
106
+ row = self.db.fetchone("SELECT * FROM projects WHERE name = ?", (name,))
107
+ return Project.from_row(row) if row else None
108
+
109
+ def get_or_create(
110
+ self,
111
+ name: str,
112
+ repo_path: str | None = None,
113
+ github_url: str | None = None,
114
+ ) -> Project:
115
+ """Get existing project or create new one."""
116
+ project = self.get_by_name(name)
117
+ if project:
118
+ return project
119
+ return self.create(name, repo_path, github_url)
120
+
121
+ def list(self) -> list[Project]:
122
+ """List all projects."""
123
+ rows = self.db.fetchall("SELECT * FROM projects ORDER BY name")
124
+ return [Project.from_row(row) for row in rows]
125
+
126
+ def update(self, project_id: str, **fields: Any) -> Project | None:
127
+ """
128
+ Update project fields.
129
+
130
+ Args:
131
+ project_id: Project ID
132
+ **fields: Fields to update (name, repo_path, github_url)
133
+
134
+ Returns:
135
+ Updated Project or None if not found
136
+ """
137
+ if not fields:
138
+ return self.get(project_id)
139
+
140
+ allowed = {"name", "repo_path", "github_url", "github_repo", "linear_team_id"}
141
+ fields = {k: v for k, v in fields.items() if k in allowed}
142
+ if not fields:
143
+ return self.get(project_id)
144
+
145
+ fields["updated_at"] = datetime.now(UTC).isoformat()
146
+
147
+ # nosec B608: Fields validated against allowlist above, values parameterized
148
+ set_clause = ", ".join(f"{k} = ?" for k in fields)
149
+ values = list(fields.values()) + [project_id]
150
+
151
+ self.db.execute(
152
+ f"UPDATE projects SET {set_clause} WHERE id = ?", # nosec B608
153
+ tuple(values),
154
+ )
155
+
156
+ return self.get(project_id)
157
+
158
+ def delete(self, project_id: str) -> bool:
159
+ """
160
+ Delete project by ID.
161
+
162
+ Returns:
163
+ True if deleted, False if not found
164
+ """
165
+ cursor = self.db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
166
+ return cursor.rowcount > 0
@@ -0,0 +1,251 @@
1
+ """
2
+ Local storage for session messages.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ from gobby.sessions.transcripts.base import ParsedMessage
11
+ from gobby.storage.database import DatabaseProtocol
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class LocalSessionMessageManager:
17
+ """Manages storage of session messages and processing state."""
18
+
19
+ def __init__(self, database: DatabaseProtocol):
20
+ self.db = database
21
+
22
+ async def store_messages(self, session_id: str, messages: list[ParsedMessage]) -> int:
23
+ """
24
+ Store parsed messages for a session.
25
+
26
+ Args:
27
+ session_id: ID of the session
28
+ messages: List of ParsedMessage objects
29
+
30
+ Returns:
31
+ Number of messages stored
32
+ """
33
+ if not messages:
34
+ return 0
35
+
36
+ def _store_blocking() -> int:
37
+ # Check if session exists to avoid FOREIGN KEY constraint errors
38
+ # This can happen when sessions are created in hub-only mode but
39
+ # message processor is using the project database
40
+ session_exists = self.db.fetchone("SELECT 1 FROM sessions WHERE id = ?", (session_id,))
41
+ if not session_exists:
42
+ logger.debug(
43
+ f"Session {session_id} not found in database, skipping message storage"
44
+ )
45
+ return 0
46
+
47
+ count = 0
48
+ for msg in messages:
49
+ # Convert dicts to JSON strings for storage
50
+ tool_input = json.dumps(msg.tool_input) if msg.tool_input is not None else None
51
+ tool_result = json.dumps(msg.tool_result) if msg.tool_result is not None else None
52
+ raw_json = json.dumps(msg.raw_json) if msg.raw_json is not None else None
53
+
54
+ query = """
55
+ INSERT INTO session_messages (
56
+ session_id, message_index, role, content, content_type,
57
+ tool_name, tool_input, tool_result, timestamp, raw_json
58
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
+ ON CONFLICT(session_id, message_index) DO UPDATE SET
60
+ content=excluded.content,
61
+ content_type=excluded.content_type,
62
+ tool_name=excluded.tool_name,
63
+ tool_input=excluded.tool_input,
64
+ tool_result=excluded.tool_result,
65
+ timestamp=excluded.timestamp,
66
+ raw_json=excluded.raw_json
67
+ """
68
+
69
+ self.db.execute(
70
+ query,
71
+ (
72
+ session_id,
73
+ msg.index,
74
+ msg.role,
75
+ msg.content,
76
+ msg.content_type,
77
+ msg.tool_name,
78
+ tool_input,
79
+ tool_result,
80
+ msg.timestamp.isoformat(),
81
+ raw_json,
82
+ ),
83
+ )
84
+ count += 1
85
+ return count
86
+
87
+ try:
88
+ return await asyncio.to_thread(_store_blocking)
89
+ except Exception as e:
90
+ logger.error(f"Failed to store messages for session {session_id}: {e}")
91
+ raise
92
+
93
+ async def get_messages(
94
+ self,
95
+ session_id: str,
96
+ limit: int = 100,
97
+ offset: int = 0,
98
+ role: str | None = None,
99
+ ) -> list[dict[str, Any]]:
100
+ """
101
+ Retrieve messages for a session.
102
+
103
+ Args:
104
+ session_id: Session ID
105
+ limit: Maximum number of messages to return
106
+ offset: Offset for pagination
107
+ role: Optional role to filter by
108
+
109
+ Returns:
110
+ List of message dictionaries
111
+ """
112
+ query = "SELECT * FROM session_messages WHERE session_id = ?"
113
+ params: list[Any] = [session_id]
114
+
115
+ if role:
116
+ query += " AND role = ?"
117
+ params.append(role)
118
+
119
+ query += " ORDER BY message_index ASC LIMIT ? OFFSET ?"
120
+ params.extend([limit, offset])
121
+
122
+ rows = await asyncio.to_thread(self.db.fetchall, query, tuple(params))
123
+ return [dict(row) for row in rows]
124
+
125
+ async def get_state(self, session_id: str) -> dict[str, Any] | None:
126
+ """
127
+ Get processing state for a session.
128
+
129
+ Args:
130
+ session_id: Session ID
131
+
132
+ Returns:
133
+ State dictionary or None if not found
134
+ """
135
+ row = await asyncio.to_thread(
136
+ self.db.fetchone,
137
+ "SELECT * FROM session_message_state WHERE session_id = ?",
138
+ (session_id,),
139
+ )
140
+ return dict(row) if row else None
141
+
142
+ async def update_state(
143
+ self,
144
+ session_id: str,
145
+ byte_offset: int,
146
+ message_index: int,
147
+ ) -> None:
148
+ """
149
+ Update processing state for a session.
150
+
151
+ Args:
152
+ session_id: Session ID
153
+ byte_offset: New byte offset in source file
154
+ message_index: Index of last processed message
155
+ """
156
+ # Check if session exists to avoid FOREIGN KEY constraint errors
157
+ session_exists = await asyncio.to_thread(
158
+ self.db.fetchone,
159
+ "SELECT 1 FROM sessions WHERE id = ?",
160
+ (session_id,),
161
+ )
162
+ if not session_exists:
163
+ logger.debug(f"Session {session_id} not found in database, skipping state update")
164
+ return
165
+
166
+ sql = """
167
+ INSERT INTO session_message_state (
168
+ session_id, last_byte_offset, last_message_index,
169
+ last_processed_at, updated_at
170
+ ) VALUES (?, ?, ?, datetime('now'), datetime('now'))
171
+ ON CONFLICT(session_id) DO UPDATE SET
172
+ last_byte_offset=excluded.last_byte_offset,
173
+ last_message_index=excluded.last_message_index,
174
+ last_processed_at=excluded.last_processed_at,
175
+ updated_at=excluded.updated_at
176
+ """
177
+ await asyncio.to_thread(self.db.execute, sql, (session_id, byte_offset, message_index))
178
+
179
+ async def count_messages(self, session_id: str) -> int:
180
+ """
181
+ Count messages for a session.
182
+
183
+ Args:
184
+ session_id: Session ID
185
+
186
+ Returns:
187
+ Number of messages
188
+ """
189
+ result = await asyncio.to_thread(
190
+ self.db.fetchone,
191
+ "SELECT COUNT(*) as count FROM session_messages WHERE session_id = ?",
192
+ (session_id,),
193
+ )
194
+ return result["count"] if result else 0
195
+
196
+ async def get_all_counts(self) -> dict[str, int]:
197
+ """
198
+ Get message counts for all sessions.
199
+
200
+ Returns:
201
+ Dictionary mapping session_id to count
202
+ """
203
+ rows = await asyncio.to_thread(
204
+ self.db.fetchall,
205
+ "SELECT session_id, COUNT(*) as count FROM session_messages GROUP BY session_id",
206
+ )
207
+ return {row["session_id"]: row["count"] for row in rows}
208
+
209
+ async def search_messages(
210
+ self,
211
+ query_text: str,
212
+ limit: int = 20,
213
+ offset: int = 0,
214
+ session_id: str | None = None,
215
+ project_id: str | None = None,
216
+ ) -> list[dict[str, Any]]:
217
+ """
218
+ Search messages using simple text matching.
219
+
220
+ Args:
221
+ query_text: Text to search for
222
+ limit: Max results
223
+ offset: Pagination offset
224
+ session_id: Optional session ID to filter by
225
+ project_id: Optional project ID to filter by
226
+
227
+ Returns:
228
+ List of matching messages
229
+ """
230
+ # Escape LIKE wildcards in query_text
231
+ escaped_query = query_text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
232
+ sql = "SELECT m.* FROM session_messages m"
233
+ params: list[Any] = []
234
+ conditions: list[str] = ["m.content LIKE ? ESCAPE '\\'"]
235
+ params.append(f"%{escaped_query}%")
236
+
237
+ if project_id:
238
+ sql += " JOIN sessions s ON m.session_id = s.session_id"
239
+ conditions.append("s.project_id = ?")
240
+ params.append(project_id)
241
+
242
+ if session_id:
243
+ conditions.append("m.session_id = ?")
244
+ params.append(session_id)
245
+
246
+ sql += " WHERE " + " AND ".join(conditions)
247
+ sql += " ORDER BY m.timestamp DESC LIMIT ? OFFSET ?"
248
+ params.extend([limit, offset])
249
+
250
+ rows = await asyncio.to_thread(self.db.fetchall, sql, tuple(params))
251
+ return [dict(row) for row in rows]
@@ -0,0 +1,97 @@
1
+ import logging
2
+ from datetime import UTC, datetime
3
+ from typing import Any, Literal
4
+
5
+ from gobby.storage.database import DatabaseProtocol
6
+ from gobby.storage.tasks import Task
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ SessionTaskAction = Literal["worked_on", "discovered", "mentioned", "closed"]
11
+
12
+
13
+ class SessionTaskManager:
14
+ VALID_ACTIONS = {"worked_on", "discovered", "mentioned", "closed"}
15
+
16
+ def __init__(self, db: DatabaseProtocol):
17
+ self.db = db
18
+
19
+ def link_task(
20
+ self,
21
+ session_id: str,
22
+ task_id: str,
23
+ action: str = "worked_on",
24
+ ) -> None:
25
+ """
26
+ Link a task to a session with a specific action.
27
+ Actions: worked_on, discovered, mentioned, closed
28
+ """
29
+ if action not in self.VALID_ACTIONS:
30
+ raise ValueError(f"Invalid action '{action}'. Must be one of {self.VALID_ACTIONS}")
31
+
32
+ now = datetime.now(UTC).isoformat()
33
+
34
+ with self.db.transaction() as conn:
35
+ # Use INSERT OR IGNORE to handle duplicate links gracefully
36
+ conn.execute(
37
+ """
38
+ INSERT OR IGNORE INTO session_tasks (
39
+ session_id, task_id, action, created_at
40
+ ) VALUES (?, ?, ?, ?)
41
+ """,
42
+ (session_id, task_id, action, now),
43
+ )
44
+ logger.debug(f"Linked task {task_id} to session {session_id} with action {action}")
45
+
46
+ def unlink_task(
47
+ self,
48
+ session_id: str,
49
+ task_id: str,
50
+ action: str,
51
+ ) -> None:
52
+ """Remove a link between a task and a session."""
53
+ with self.db.transaction() as conn:
54
+ conn.execute(
55
+ """
56
+ DELETE FROM session_tasks
57
+ WHERE session_id = ? AND task_id = ? AND action = ?
58
+ """,
59
+ (session_id, task_id, action),
60
+ )
61
+ logger.debug(f"Unlinked task {task_id} from session {session_id} for action {action}")
62
+
63
+ def get_session_tasks(self, session_id: str) -> list[dict[str, Any]]:
64
+ """
65
+ Get all tasks associated with a session.
66
+ Returns a list of dicts with task details and the action.
67
+ """
68
+ query = """
69
+ SELECT t.*, st.action as session_action, st.created_at as link_created_at
70
+ FROM tasks t
71
+ JOIN session_tasks st ON t.id = st.task_id
72
+ WHERE st.session_id = ?
73
+ ORDER BY st.created_at DESC
74
+ """
75
+ rows = self.db.fetchall(query, (session_id,))
76
+
77
+ results = []
78
+ for row in rows:
79
+ task = Task.from_row(row)
80
+ results.append(
81
+ {
82
+ "task": task,
83
+ "action": row["session_action"],
84
+ "link_created_at": row["link_created_at"],
85
+ }
86
+ )
87
+ return results
88
+
89
+ def get_task_sessions(self, task_id: str) -> list[dict[str, Any]]:
90
+ """
91
+ Get all sessions associated with a task.
92
+ """
93
+ # Simple query that relies only on session_tasks to minimize dependencies
94
+ rows = self.db.fetchall(
95
+ "SELECT * FROM session_tasks WHERE task_id = ? ORDER BY created_at DESC", (task_id,)
96
+ )
97
+ return [dict(row) for row in rows]