gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -4,8 +4,12 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle workflow state persistence and variable management.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from gobby.workflows.actions import ActionContext
9
13
 
10
14
  logger = logging.getLogger(__name__)
11
15
 
@@ -121,3 +125,58 @@ def mark_loop_complete(state: Any) -> dict[str, Any]:
121
125
  state.variables = {}
122
126
  state.variables["stop_reason"] = "completed"
123
127
  return {"loop_marked_complete": True}
128
+
129
+
130
+ # --- ActionHandler-compatible wrappers ---
131
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
132
+
133
+
134
+ async def handle_load_workflow_state(
135
+ context: "ActionContext", **kwargs: Any
136
+ ) -> dict[str, Any] | None:
137
+ """ActionHandler wrapper for load_workflow_state."""
138
+ return await asyncio.to_thread(
139
+ load_workflow_state, context.db, context.session_id, context.state
140
+ )
141
+
142
+
143
+ async def handle_save_workflow_state(
144
+ context: "ActionContext", **kwargs: Any
145
+ ) -> dict[str, Any] | None:
146
+ """ActionHandler wrapper for save_workflow_state."""
147
+ return await asyncio.to_thread(save_workflow_state, context.db, context.state)
148
+
149
+
150
+ async def handle_set_variable(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
151
+ """ActionHandler wrapper for set_variable.
152
+
153
+ Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
154
+ """
155
+ value = kwargs.get("value")
156
+
157
+ # Render template if value contains Jinja2 syntax
158
+ if isinstance(value, str) and "{{" in value:
159
+ template_context = {
160
+ "variables": context.state.variables or {},
161
+ "state": context.state,
162
+ }
163
+ if context.template_engine:
164
+ value = context.template_engine.render(value, template_context)
165
+ else:
166
+ logger.warning("handle_set_variable: template_engine is None, skipping template render")
167
+
168
+ return set_variable(context.state, kwargs.get("name"), value)
169
+
170
+
171
+ async def handle_increment_variable(
172
+ context: "ActionContext", **kwargs: Any
173
+ ) -> dict[str, Any] | None:
174
+ """ActionHandler wrapper for increment_variable."""
175
+ return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
176
+
177
+
178
+ async def handle_mark_loop_complete(
179
+ context: "ActionContext", **kwargs: Any
180
+ ) -> dict[str, Any] | None:
181
+ """ActionHandler wrapper for mark_loop_complete."""
182
+ return mark_loop_complete(context.state)
@@ -161,3 +161,58 @@ def clear_stop_signal(
161
161
 
162
162
  cleared = stop_registry.clear(session_id)
163
163
  return {"success": True, "cleared": cleared}
164
+
165
+
166
+ # --- ActionHandler factory functions ---
167
+ # These create ActionHandler-compatible wrappers that close over the stop_registry.
168
+ # The ActionExecutor calls these factories in _register_defaults() to create handlers
169
+ # that have access to the executor's stop_registry instance.
170
+
171
+
172
+ def make_handle_check_stop_signal(
173
+ stop_registry: "StopRegistry | None",
174
+ ) -> Any:
175
+ """Factory that creates a check_stop_signal handler with access to stop_registry."""
176
+
177
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
178
+ """ActionHandler for check_stop_signal."""
179
+ return check_stop_signal(
180
+ stop_registry=stop_registry,
181
+ session_id=context.session_id,
182
+ state=context.state,
183
+ acknowledge=kwargs.get("acknowledge", False),
184
+ )
185
+
186
+ return handler
187
+
188
+
189
+ def make_handle_request_stop(
190
+ stop_registry: "StopRegistry | None",
191
+ ) -> Any:
192
+ """Factory that creates a request_stop handler with access to stop_registry."""
193
+
194
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
195
+ """ActionHandler for request_stop."""
196
+ return request_stop(
197
+ stop_registry=stop_registry,
198
+ session_id=kwargs.get("session_id", context.session_id),
199
+ source=kwargs.get("source", "workflow"),
200
+ reason=kwargs.get("reason"),
201
+ )
202
+
203
+ return handler
204
+
205
+
206
+ def make_handle_clear_stop_signal(
207
+ stop_registry: "StopRegistry | None",
208
+ ) -> Any:
209
+ """Factory that creates a clear_stop_signal handler with access to stop_registry."""
210
+
211
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
212
+ """ActionHandler for clear_stop_signal."""
213
+ return clear_stop_signal(
214
+ stop_registry=stop_registry,
215
+ session_id=kwargs.get("session_id", context.session_id),
216
+ )
217
+
218
+ return handler
@@ -9,16 +9,22 @@ from __future__ import annotations
9
9
  import json
10
10
  import logging
11
11
  from pathlib import Path
12
- from typing import Any, Literal
12
+ from typing import TYPE_CHECKING, Any, Literal
13
13
 
14
14
  from gobby.workflows.git_utils import get_file_changes, get_git_status
15
15
 
16
+ if TYPE_CHECKING:
17
+ from gobby.workflows.actions import ActionContext
18
+
16
19
  logger = logging.getLogger(__name__)
17
20
 
18
21
 
19
22
  def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
20
23
  """Format transcript turns for LLM analysis.
21
24
 
25
+ Handles both Claude Code format (nested message.role/content) and
26
+ Gemini CLI format (flat type/role/content).
27
+
22
28
  Args:
23
29
  turns: List of transcript turn dicts
24
30
 
@@ -27,51 +33,108 @@ def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
27
33
  """
28
34
  formatted: list[str] = []
29
35
  for i, turn in enumerate(turns):
30
- message = turn.get("message", {})
31
- role = message.get("role", "unknown")
32
- content = message.get("content", "")
33
-
34
- # Assistant messages have content as array of blocks
35
- if isinstance(content, list):
36
- text_parts: list[str] = []
37
- for block in content:
38
- if isinstance(block, dict):
39
- if block.get("type") == "text":
40
- text_parts.append(block.get("text", ""))
41
- elif block.get("type") == "thinking":
42
- text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
43
- elif block.get("type") == "tool_use":
44
- text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
45
- elif block.get("type") == "tool_result":
46
- result_content = block.get("content", "")
47
- # Extract text from list of content blocks if needed
48
- if isinstance(result_content, list):
49
- extracted = []
50
- for item in result_content:
51
- if isinstance(item, dict):
52
- extracted.append(
53
- item.get("text", "") or item.get("content", "")
54
- )
55
- else:
56
- extracted.append(str(item))
57
- result_content = " ".join(extracted)
58
- content_str = str(result_content)
59
- preview = content_str[:100]
60
- suffix = "..." if len(content_str) > 100 else ""
61
- text_parts.append(f"[Result: {preview}{suffix}]")
62
- content = " ".join(text_parts)
36
+ # Detect format: Gemini CLI uses "type" field, Claude uses nested "message"
37
+ event_type = turn.get("type")
38
+
39
+ if event_type:
40
+ # Gemini CLI format: flat structure with type field
41
+ role, content = _format_gemini_turn(turn, event_type)
42
+ if role is None:
43
+ continue # Skip non-displayable events
44
+ else:
45
+ # Claude Code format: nested message structure
46
+ role, content = _format_claude_turn(turn)
63
47
 
64
48
  formatted.append(f"[Turn {i + 1} - {role}]: {content}")
65
49
 
66
50
  return "\n\n".join(formatted)
67
51
 
68
52
 
53
+ def _format_gemini_turn(turn: dict[str, Any], event_type: str) -> tuple[str | None, str]:
54
+ """Format a Gemini CLI turn.
55
+
56
+ Returns:
57
+ Tuple of (role, formatted_content) or (None, "") if should skip
58
+ """
59
+ if event_type == "message":
60
+ role = turn.get("role", "unknown")
61
+ if role == "model":
62
+ role = "assistant"
63
+ content = turn.get("content", "")
64
+ if isinstance(content, list):
65
+ content = " ".join(str(part) for part in content)
66
+ return role, str(content)
67
+
68
+ elif event_type == "tool_use":
69
+ tool_name = turn.get("tool_name") or turn.get("function_name", "unknown")
70
+ params = turn.get("parameters") or turn.get("args", {})
71
+ param_preview = str(params)[:100] if params else ""
72
+ return "assistant", f"[Tool: {tool_name}] {param_preview}"
73
+
74
+ elif event_type == "tool_result":
75
+ tool_name = turn.get("tool_name", "")
76
+ output = turn.get("output") or turn.get("result", "")
77
+ output_str = str(output)
78
+ preview = output_str[:100]
79
+ suffix = "..." if len(output_str) > 100 else ""
80
+ return "tool", f"[Result{' from ' + tool_name if tool_name else ''}]: {preview}{suffix}"
81
+
82
+ elif event_type in ("init", "result"):
83
+ # Skip initialization and final result events
84
+ return None, ""
85
+
86
+ else:
87
+ # Unknown type, try to extract something
88
+ content = turn.get("content", turn.get("message", ""))
89
+ return "unknown", str(content)[:200]
90
+
91
+
92
+ def _format_claude_turn(turn: dict[str, Any]) -> tuple[str, str]:
93
+ """Format a Claude Code turn with nested message structure."""
94
+ message = turn.get("message", {})
95
+ role = message.get("role", "unknown")
96
+ content = message.get("content", "")
97
+
98
+ # Assistant messages have content as array of blocks
99
+ if isinstance(content, list):
100
+ text_parts: list[str] = []
101
+ for block in content:
102
+ if isinstance(block, dict):
103
+ if block.get("type") == "text":
104
+ text_parts.append(block.get("text", ""))
105
+ elif block.get("type") == "thinking":
106
+ text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
107
+ elif block.get("type") == "tool_use":
108
+ text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
109
+ elif block.get("type") == "tool_result":
110
+ result_content = block.get("content", "")
111
+ # Extract text from list of content blocks if needed
112
+ if isinstance(result_content, list):
113
+ extracted = []
114
+ for item in result_content:
115
+ if isinstance(item, dict):
116
+ extracted.append(item.get("text", "") or item.get("content", ""))
117
+ else:
118
+ extracted.append(str(item))
119
+ result_content = " ".join(extracted)
120
+ content_str = str(result_content)
121
+ preview = content_str[:100]
122
+ suffix = "..." if len(content_str) > 100 else ""
123
+ text_parts.append(f"[Result: {preview}{suffix}]")
124
+ content = " ".join(text_parts)
125
+
126
+ return role, str(content)
127
+
128
+
69
129
  def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
70
130
  """Extract the last TodoWrite tool call's todos list from transcript.
71
131
 
72
132
  Scans turns in reverse to find the most recent TodoWrite tool call
73
133
  and formats it as a markdown checklist.
74
134
 
135
+ Handles both Claude Code format (nested message.content) and
136
+ Gemini CLI format (flat type/tool_name/parameters).
137
+
75
138
  Args:
76
139
  turns: List of transcript turns
77
140
 
@@ -79,6 +142,16 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
79
142
  Formatted markdown string with todo list, or empty string if not found
80
143
  """
81
144
  for turn in reversed(turns):
145
+ # Check Gemini CLI format: flat structure with type="tool_use"
146
+ event_type = turn.get("type")
147
+ if event_type == "tool_use":
148
+ tool_name = turn.get("tool_name") or turn.get("function_name", "")
149
+ if tool_name == "TodoWrite":
150
+ tool_input = turn.get("parameters") or turn.get("args") or turn.get("input", {})
151
+ todos = tool_input.get("todos", [])
152
+ return _format_todos(todos)
153
+
154
+ # Check Claude Code format: nested message.content
82
155
  message = turn.get("message", {})
83
156
  content = message.get("content", [])
84
157
 
@@ -88,29 +161,32 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
88
161
  if block.get("name") == "TodoWrite":
89
162
  tool_input = block.get("input", {})
90
163
  todos = tool_input.get("todos", [])
164
+ return _format_todos(todos)
91
165
 
92
- if not todos:
93
- return ""
166
+ return ""
94
167
 
95
- # Format as markdown checklist
96
- lines: list[str] = []
97
- for todo in todos:
98
- content_text = todo.get("content", "")
99
- status = todo.get("status", "pending")
100
168
 
101
- # Map status to checkbox style
102
- if status == "completed":
103
- checkbox = "[x]"
104
- elif status == "in_progress":
105
- checkbox = "[>]"
106
- else:
107
- checkbox = "[ ]"
169
+ def _format_todos(todos: list[dict[str, Any]]) -> str:
170
+ """Format todos list as markdown checklist."""
171
+ if not todos:
172
+ return ""
108
173
 
109
- lines.append(f"- {checkbox} {content_text}")
174
+ lines: list[str] = []
175
+ for todo in todos:
176
+ content_text = todo.get("content", "")
177
+ status = todo.get("status", "pending")
110
178
 
111
- return "\n".join(lines)
179
+ # Map status to checkbox style
180
+ if status == "completed":
181
+ checkbox = "[x]"
182
+ elif status == "in_progress":
183
+ checkbox = "[>]"
184
+ else:
185
+ checkbox = "[ ]"
112
186
 
113
- return ""
187
+ lines.append(f"- {checkbox} {content_text}")
188
+
189
+ return "\n".join(lines)
114
190
 
115
191
 
116
192
  async def synthesize_title(
@@ -359,3 +435,93 @@ async def generate_handoff(
359
435
  return {"error": "Failed to generate summary"}
360
436
 
361
437
  return {"handoff_created": True, "summary_length": summary_result.get("summary_length", 0)}
438
+
439
+
440
+ # --- ActionHandler-compatible wrappers ---
441
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
442
+
443
+
444
+ async def handle_synthesize_title(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
445
+ """ActionHandler wrapper for synthesize_title."""
446
+ # Extract prompt from event data (UserPromptSubmit hook)
447
+ prompt = None
448
+ if context.event_data:
449
+ prompt = context.event_data.get("prompt")
450
+
451
+ return await synthesize_title(
452
+ session_manager=context.session_manager,
453
+ session_id=context.session_id,
454
+ llm_service=context.llm_service,
455
+ transcript_processor=context.transcript_processor,
456
+ template_engine=context.template_engine,
457
+ template=kwargs.get("template"),
458
+ prompt=prompt,
459
+ )
460
+
461
+
462
+ async def handle_generate_summary(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
463
+ """ActionHandler wrapper for generate_summary."""
464
+ return await generate_summary(
465
+ session_manager=context.session_manager,
466
+ session_id=context.session_id,
467
+ llm_service=context.llm_service,
468
+ transcript_processor=context.transcript_processor,
469
+ template=kwargs.get("template"),
470
+ mode=kwargs.get("mode", "clear"),
471
+ previous_summary=kwargs.get("previous_summary"),
472
+ )
473
+
474
+
475
+ async def handle_generate_handoff(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
476
+ """ActionHandler wrapper for generate_handoff.
477
+
478
+ Handles mode detection from event_data and previous summary fetching for compact mode.
479
+ Also supports loading templates from prompts collection via 'prompt' parameter.
480
+ """
481
+ # Detect mode from kwargs or event data
482
+ mode = kwargs.get("mode", "clear")
483
+
484
+ # Check if this is a compact event based on event_data
485
+ COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
486
+ if context.event_data:
487
+ raw_event_type = context.event_data.get("event_type") or ""
488
+ normalized_event_type = str(raw_event_type).strip().lower()
489
+ if normalized_event_type in COMPACT_EVENT_TYPES:
490
+ mode = "compact"
491
+
492
+ # For compact mode, fetch previous summary for cumulative compression
493
+ previous_summary = None
494
+ if mode == "compact":
495
+ current_session = context.session_manager.get(context.session_id)
496
+ if current_session:
497
+ previous_summary = getattr(current_session, "summary_markdown", None)
498
+ if previous_summary:
499
+ logger.debug(
500
+ f"Compact mode: using previous summary ({len(previous_summary)} chars) "
501
+ f"for cumulative compression"
502
+ )
503
+
504
+ # Load template from prompts collection if 'prompt' parameter provided
505
+ template = kwargs.get("template")
506
+ prompt_path = kwargs.get("prompt")
507
+ if prompt_path and not template:
508
+ try:
509
+ from gobby.prompts.loader import PromptLoader
510
+
511
+ loader = PromptLoader()
512
+ prompt_template = loader.load(prompt_path)
513
+ template = prompt_template.content
514
+ logger.debug(f"Loaded prompt template from: {prompt_path}")
515
+ except Exception as e:
516
+ logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
517
+ # Fall back to inline template or default
518
+
519
+ return await generate_handoff(
520
+ session_manager=context.session_manager,
521
+ session_id=context.session_id,
522
+ llm_service=context.llm_service,
523
+ transcript_processor=context.transcript_processor,
524
+ template=template,
525
+ previous_summary=previous_summary,
526
+ mode=mode,
527
+ )