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,357 @@
1
+ """SQLite database manager for local storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ import os
8
+ import re
9
+ import sqlite3
10
+ import threading
11
+ import weakref
12
+ from collections.abc import Iterator
13
+ from contextlib import AbstractContextManager, contextmanager
14
+ from datetime import date, datetime
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
17
+
18
+ # Register custom datetime adapters/converters (required since Python 3.12)
19
+ # See: https://docs.python.org/3/library/sqlite3.html#default-adapters-and-converters-deprecated
20
+
21
+
22
+ def _adapt_datetime(val: datetime) -> str:
23
+ """Adapt datetime to ISO format string for SQLite storage."""
24
+ return val.isoformat(" ")
25
+
26
+
27
+ def _adapt_date(val: date) -> str:
28
+ """Adapt date to ISO format string for SQLite storage."""
29
+ return val.isoformat()
30
+
31
+
32
+ def _convert_datetime(val: bytes) -> datetime:
33
+ """Convert SQLite datetime string back to datetime object."""
34
+ return datetime.fromisoformat(val.decode())
35
+
36
+
37
+ def _convert_date(val: bytes) -> date:
38
+ """Convert SQLite date string back to date object."""
39
+ return date.fromisoformat(val.decode())
40
+
41
+
42
+ # Register adapters (Python -> SQLite)
43
+ sqlite3.register_adapter(datetime, _adapt_datetime)
44
+ sqlite3.register_adapter(date, _adapt_date)
45
+
46
+ # Register converters (SQLite -> Python) - used with detect_types
47
+ sqlite3.register_converter("datetime", _convert_datetime)
48
+ sqlite3.register_converter("date", _convert_date)
49
+
50
+ if TYPE_CHECKING:
51
+ from gobby.storage.artifacts import LocalArtifactManager
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ @runtime_checkable
57
+ class DatabaseProtocol(Protocol):
58
+ """Protocol defining the database interface for storage managers."""
59
+
60
+ @property
61
+ def db_path(self) -> Any:
62
+ """Return database path."""
63
+ ...
64
+
65
+ @property
66
+ def connection(self) -> sqlite3.Connection:
67
+ """Get database connection (for reads)."""
68
+ ...
69
+
70
+ @property
71
+ def artifact_manager(self) -> Any:
72
+ """Get artifact manager."""
73
+ ...
74
+
75
+ def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
76
+ """Execute SQL statement."""
77
+ ...
78
+
79
+ def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> sqlite3.Cursor:
80
+ """Execute SQL statement with multiple parameter sets."""
81
+ ...
82
+
83
+ def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
84
+ """Execute query and fetch one row."""
85
+ ...
86
+
87
+ def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
88
+ """Execute query and fetch all rows."""
89
+ ...
90
+
91
+ def safe_update(
92
+ self,
93
+ table: str,
94
+ values: dict[str, Any],
95
+ where: str,
96
+ where_params: tuple[Any, ...],
97
+ ) -> sqlite3.Cursor:
98
+ """Safely execute an UPDATE statement with dynamic columns."""
99
+ ...
100
+
101
+ def transaction(self) -> AbstractContextManager[sqlite3.Connection]:
102
+ """Context manager for database transactions."""
103
+ ...
104
+
105
+ def close(self) -> None:
106
+ """Close database connection."""
107
+ ...
108
+
109
+
110
+ # Default database path
111
+ DEFAULT_DB_PATH = Path.home() / ".gobby" / "gobby-hub.db"
112
+
113
+ # SQL identifier validation pattern (alphanumeric + underscore only)
114
+ # Used by safe_update to prevent SQL injection via column/table names
115
+ _SQL_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
116
+
117
+
118
+ class LocalDatabase:
119
+ """
120
+ SQLite database manager with connection pooling.
121
+
122
+ Thread-safe connection management using thread-local storage.
123
+ """
124
+
125
+ def __init__(self, db_path: Path | str | None = None):
126
+ """
127
+ Initialize database manager.
128
+
129
+ Args:
130
+ db_path: Path to SQLite database file. Defaults to ~/.gobby/gobby-hub.db
131
+ """
132
+ # SAFETY SWITCH: During tests, override with safe path from environment
133
+ if db_path is None and os.environ.get("GOBBY_TEST_PROTECT") == "1":
134
+ safe_path = os.environ.get("GOBBY_DATABASE_PATH")
135
+ if safe_path:
136
+ db_path = safe_path
137
+
138
+ self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
139
+ self._local = threading.local()
140
+ self._artifact_manager: LocalArtifactManager | None = None
141
+ self._artifact_manager_lock = threading.Lock()
142
+ # Track all connections for proper cleanup across threads
143
+ self._all_connections: set[sqlite3.Connection] = set()
144
+ self._connections_lock = threading.Lock()
145
+ self._ensure_directory()
146
+
147
+ # Register atexit cleanup using weak reference to avoid preventing GC
148
+ # and to safely handle shutdown without __del__ lock issues
149
+ self._weak_self = weakref.ref(self)
150
+ atexit.register(self._cleanup_at_exit)
151
+
152
+ def _ensure_directory(self) -> None:
153
+ """Create database directory if it doesn't exist."""
154
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
155
+
156
+ def _get_connection(self) -> sqlite3.Connection:
157
+ """Get thread-local database connection."""
158
+ if not hasattr(self._local, "connection") or self._local.connection is None:
159
+ conn = sqlite3.connect(
160
+ str(self.db_path),
161
+ check_same_thread=False,
162
+ isolation_level=None, # Autocommit mode
163
+ )
164
+ conn.row_factory = sqlite3.Row
165
+ # Enable foreign keys
166
+ conn.execute("PRAGMA foreign_keys = ON")
167
+ # Use default DELETE journal mode (more reliable than WAL for dual-write)
168
+ self._local.connection = conn
169
+ # Track for cleanup in close()
170
+ with self._connections_lock:
171
+ self._all_connections.add(conn)
172
+ return cast(sqlite3.Connection, self._local.connection)
173
+
174
+ @property
175
+ def connection(self) -> sqlite3.Connection:
176
+ """Get current thread's database connection."""
177
+ return self._get_connection()
178
+
179
+ @property
180
+ def artifact_manager(self) -> LocalArtifactManager:
181
+ """Get lazily-initialized LocalArtifactManager instance.
182
+
183
+ The artifact manager is created on first access and reused for the
184
+ lifetime of this LocalDatabase instance. Uses double-checked locking
185
+ for thread-safe initialization.
186
+
187
+ Returns:
188
+ LocalArtifactManager instance for managing session artifacts.
189
+ """
190
+ if self._artifact_manager is None:
191
+ with self._artifact_manager_lock:
192
+ # Double-check inside lock
193
+ if self._artifact_manager is None:
194
+ from gobby.storage.artifacts import LocalArtifactManager
195
+
196
+ self._artifact_manager = LocalArtifactManager(self)
197
+ return self._artifact_manager
198
+
199
+ def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
200
+ """Execute SQL statement."""
201
+ return self.connection.execute(sql, params)
202
+
203
+ def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> sqlite3.Cursor:
204
+ """Execute SQL statement with multiple parameter sets."""
205
+ return self.connection.executemany(sql, params_list)
206
+
207
+ def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
208
+ """Execute query and fetch one row."""
209
+ cursor = self.execute(sql, params)
210
+ try:
211
+ return cast(sqlite3.Row | None, cursor.fetchone())
212
+ finally:
213
+ cursor.close()
214
+
215
+ def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
216
+ """Execute query and fetch all rows."""
217
+ cursor = self.execute(sql, params)
218
+ try:
219
+ return cursor.fetchall()
220
+ finally:
221
+ cursor.close()
222
+
223
+ def safe_update(
224
+ self,
225
+ table: str,
226
+ values: dict[str, Any],
227
+ where: str,
228
+ where_params: tuple[Any, ...],
229
+ ) -> sqlite3.Cursor:
230
+ """
231
+ Safely execute an UPDATE statement with dynamic columns.
232
+
233
+ This method validates table and column names against a strict allowlist
234
+ pattern to prevent SQL injection, even though callers typically use
235
+ hardcoded strings. This is defense-in-depth.
236
+
237
+ Args:
238
+ table: Table name (validated against identifier pattern).
239
+ values: Dictionary of column_name -> new_value.
240
+ where: WHERE clause (e.g., "id = ?"). This is NOT validated -
241
+ callers must use parameterized queries for values.
242
+ where_params: Parameters for the WHERE clause placeholders.
243
+
244
+ Returns:
245
+ sqlite3.Cursor from the executed statement.
246
+
247
+ Raises:
248
+ ValueError: If table or column names fail validation.
249
+
250
+ Example:
251
+ db.safe_update(
252
+ "sessions",
253
+ {"status": "closed", "updated_at": now},
254
+ "id = ?",
255
+ (session_id,)
256
+ )
257
+ """
258
+ if not values:
259
+ # No-op: return closed cursor without executing
260
+ cursor = self.connection.cursor()
261
+ cursor.close()
262
+ return cursor
263
+
264
+ # Validate table name
265
+ if not _SQL_IDENTIFIER_PATTERN.match(table):
266
+ raise ValueError(f"Invalid table name: {table!r}")
267
+
268
+ # Validate column names and build SET clause
269
+ set_clauses: list[str] = []
270
+ update_params: list[Any] = []
271
+
272
+ for col, val in values.items():
273
+ if not _SQL_IDENTIFIER_PATTERN.match(col):
274
+ raise ValueError(f"Invalid column name: {col!r}")
275
+ set_clauses.append(f"{col} = ?")
276
+ update_params.append(val)
277
+
278
+ # Construct and execute query
279
+ # nosec B608: Table and column names are validated above against a strict alphanumeric pattern.
280
+ # The WHERE clause uses parameterized queries. This is safe from SQL injection.
281
+ sql = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE {where}" # nosec B608
282
+ full_params = tuple(update_params) + where_params
283
+
284
+ return self.execute(sql, full_params)
285
+
286
+ @contextmanager
287
+ def transaction(self) -> Iterator[sqlite3.Connection]:
288
+ """
289
+ Context manager for database transactions.
290
+
291
+ Usage:
292
+ with db.transaction() as conn:
293
+ conn.execute("INSERT ...")
294
+ conn.execute("UPDATE ...")
295
+ """
296
+ conn = self.connection
297
+ conn.execute("BEGIN")
298
+ try:
299
+ yield conn
300
+ conn.execute("COMMIT")
301
+ except Exception:
302
+ conn.execute("ROLLBACK")
303
+ raise
304
+
305
+ def close(self) -> None:
306
+ """Close all database connections and clean up managers.
307
+
308
+ Can be called explicitly or via context manager. For automatic cleanup
309
+ at interpreter shutdown, atexit handler is used instead of __del__ to
310
+ avoid lock acquisition issues during GC.
311
+ """
312
+ # Clean up artifact manager
313
+ self._artifact_manager = None
314
+
315
+ # Close all connections from all threads
316
+ with self._connections_lock:
317
+ for conn in self._all_connections:
318
+ try:
319
+ conn.close()
320
+ except Exception:
321
+ pass # nosec B110 - connection may already be closed
322
+ self._all_connections.clear()
323
+
324
+ # Clear thread-local reference
325
+ if hasattr(self._local, "connection"):
326
+ self._local.connection = None
327
+
328
+ def _cleanup_at_exit(self) -> None:
329
+ """Atexit handler for safe cleanup during interpreter shutdown.
330
+
331
+ Uses try/except to safely handle any errors that may occur during
332
+ shutdown when modules may already be partially unloaded.
333
+ """
334
+ try:
335
+ self.close()
336
+ except Exception:
337
+ pass # nosec B110 - ignore errors during shutdown
338
+
339
+ def __del__(self) -> None:
340
+ """Clean up connections when object is garbage collected.
341
+
342
+ Note: Most cleanup should happen via atexit or explicit close() calls.
343
+ This is a fallback that unregisters the atexit handler to avoid double-close.
344
+ """
345
+ try:
346
+ # Unregister atexit handler since we're being collected
347
+ atexit.unregister(self._cleanup_at_exit)
348
+ except Exception:
349
+ pass # nosec B110 - ignore errors during gc
350
+
351
+ def __enter__(self) -> LocalDatabase:
352
+ """Enter context manager."""
353
+ return self
354
+
355
+ def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None:
356
+ """Exit context manager, closing connections."""
357
+ self.close()
@@ -0,0 +1,194 @@
1
+ """Inter-session messaging for agent coordination.
2
+
3
+ This module provides storage and management of messages sent between sessions,
4
+ enabling parent-child session communication and agent coordination.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from sqlite3 import Row
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from gobby.storage.database import LocalDatabase
17
+
18
+
19
+ @dataclass
20
+ class InterSessionMessage:
21
+ """A message sent between sessions.
22
+
23
+ Attributes:
24
+ id: Unique message identifier
25
+ from_session: ID of the sending session
26
+ to_session: ID of the receiving session
27
+ content: Message content
28
+ priority: Message priority (e.g., "normal", "urgent")
29
+ sent_at: Timestamp when message was sent
30
+ read_at: Timestamp when message was read (None if unread)
31
+ """
32
+
33
+ id: str
34
+ from_session: str
35
+ to_session: str
36
+ content: str
37
+ priority: str
38
+ sent_at: str
39
+ read_at: str | None
40
+
41
+ @classmethod
42
+ def from_row(cls, row: Row) -> InterSessionMessage:
43
+ """Create instance from database row.
44
+
45
+ Args:
46
+ row: SQLite row with message data
47
+
48
+ Returns:
49
+ InterSessionMessage instance
50
+ """
51
+ return cls(
52
+ id=row["id"],
53
+ from_session=row["from_session"],
54
+ to_session=row["to_session"],
55
+ content=row["content"],
56
+ priority=row["priority"],
57
+ sent_at=row["sent_at"],
58
+ read_at=row["read_at"],
59
+ )
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ """Convert to dictionary.
63
+
64
+ Returns:
65
+ Dictionary with all message fields
66
+ """
67
+ return {
68
+ "id": self.id,
69
+ "from_session": self.from_session,
70
+ "to_session": self.to_session,
71
+ "content": self.content,
72
+ "priority": self.priority,
73
+ "sent_at": self.sent_at,
74
+ "read_at": self.read_at,
75
+ }
76
+
77
+
78
+ class InterSessionMessageManager:
79
+ """Manages inter-session messages.
80
+
81
+ Provides CRUD operations for messages sent between sessions,
82
+ enabling agent coordination and parent-child communication.
83
+ """
84
+
85
+ def __init__(self, db: LocalDatabase) -> None:
86
+ """Initialize the message manager.
87
+
88
+ Args:
89
+ db: LocalDatabase instance for persistence
90
+ """
91
+ self.db = db
92
+
93
+ def create_message(
94
+ self,
95
+ from_session: str,
96
+ to_session: str,
97
+ content: str,
98
+ priority: str = "normal",
99
+ ) -> InterSessionMessage:
100
+ """Create and persist a new message.
101
+
102
+ Args:
103
+ from_session: ID of the sending session
104
+ to_session: ID of the receiving session
105
+ content: Message content
106
+ priority: Message priority (default: "normal")
107
+
108
+ Returns:
109
+ The created InterSessionMessage
110
+ """
111
+ message_id = str(uuid.uuid4())
112
+ sent_at = datetime.now(UTC).isoformat()
113
+
114
+ self.db.execute(
115
+ """
116
+ INSERT INTO inter_session_messages
117
+ (id, from_session, to_session, content, priority, sent_at, read_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, NULL)
119
+ """,
120
+ (message_id, from_session, to_session, content, priority, sent_at),
121
+ )
122
+
123
+ return InterSessionMessage(
124
+ id=message_id,
125
+ from_session=from_session,
126
+ to_session=to_session,
127
+ content=content,
128
+ priority=priority,
129
+ sent_at=sent_at,
130
+ read_at=None,
131
+ )
132
+
133
+ def get_message(self, message_id: str) -> InterSessionMessage | None:
134
+ """Get a message by ID.
135
+
136
+ Args:
137
+ message_id: The message ID to retrieve
138
+
139
+ Returns:
140
+ The InterSessionMessage if found, None otherwise
141
+ """
142
+ row = self.db.fetchone(
143
+ "SELECT * FROM inter_session_messages WHERE id = ?",
144
+ (message_id,),
145
+ )
146
+
147
+ if row:
148
+ return InterSessionMessage.from_row(row)
149
+ return None
150
+
151
+ def get_messages(self, to_session: str, unread_only: bool = False) -> list[InterSessionMessage]:
152
+ """Get messages for a recipient session.
153
+
154
+ Args:
155
+ to_session: ID of the receiving session
156
+ unread_only: If True, only return unread messages
157
+
158
+ Returns:
159
+ List of InterSessionMessage instances
160
+ """
161
+ if unread_only:
162
+ query = """
163
+ SELECT * FROM inter_session_messages
164
+ WHERE to_session = ? AND read_at IS NULL
165
+ """
166
+ else:
167
+ query = "SELECT * FROM inter_session_messages WHERE to_session = ?"
168
+
169
+ rows = self.db.fetchall(query, (to_session,))
170
+ return [InterSessionMessage.from_row(row) for row in rows]
171
+
172
+ def mark_read(self, message_id: str) -> InterSessionMessage:
173
+ """Mark a message as read.
174
+
175
+ Args:
176
+ message_id: The message ID to mark as read
177
+
178
+ Returns:
179
+ The updated InterSessionMessage
180
+
181
+ Raises:
182
+ ValueError: If message not found
183
+ """
184
+ read_at = datetime.now(UTC).isoformat()
185
+
186
+ self.db.execute(
187
+ "UPDATE inter_session_messages SET read_at = ? WHERE id = ?",
188
+ (read_at, message_id),
189
+ )
190
+
191
+ message = self.get_message(message_id)
192
+ if not message:
193
+ raise ValueError(f"Message not found: {message_id}")
194
+ return message