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.
- gobby/__init__.py +1 -1
- gobby/adapters/claude_code.py +99 -61
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/app_context.py +59 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/cli/utils.py +5 -17
- gobby/clones/git.py +177 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +87 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +573 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/hook_manager.py +21 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/claude.py +377 -42
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/registries.py +35 -4
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +46 -12
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
- gobby/mcp_proxy/tools/workflows/_query.py +207 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/extractor.py +15 -1
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +36 -10
- gobby/servers/http.py +186 -149
- gobby/servers/routes/admin.py +12 -0
- gobby/servers/routes/mcp/endpoints/execution.py +15 -7
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +4 -4
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +46 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +87 -7
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +5 -0
- gobby/workflows/context_actions.py +21 -24
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +109 -1
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/engine.py +96 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/lifecycle_evaluator.py +2 -1
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
- gobby/cli/tui.py +0 -34
- gobby/hooks/event_handlers.py +0 -909
- gobby/mcp_proxy/tools/workflows.py +0 -973
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
+
}
|
gobby/workflows/evaluator.py
CHANGED
|
@@ -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)}
|
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
174
|
+
lines: list[str] = []
|
|
175
|
+
for todo in todos:
|
|
176
|
+
content_text = todo.get("content", "")
|
|
177
|
+
status = todo.get("status", "pending")
|
|
113
178
|
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
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
|