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,13 @@
1
+ """
2
+ Sessions package for multi-CLI session management.
3
+
4
+ This package provides:
5
+ - SessionManager: Session registration, handoff, and context restoration
6
+ - SummaryFileGenerator: LLM-powered session summaries (failover)
7
+ - Transcript parsers: CLI-specific transcript parsing (Claude, Codex, Gemini, etc.)
8
+ """
9
+
10
+ from gobby.sessions.manager import SessionManager
11
+ from gobby.sessions.summary import SummaryFileGenerator
12
+
13
+ __all__ = ["SessionManager", "SummaryFileGenerator"]
@@ -0,0 +1,322 @@
1
+ """
2
+ Transcript analyzer for autonomous session handoff.
3
+
4
+ Extracts structured context from session transcripts to support
5
+ autonomous continuity without relying on manual /clear boundaries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ from gobby.sessions.transcripts.base import TranscriptParser
16
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class HandoffContext:
23
+ """Structured context for autonomous handoff."""
24
+
25
+ active_gobby_task: dict[str, Any] | None = None
26
+ todo_state: list[dict[str, Any]] = field(default_factory=list)
27
+ files_modified: list[str] = field(default_factory=list)
28
+ git_commits: list[dict[str, Any]] = field(default_factory=list)
29
+ git_status: str = ""
30
+ initial_goal: str = ""
31
+ recent_activity: list[str] = field(default_factory=list)
32
+ key_decisions: list[str] | None = None
33
+ active_worktree: dict[str, Any] | None = None
34
+ """Worktree context if session is operating in a worktree."""
35
+
36
+
37
+ class TranscriptAnalyzer:
38
+ """
39
+ Transcript analysis for handoff context.
40
+
41
+ Primary: Claude Code
42
+ Extensible: Other CLIs via TranscriptParser protocol
43
+ """
44
+
45
+ def __init__(self, parser: TranscriptParser | None = None):
46
+ """
47
+ Initialize TranscriptAnalyzer.
48
+
49
+ Args:
50
+ parser: Optional specific parser. Defaults to ClaudeTranscriptParser.
51
+ """
52
+ self.parser = parser or ClaudeTranscriptParser()
53
+
54
+ def extract_handoff_context(
55
+ self, turns: list[dict[str, Any]], max_turns: int = 150
56
+ ) -> HandoffContext:
57
+ """
58
+ Extract context for autonomous handoff.
59
+
60
+ Analyzes recent turns to find:
61
+ - Active task state from gobby-tasks calls
62
+ - TodoWrite state from Claude's internal tracking (if available in transcript)
63
+ - Files modified from Edit/Write/Bash calls
64
+ - Git commits from Bash calls
65
+ - The original user goal (first user message)
66
+ - Recent tool activity summaries
67
+
68
+ Args:
69
+ turns: List of transcript turns (dicts)
70
+ max_turns: Maximum number of turns to look back for context
71
+
72
+ Returns:
73
+ HandoffContext object populated with extracted data
74
+ """
75
+ context = HandoffContext()
76
+
77
+ if not turns:
78
+ return context
79
+
80
+ # 1. Extract Initial Goal (First User Message)
81
+ # We scan from the beginning to find the first user message
82
+ for turn in turns:
83
+ if turn.get("type") == "user":
84
+ msg = turn.get("message", {})
85
+ context.initial_goal = str(msg.get("content", "")).strip()
86
+ break
87
+
88
+ # 2. Analyze Recent Activity (Scan backwards)
89
+ # We look at the last `max_turns` or less
90
+ relevant_turns = turns[-max_turns:] if len(turns) > max_turns else turns
91
+
92
+ # Track what we've found to avoid duplicates where appropriate
93
+ found_active_task = False
94
+ modified_files_set: set[str] = set()
95
+
96
+ for turn in reversed(relevant_turns):
97
+ message = turn.get("message", {})
98
+ content_blocks = message.get("content", [])
99
+
100
+ # Handle Claude's content block list format
101
+ if isinstance(content_blocks, list):
102
+ for block in content_blocks:
103
+ if not isinstance(block, dict):
104
+ continue
105
+
106
+ block_type = block.get("type")
107
+
108
+ # Check for Tool Use
109
+ if block_type == "tool_use":
110
+ self._analyze_tool_use(
111
+ block, context, found_active_task, modified_files_set
112
+ )
113
+ if (
114
+ block.get("name") == "mcp_call_tool"
115
+ and block.get("input", {}).get("server_name") == "gobby-tasks"
116
+ ):
117
+ # We found a task interaction, but we want the *latest* active one
118
+ # The helper _analyze_tool_use will handle extraction,
119
+ # we just mark we found some task activity if needed.
120
+ pass
121
+
122
+ context.files_modified = sorted(modified_files_set)
123
+
124
+ # 3. Extract TodoWrite state
125
+ context.todo_state = self._extract_todowrite(relevant_turns)
126
+
127
+ # 4. Recent Activity Summary (Last 10 calls)
128
+ # Extract meaningful details from recent tool uses
129
+ recent_tools = []
130
+ count = 0
131
+ for turn in reversed(turns):
132
+ if count >= 10:
133
+ break
134
+ message = turn.get("message", {})
135
+ content = message.get("content", [])
136
+ if isinstance(content, list):
137
+ for block in content:
138
+ if isinstance(block, dict) and block.get("type") == "tool_use":
139
+ description = self._format_tool_description(block)
140
+ recent_tools.append(description)
141
+ count += 1
142
+ if count >= 10:
143
+ break
144
+ context.recent_activity = recent_tools
145
+
146
+ return context
147
+
148
+ def _analyze_tool_use(
149
+ self,
150
+ block: dict[str, Any],
151
+ context: HandoffContext,
152
+ found_active_task: bool,
153
+ modified_files_set: set[str],
154
+ ) -> None:
155
+ """Helper to analyze a single tool use block."""
156
+ tool_name = block.get("name")
157
+ tool_input = block.get("input", {})
158
+
159
+ # -- Gobby Tasks --
160
+ if tool_name == "mcp_call_tool":
161
+ server = tool_input.get("server_name")
162
+ tool = tool_input.get("tool_name")
163
+ args = tool_input.get("arguments", {})
164
+
165
+ if server == "gobby-tasks":
166
+ # We want the most recent task interaction that implies working on a task
167
+ # e.g., create_task, update_task, get_task
168
+ if not context.active_gobby_task:
169
+ # Heuristic: If we see a task interaction, it might be the active task
170
+ # especially if it's get_task or update_task
171
+ task_id = args.get("task_id") or args.get("id")
172
+ if task_id:
173
+ context.active_gobby_task = {
174
+ "id": task_id,
175
+ "action": tool,
176
+ # We don't have the full task object here, just the ID and intent
177
+ # The injection template might need to fetch it or we assume
178
+ # the ID is enough for the user to know.
179
+ # Ideally, we'd have the title, but we can't get it from the tool input easily
180
+ # unless it was a create/update with title.
181
+ # For now, store what we have.
182
+ "title": args.get("title", f"Task {task_id}"),
183
+ }
184
+
185
+ # -- File Modifications --
186
+ elif tool_name in ("Edit", "Write", "Replace", "replace_file_content", "write_to_file"):
187
+ # Claude Code uses Edit/Write? Antigravity uses write_to_file/replace_file_content
188
+ # We should support both if possible or stick to what we expect Claude to use.
189
+ # Claude Code typically uses `grep_search`, `view_file`, `edit_file`?
190
+ # Let's assume standard names or generic ones.
191
+ path = (
192
+ tool_input.get("file_path")
193
+ or tool_input.get("TargetFile")
194
+ or tool_input.get("path")
195
+ )
196
+ if path:
197
+ modified_files_set.add(path)
198
+
199
+ # -- Git Commits --
200
+ elif tool_name == "Bash":
201
+ command = tool_input.get("command", "")
202
+ if "git commit" in command:
203
+ # Attempt to extract message
204
+ # This is a bit brittle, but useful context
205
+ context.git_commits.append(
206
+ {
207
+ "command": command,
208
+ "timestamp": datetime.now().isoformat(), # Approx time
209
+ }
210
+ )
211
+
212
+ def _format_tool_description(self, block: dict[str, Any]) -> str:
213
+ """
214
+ Format a tool use block into a human-readable description.
215
+
216
+ Extracts meaningful details instead of just showing the tool name.
217
+
218
+ Args:
219
+ block: Tool use block with 'name' and 'input' keys
220
+
221
+ Returns:
222
+ Human-readable description of what the tool call did
223
+ """
224
+ tool_name = block.get("name", "unknown")
225
+ tool_input = block.get("input", {})
226
+
227
+ # MCP tool calls - show server.tool
228
+ if tool_name in ("mcp__gobby__call_tool", "mcp_call_tool"):
229
+ server = tool_input.get("server_name", "unknown")
230
+ tool = tool_input.get("tool_name", "unknown")
231
+ return f"Called {server}.{tool}"
232
+
233
+ # Bash - show the command (truncated)
234
+ if tool_name == "Bash":
235
+ command = tool_input.get("command", "")
236
+ # Truncate long commands
237
+ if len(command) > 60:
238
+ command = command[:57] + "..."
239
+ return f"Ran: {command}"
240
+
241
+ # Edit/Write - show the file path
242
+ if tool_name in ("Edit", "Write"):
243
+ path = tool_input.get("file_path", "")
244
+ if path:
245
+ return f"{tool_name}: {path}"
246
+ return f"Called {tool_name}"
247
+
248
+ # Read - show the file path
249
+ if tool_name == "Read":
250
+ path = tool_input.get("file_path", "")
251
+ if path:
252
+ return f"Read: {path}"
253
+ return "Called Read"
254
+
255
+ # Glob - show the pattern
256
+ if tool_name == "Glob":
257
+ pattern = tool_input.get("pattern", "")
258
+ if pattern:
259
+ return f"Glob: {pattern}"
260
+ return "Called Glob"
261
+
262
+ # Grep - show the pattern
263
+ if tool_name == "Grep":
264
+ pattern = tool_input.get("pattern", "")
265
+ if pattern:
266
+ # Truncate long patterns
267
+ if len(pattern) > 40:
268
+ pattern = pattern[:37] + "..."
269
+ return f"Grep: {pattern}"
270
+ return "Called Grep"
271
+
272
+ # TodoWrite - show count
273
+ if tool_name == "TodoWrite":
274
+ todos = tool_input.get("todos", [])
275
+ return f"TodoWrite: {len(todos)} items"
276
+
277
+ # Task tool - show subagent type
278
+ if tool_name == "Task":
279
+ subagent = tool_input.get("subagent_type", "")
280
+ desc = tool_input.get("description", "")
281
+ if subagent:
282
+ return f"Task ({subagent}): {desc}" if desc else f"Task ({subagent})"
283
+ return f"Task: {desc}" if desc else "Called Task"
284
+
285
+ # Default - just show the tool name
286
+ return f"Called {tool_name}"
287
+
288
+ def _extract_todowrite(self, turns: list[dict[str, Any]]) -> list[dict[str, Any]]:
289
+ """
290
+ Extract the most recent TodoWrite state from transcript.
291
+
292
+ Scans turns in reverse to find the last TodoWrite tool call and
293
+ extracts the todos list.
294
+
295
+ Args:
296
+ turns: List of transcript turns to scan
297
+
298
+ Returns:
299
+ List of todo dicts with 'content' and 'status' keys, or empty list
300
+ """
301
+ for turn in reversed(turns):
302
+ message = turn.get("message", {})
303
+ content = message.get("content", [])
304
+
305
+ if isinstance(content, list):
306
+ for block in content:
307
+ if isinstance(block, dict) and block.get("type") == "tool_use":
308
+ if block.get("name") == "TodoWrite":
309
+ tool_input = block.get("input", {})
310
+ todos = tool_input.get("todos", [])
311
+
312
+ if todos:
313
+ # Return the raw todo list for HandoffContext
314
+ return [
315
+ {
316
+ "content": todo.get("content", ""),
317
+ "status": todo.get("status", "pending"),
318
+ }
319
+ for todo in todos
320
+ ]
321
+
322
+ return []
@@ -0,0 +1,240 @@
1
+ """
2
+ Session lifecycle manager.
3
+
4
+ Handles background jobs for:
5
+ - Expiring stale sessions
6
+ - Processing transcripts for expired sessions
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ from typing import Any
13
+
14
+ from gobby.config.app import SessionLifecycleConfig
15
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
16
+ from gobby.sessions.transcripts.codex import CodexTranscriptParser
17
+ from gobby.sessions.transcripts.gemini import GeminiTranscriptParser
18
+ from gobby.storage.database import DatabaseProtocol
19
+ from gobby.storage.session_messages import LocalSessionMessageManager
20
+ from gobby.storage.sessions import LocalSessionManager
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class SessionLifecycleManager:
26
+ """
27
+ Manages session lifecycle background jobs.
28
+
29
+ Two independent jobs:
30
+ 1. expire_stale_sessions - marks old active/paused sessions as expired
31
+ 2. process_pending_transcripts - processes transcripts for expired sessions
32
+ """
33
+
34
+ def __init__(self, db: DatabaseProtocol, config: SessionLifecycleConfig):
35
+ self.db = db
36
+ self.config = config
37
+ self.session_manager = LocalSessionManager(db)
38
+ self.message_manager = LocalSessionMessageManager(db)
39
+
40
+ self._running = False
41
+ self._expire_task: asyncio.Task[None] | None = None
42
+ self._process_task: asyncio.Task[None] | None = None
43
+
44
+ async def start(self) -> None:
45
+ """Start background jobs."""
46
+ if self._running:
47
+ return
48
+
49
+ self._running = True
50
+
51
+ # Start expire job
52
+ self._expire_task = asyncio.create_task(
53
+ self._expire_loop(),
54
+ name="session-lifecycle-expire",
55
+ )
56
+
57
+ # Start process job
58
+ self._process_task = asyncio.create_task(
59
+ self._process_loop(),
60
+ name="session-lifecycle-process",
61
+ )
62
+
63
+ logger.info(
64
+ f"SessionLifecycleManager started "
65
+ f"(expire every {self.config.expire_check_interval_minutes}m, "
66
+ f"process every {self.config.transcript_processing_interval_minutes}m)"
67
+ )
68
+
69
+ async def stop(self) -> None:
70
+ """Stop background jobs."""
71
+ self._running = False
72
+
73
+ tasks = [t for t in [self._expire_task, self._process_task] if t]
74
+ for task in tasks:
75
+ task.cancel()
76
+
77
+ if tasks:
78
+ await asyncio.gather(*tasks, return_exceptions=True)
79
+
80
+ self._expire_task = None
81
+ self._process_task = None
82
+
83
+ logger.info("SessionLifecycleManager stopped")
84
+
85
+ async def _expire_loop(self) -> None:
86
+ """Background loop for expiring stale sessions."""
87
+ interval_seconds = self.config.expire_check_interval_minutes * 60
88
+
89
+ while self._running:
90
+ try:
91
+ await self._expire_stale_sessions()
92
+ except Exception as e:
93
+ logger.error(f"Error in expire loop: {e}")
94
+
95
+ try:
96
+ await asyncio.sleep(interval_seconds)
97
+ except asyncio.CancelledError:
98
+ break
99
+
100
+ async def _process_loop(self) -> None:
101
+ """Background loop for processing pending transcripts."""
102
+ interval_seconds = self.config.transcript_processing_interval_minutes * 60
103
+
104
+ while self._running:
105
+ try:
106
+ await self._process_pending_transcripts()
107
+ except Exception as e:
108
+ logger.error(f"Error in process loop: {e}")
109
+
110
+ try:
111
+ await asyncio.sleep(interval_seconds)
112
+ except asyncio.CancelledError:
113
+ break
114
+
115
+ async def _expire_stale_sessions(self) -> int:
116
+ """Pause inactive active sessions and expire stale sessions."""
117
+ # First, pause active sessions that have been idle too long
118
+ # This catches orphaned sessions that never got AFTER_AGENT hook
119
+ paused = self.session_manager.pause_inactive_active_sessions(
120
+ timeout_minutes=self.config.active_session_pause_minutes
121
+ )
122
+
123
+ # Then expire sessions that have been paused/active for too long
124
+ expired = self.session_manager.expire_stale_sessions(
125
+ timeout_hours=self.config.stale_session_timeout_hours
126
+ )
127
+
128
+ return paused + expired
129
+
130
+ async def _process_pending_transcripts(self) -> int:
131
+ """Process transcripts for expired sessions."""
132
+ sessions = self.session_manager.get_pending_transcript_sessions(
133
+ limit=self.config.transcript_processing_batch_size
134
+ )
135
+
136
+ if not sessions:
137
+ return 0
138
+
139
+ processed = 0
140
+ for session in sessions:
141
+ try:
142
+ await self._process_session_transcript(session.id, session.jsonl_path)
143
+ self.session_manager.mark_transcript_processed(session.id)
144
+ processed += 1
145
+ logger.debug(f"Processed transcript for session {session.id}")
146
+ except Exception as e:
147
+ logger.error(f"Failed to process transcript for {session.id}: {e}")
148
+
149
+ if processed > 0:
150
+ logger.info(f"Processed {processed} session transcripts")
151
+
152
+ return processed
153
+
154
+ async def _process_session_transcript(self, session_id: str, jsonl_path: str | None) -> None:
155
+ """
156
+ Process a full transcript for a session.
157
+
158
+ Reads the entire transcript and stores messages.
159
+ Aggregates token usage and costs.
160
+ Uses idempotent upsert so re-processing is safe.
161
+
162
+ Args:
163
+ session_id: Session ID
164
+ jsonl_path: Path to transcript JSONL file
165
+ """
166
+ if not jsonl_path or not os.path.exists(jsonl_path):
167
+ logger.warning(f"Transcript not found for session {session_id}: {jsonl_path}")
168
+ return
169
+
170
+ # Read entire file
171
+ try:
172
+ with open(jsonl_path, encoding="utf-8") as f:
173
+ lines = f.readlines()
174
+ except Exception as e:
175
+ logger.error(f"Error reading transcript {jsonl_path}: {e}")
176
+ raise
177
+
178
+ if not lines:
179
+ return
180
+
181
+ # Parse all lines
182
+ session = self.session_manager.get(session_id)
183
+ if not session:
184
+ return
185
+
186
+ # Choose parser based on source
187
+ # Default to Claude for backward compatibility or safety
188
+ # But we should rely on session.source if possible
189
+ parser: Any = ClaudeTranscriptParser()
190
+ if session.source == "gemini":
191
+ parser = GeminiTranscriptParser()
192
+ elif session.source == "codex":
193
+ parser = CodexTranscriptParser()
194
+ elif session.source == "antigravity":
195
+ parser = ClaudeTranscriptParser()
196
+ # Default (claude or unknown) uses Claude transcript format
197
+
198
+ messages = parser.parse_lines(lines, start_index=0)
199
+
200
+ if not messages:
201
+ return
202
+
203
+ # Store messages (upsert - safe for re-processing)
204
+ await self.message_manager.store_messages(session_id, messages)
205
+
206
+ # Aggregate usage
207
+ input_tokens = 0
208
+ output_tokens = 0
209
+ cache_creation_tokens = 0
210
+ cache_read_tokens = 0
211
+ total_cost_usd = 0.0
212
+
213
+ for msg in messages:
214
+ if msg.usage:
215
+ input_tokens += msg.usage.input_tokens
216
+ output_tokens += msg.usage.output_tokens
217
+ cache_creation_tokens += msg.usage.cache_creation_tokens
218
+ cache_read_tokens += msg.usage.cache_read_tokens
219
+ if msg.usage.total_cost_usd:
220
+ total_cost_usd += msg.usage.total_cost_usd
221
+
222
+ # Update session with aggregated usage
223
+ # We only update if we found some usage, to avoid overwriting with zeros if re-processing
224
+ # (though re-processing from scratch IS the source of truth, so zeros might be correct if no usage found)
225
+ # Actually, let's always update to ensure consistency with the file
226
+ self.session_manager.update_usage(
227
+ session_id=session_id,
228
+ input_tokens=input_tokens,
229
+ output_tokens=output_tokens,
230
+ cache_creation_tokens=cache_creation_tokens,
231
+ cache_read_tokens=cache_read_tokens,
232
+ total_cost_usd=total_cost_usd,
233
+ )
234
+
235
+ # Update processing state
236
+ await self.message_manager.update_state(
237
+ session_id=session_id,
238
+ byte_offset=sum(len(line.encode("utf-8")) for line in lines),
239
+ message_index=messages[-1].index,
240
+ )