gobby 0.2.7__py3-none-any.whl → 0.2.9__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 (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
gobby/workflows/engine.py CHANGED
@@ -494,3 +494,99 @@ class WorkflowEngine:
494
494
  def _detect_mcp_call(self, event: HookEvent, state: WorkflowState) -> None:
495
495
  """Track MCP tool calls by server/tool for workflow conditions."""
496
496
  detect_mcp_call(event, state)
497
+
498
+ def activate_workflow(
499
+ self,
500
+ workflow_name: str,
501
+ session_id: str,
502
+ project_path: Path | None = None,
503
+ variables: dict[str, Any] | None = None,
504
+ ) -> dict[str, Any]:
505
+ """
506
+ Activate a step-based workflow for a session.
507
+
508
+ This is used internally during session startup for terminal-mode agents
509
+ that have a workflow_name set. It creates the initial workflow state.
510
+
511
+ Args:
512
+ workflow_name: Name of the workflow to activate
513
+ session_id: Session ID to activate for
514
+ project_path: Optional project path for workflow discovery
515
+ variables: Optional initial variables to merge with workflow defaults
516
+
517
+ Returns:
518
+ Dict with success status and workflow info
519
+ """
520
+ # Load workflow
521
+ definition = self.loader.load_workflow(workflow_name, project_path)
522
+ if not definition:
523
+ logger.warning(f"Workflow '{workflow_name}' not found for auto-activation")
524
+ return {"success": False, "error": f"Workflow '{workflow_name}' not found"}
525
+
526
+ if definition.type == "lifecycle":
527
+ logger.debug(f"Skipping auto-activation of lifecycle workflow '{workflow_name}'")
528
+ return {
529
+ "success": False,
530
+ "error": f"Workflow '{workflow_name}' is lifecycle type (auto-runs on events)",
531
+ }
532
+
533
+ # Check for existing step workflow
534
+ existing = self.state_manager.get_state(session_id)
535
+ if existing and existing.workflow_name != "__lifecycle__":
536
+ # Check if existing is lifecycle type
537
+ existing_def = self.loader.load_workflow(existing.workflow_name, project_path)
538
+ if not existing_def or existing_def.type != "lifecycle":
539
+ logger.warning(
540
+ f"Session {session_id} already has workflow '{existing.workflow_name}' active"
541
+ )
542
+ return {
543
+ "success": False,
544
+ "error": f"Session already has workflow '{existing.workflow_name}' active",
545
+ }
546
+
547
+ # Determine initial step - fail fast if no steps defined
548
+ if not definition.steps:
549
+ logger.error(f"Workflow '{workflow_name}' has no steps defined")
550
+ return {
551
+ "success": False,
552
+ "error": f"Workflow '{workflow_name}' has no steps defined",
553
+ }
554
+ step = definition.steps[0].name
555
+
556
+ # Merge variables: preserve existing lifecycle variables, then apply workflow declarations
557
+ # Priority: existing state < workflow defaults < passed-in variables
558
+ # This preserves lifecycle variables (like unlocked_tools) that the step workflow doesn't declare
559
+ merged_variables = dict(existing.variables) if existing else {}
560
+ merged_variables.update(definition.variables) # Override with workflow-declared defaults
561
+ if variables:
562
+ merged_variables.update(variables) # Override with passed-in values
563
+
564
+ # Create state
565
+ state = WorkflowState(
566
+ session_id=session_id,
567
+ workflow_name=workflow_name,
568
+ step=step,
569
+ step_entered_at=datetime.now(UTC),
570
+ step_action_count=0,
571
+ total_action_count=0,
572
+ artifacts={},
573
+ observations=[],
574
+ reflection_pending=False,
575
+ context_injected=False,
576
+ variables=merged_variables,
577
+ task_list=None,
578
+ current_task_index=0,
579
+ files_modified_this_task=0,
580
+ )
581
+
582
+ self.state_manager.save_state(state)
583
+ logger.info(f"Auto-activated workflow '{workflow_name}' for session {session_id}")
584
+
585
+ return {
586
+ "success": True,
587
+ "session_id": session_id,
588
+ "workflow": workflow_name,
589
+ "step": step,
590
+ "steps": [s.name for s in definition.steps],
591
+ "variables": merged_variables,
592
+ }
@@ -348,6 +348,116 @@ class ConditionEvaluator:
348
348
 
349
349
  allowed_globals["mcp_called"] = _mcp_called
350
350
 
351
+ def _mcp_result_is_null(server: str, tool: str) -> bool:
352
+ """Check if MCP tool result is null/missing.
353
+
354
+ Used in workflow conditions like:
355
+ when: "mcp_result_is_null('gobby-tasks', 'suggest_next_task')"
356
+
357
+ Args:
358
+ server: MCP server name
359
+ tool: Tool name
360
+
361
+ Returns:
362
+ True if the result is null/missing, False if result exists.
363
+ """
364
+ variables = context.get("variables", {})
365
+ if isinstance(variables, dict):
366
+ mcp_results = variables.get("mcp_results", {})
367
+ else:
368
+ mcp_results = getattr(variables, "mcp_results", {})
369
+
370
+ if not isinstance(mcp_results, dict):
371
+ return True # No results means null
372
+
373
+ server_results = mcp_results.get(server, {})
374
+ if not isinstance(server_results, dict):
375
+ return True
376
+
377
+ result = server_results.get(tool)
378
+ return result is None
379
+
380
+ allowed_globals["mcp_result_is_null"] = _mcp_result_is_null
381
+
382
+ def _mcp_failed(server: str, tool: str) -> bool:
383
+ """Check if MCP tool call failed.
384
+
385
+ Used in workflow conditions like:
386
+ when: "mcp_failed('gobby-agents', 'spawn_agent')"
387
+
388
+ Args:
389
+ server: MCP server name
390
+ tool: Tool name
391
+
392
+ Returns:
393
+ True if the result exists and indicates failure.
394
+ """
395
+ variables = context.get("variables", {})
396
+ if isinstance(variables, dict):
397
+ mcp_results = variables.get("mcp_results", {})
398
+ else:
399
+ mcp_results = getattr(variables, "mcp_results", {})
400
+
401
+ if not isinstance(mcp_results, dict):
402
+ return False # No results means we can't determine failure
403
+
404
+ server_results = mcp_results.get(server, {})
405
+ if not isinstance(server_results, dict):
406
+ return False
407
+
408
+ result = server_results.get(tool)
409
+ if result is None:
410
+ return False
411
+
412
+ # Check for failure indicators
413
+ if isinstance(result, dict):
414
+ if result.get("success") is False:
415
+ return True
416
+ if result.get("error"):
417
+ return True
418
+ if result.get("status") == "failed":
419
+ return True
420
+ return False
421
+
422
+ allowed_globals["mcp_failed"] = _mcp_failed
423
+
424
+ def _mcp_result_has(server: str, tool: str, field: str, value: Any) -> bool:
425
+ """Check if MCP tool result has a specific field value.
426
+
427
+ Used in workflow conditions like:
428
+ when: "mcp_result_has('gobby-tasks', 'wait_for_task', 'timed_out', True)"
429
+
430
+ Args:
431
+ server: MCP server name
432
+ tool: Tool name
433
+ field: Field name to check
434
+ value: Expected value (supports bool, str, int, float)
435
+
436
+ Returns:
437
+ True if the field equals the expected value.
438
+ """
439
+ variables = context.get("variables", {})
440
+ if isinstance(variables, dict):
441
+ mcp_results = variables.get("mcp_results", {})
442
+ else:
443
+ mcp_results = getattr(variables, "mcp_results", {})
444
+
445
+ if not isinstance(mcp_results, dict):
446
+ return False
447
+
448
+ server_results = mcp_results.get(server, {})
449
+ if not isinstance(server_results, dict):
450
+ return False
451
+
452
+ result = server_results.get(tool)
453
+ if not isinstance(result, dict):
454
+ return False
455
+
456
+ actual_value = result.get(field)
457
+ return bool(actual_value == value)
458
+
459
+ allowed_globals["mcp_result_has"] = _mcp_result_has
460
+
351
461
  # eval used with restricted allowed_globals for workflow conditions
352
462
  # nosec B307: eval is intentional here for DSL evaluation with
353
463
  # restricted globals (__builtins__={}) and controlled workflow conditions
gobby/workflows/hooks.py CHANGED
@@ -167,3 +167,44 @@ class WorkflowHookHandler:
167
167
  except Exception as e:
168
168
  logger.error(f"Error handling lifecycle workflow: {e}", exc_info=True)
169
169
  return HookResponse(decision="allow")
170
+
171
+ def activate_workflow(
172
+ self,
173
+ workflow_name: str,
174
+ session_id: str,
175
+ project_path: str | None = None,
176
+ variables: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """
179
+ Activate a step-based workflow for a session.
180
+
181
+ This is used during session startup for terminal-mode agents that have
182
+ a workflow_name set. It's a synchronous wrapper around the engine's
183
+ activate_workflow method.
184
+
185
+ Args:
186
+ workflow_name: Name of the workflow to activate
187
+ session_id: Session ID to activate for
188
+ project_path: Optional project path for workflow discovery
189
+ variables: Optional initial variables to merge with workflow defaults
190
+
191
+ Returns:
192
+ Dict with success status and workflow info
193
+ """
194
+ if not self._enabled:
195
+ return {"success": False, "error": "Workflow engine is disabled"}
196
+
197
+ from pathlib import Path
198
+
199
+ path = Path(project_path) if project_path else None
200
+
201
+ try:
202
+ return self.engine.activate_workflow(
203
+ workflow_name=workflow_name,
204
+ session_id=session_id,
205
+ project_path=path,
206
+ variables=variables,
207
+ )
208
+ except Exception as e:
209
+ logger.error(f"Error activating workflow: {e}", exc_info=True)
210
+ return {"success": False, "error": str(e)}
@@ -636,6 +636,7 @@ async def evaluate_all_lifecycle_workflows(
636
636
  "path": str(w.path),
637
637
  }
638
638
  for w in workflows
639
- ]
639
+ ],
640
+ "workflow_variables": context_data,
640
641
  },
641
642
  )
@@ -205,6 +205,17 @@ async def memory_recall_relevant(
205
205
  # Filter out memories that have already been injected in this session
206
206
  new_memories = [m for m in memories if m.id not in injected_ids]
207
207
 
208
+ # Deduplicate by content to avoid showing same content with different IDs
209
+ # (can happen when same content was stored with different project_ids)
210
+ seen_content: set[str] = set()
211
+ unique_memories = []
212
+ for m in new_memories:
213
+ normalized = m.content.strip()
214
+ if normalized not in seen_content:
215
+ seen_content.add(normalized)
216
+ unique_memories.append(m)
217
+ new_memories = unique_memories
218
+
208
219
  if not new_memories:
209
220
  logger.debug(
210
221
  f"memory_recall_relevant: All {len(memories)} memories already injected, skipping"
@@ -178,6 +178,14 @@ class SafeExpressionEvaluator(ast.NodeVisitor):
178
178
  except (KeyError, IndexError, TypeError) as e:
179
179
  raise ValueError(f"Subscript access failed: {e}") from e
180
180
 
181
+ def visit_List(self, node: ast.List) -> list[Any]:
182
+ """Handle list literals (e.g., ['a', 'b', 'c'])."""
183
+ return [self.visit(elt) for elt in node.elts]
184
+
185
+ def visit_Tuple(self, node: ast.Tuple) -> tuple[Any, ...]:
186
+ """Handle tuple literals (e.g., ('a', 'b', 'c'))."""
187
+ return tuple(self.visit(elt) for elt in node.elts)
188
+
181
189
  def generic_visit(self, node: ast.AST) -> Any:
182
190
  """Reject any unsupported AST nodes."""
183
191
  raise ValueError(f"Unsupported expression type: {type(node).__name__}")
@@ -22,6 +22,9 @@ logger = logging.getLogger(__name__)
22
22
  def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
23
23
  """Format transcript turns for LLM analysis.
24
24
 
25
+ Handles both Claude Code format (nested message.role/content) and
26
+ Gemini CLI format (flat type/role/content).
27
+
25
28
  Args:
26
29
  turns: List of transcript turn dicts
27
30
 
@@ -30,51 +33,108 @@ def format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
30
33
  """
31
34
  formatted: list[str] = []
32
35
  for i, turn in enumerate(turns):
33
- message = turn.get("message", {})
34
- role = message.get("role", "unknown")
35
- content = message.get("content", "")
36
-
37
- # Assistant messages have content as array of blocks
38
- if isinstance(content, list):
39
- text_parts: list[str] = []
40
- for block in content:
41
- if isinstance(block, dict):
42
- if block.get("type") == "text":
43
- text_parts.append(block.get("text", ""))
44
- elif block.get("type") == "thinking":
45
- text_parts.append(f"[Thinking: {block.get('thinking', '')}]")
46
- elif block.get("type") == "tool_use":
47
- text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
48
- elif block.get("type") == "tool_result":
49
- result_content = block.get("content", "")
50
- # Extract text from list of content blocks if needed
51
- if isinstance(result_content, list):
52
- extracted = []
53
- for item in result_content:
54
- if isinstance(item, dict):
55
- extracted.append(
56
- item.get("text", "") or item.get("content", "")
57
- )
58
- else:
59
- extracted.append(str(item))
60
- result_content = " ".join(extracted)
61
- content_str = str(result_content)
62
- preview = content_str[:100]
63
- suffix = "..." if len(content_str) > 100 else ""
64
- text_parts.append(f"[Result: {preview}{suffix}]")
65
- 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)
66
47
 
67
48
  formatted.append(f"[Turn {i + 1} - {role}]: {content}")
68
49
 
69
50
  return "\n\n".join(formatted)
70
51
 
71
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
+
72
129
  def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
73
130
  """Extract the last TodoWrite tool call's todos list from transcript.
74
131
 
75
132
  Scans turns in reverse to find the most recent TodoWrite tool call
76
133
  and formats it as a markdown checklist.
77
134
 
135
+ Handles both Claude Code format (nested message.content) and
136
+ Gemini CLI format (flat type/tool_name/parameters).
137
+
78
138
  Args:
79
139
  turns: List of transcript turns
80
140
 
@@ -82,6 +142,16 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
82
142
  Formatted markdown string with todo list, or empty string if not found
83
143
  """
84
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
85
155
  message = turn.get("message", {})
86
156
  content = message.get("content", [])
87
157
 
@@ -91,29 +161,32 @@ def extract_todowrite_state(turns: list[dict[str, Any]]) -> str:
91
161
  if block.get("name") == "TodoWrite":
92
162
  tool_input = block.get("input", {})
93
163
  todos = tool_input.get("todos", [])
164
+ return _format_todos(todos)
94
165
 
95
- if not todos:
96
- return ""
166
+ return ""
97
167
 
98
- # Format as markdown checklist
99
- lines: list[str] = []
100
- for todo in todos:
101
- content_text = todo.get("content", "")
102
- status = todo.get("status", "pending")
103
168
 
104
- # Map status to checkbox style
105
- if status == "completed":
106
- checkbox = "[x]"
107
- elif status == "in_progress":
108
- checkbox = "[>]"
109
- else:
110
- checkbox = "[ ]"
169
+ def _format_todos(todos: list[dict[str, Any]]) -> str:
170
+ """Format todos list as markdown checklist."""
171
+ if not todos:
172
+ return ""
111
173
 
112
- 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")
113
178
 
114
- 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 = "[ ]"
115
186
 
116
- return ""
187
+ lines.append(f"- {checkbox} {content_text}")
188
+
189
+ return "\n".join(lines)
117
190
 
118
191
 
119
192
  async def synthesize_title(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gobby
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: A local-first daemon to unify your AI coding tools. Session tracking and handoffs across Claude Code, Gemini CLI, and Codex. An MCP proxy that discovers tools without flooding context. Task management with dependencies, validation, and TDD expansion. Agent spawning and worktree orchestration. Persistent memory, extensible workflows, and hooks.
5
5
  Author-email: Josh Wilhelmi <josh@gobby.ai>
6
6
  License-Expression: MIT