gobby 0.2.9__py3-none-any.whl → 0.2.11__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 (134) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +2 -2
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +5 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ import os
14
14
  from collections.abc import Callable, Coroutine
15
15
  from dataclasses import dataclass
16
16
  from datetime import UTC, datetime
17
- from typing import Any, Protocol
17
+ from typing import TYPE_CHECKING, Any, Protocol
18
18
  from uuid import uuid4
19
19
 
20
20
  from websockets.asyncio.server import serve
@@ -25,6 +25,9 @@ from websockets.http11 import Response
25
25
  from gobby.agents.registry import get_running_agent_registry
26
26
  from gobby.mcp_proxy.manager import MCPClientManager
27
27
 
28
+ if TYPE_CHECKING:
29
+ from gobby.llm import LLMService
30
+
28
31
  logger = logging.getLogger(__name__)
29
32
 
30
33
 
@@ -79,6 +82,7 @@ class WebSocketServer:
79
82
  mcp_manager: MCPClientManager,
80
83
  auth_callback: Callable[[str], Coroutine[Any, Any, str | None]] | None = None,
81
84
  stop_registry: Any = None,
85
+ llm_service: "LLMService | None" = None,
82
86
  ):
83
87
  """
84
88
  Initialize WebSocket server.
@@ -89,15 +93,20 @@ class WebSocketServer:
89
93
  auth_callback: Optional async function that validates token and returns user_id.
90
94
  If None, all connections are accepted (local-first mode).
91
95
  stop_registry: Optional StopRegistry for handling stop requests from clients.
96
+ llm_service: Optional LLM service for chat message handling.
92
97
  """
93
98
  self.config = config
94
99
  self.mcp_manager = mcp_manager
95
100
  self.auth_callback = auth_callback
96
101
  self.stop_registry = stop_registry
102
+ self.llm_service = llm_service
97
103
 
98
104
  # Connected clients: {websocket: client_metadata}
99
105
  self.clients: dict[Any, dict[str, Any]] = {}
100
106
 
107
+ # Chat conversation history per client (simple in-memory for now)
108
+ self._chat_history: dict[str, list[dict[str, str]]] = {}
109
+
101
110
  # Server instance (set when started)
102
111
  self._server: Any = None
103
112
  self._serve_task: asyncio.Task[None] | None = None
@@ -225,6 +234,7 @@ class WebSocketServer:
225
234
  finally:
226
235
  # Always cleanup client state
227
236
  self.clients.pop(websocket, None)
237
+ self._chat_history.pop(client_id, None)
228
238
  logger.debug(f"Client {client_id} cleaned up. Remaining clients: {len(self.clients)}")
229
239
 
230
240
  async def _handle_message(self, websocket: Any, message: str) -> None:
@@ -261,6 +271,9 @@ class WebSocketServer:
261
271
  elif msg_type == "terminal_input":
262
272
  await self._handle_terminal_input(websocket, data)
263
273
 
274
+ elif msg_type == "chat_message":
275
+ await self._handle_chat_message(websocket, data)
276
+
264
277
  else:
265
278
  logger.warning(f"Unknown message type: {msg_type}")
266
279
  await self._send_error(websocket, f"Unknown message type: {msg_type}")
@@ -557,6 +570,215 @@ class WebSocketServer:
557
570
  except OSError as e:
558
571
  logger.warning(f"Failed to write to agent {run_id} PTY: {e}")
559
572
 
573
+ async def _handle_chat_message(self, websocket: Any, data: dict[str, Any]) -> None:
574
+ """
575
+ Handle chat_message and stream LLM response with MCP tool support.
576
+
577
+ Message format:
578
+ {
579
+ "type": "chat_message",
580
+ "content": "user message",
581
+ "message_id": "client-generated-id",
582
+ "use_tools": true // optional, enables MCP tools
583
+ }
584
+
585
+ Response format (streamed):
586
+ {
587
+ "type": "chat_stream",
588
+ "message_id": "assistant-uuid",
589
+ "content": "chunk of text",
590
+ "done": false
591
+ }
592
+
593
+ Tool status format:
594
+ {
595
+ "type": "tool_status",
596
+ "message_id": "assistant-uuid",
597
+ "tool_call_id": "unique-id",
598
+ "status": "calling" | "completed" | "error",
599
+ "tool_name": "mcp__gobby-tasks__create_task",
600
+ "server_name": "gobby-tasks",
601
+ "arguments": {...},
602
+ "result": {...}, // when completed
603
+ "error": "..." // when error
604
+ }
605
+
606
+ Args:
607
+ websocket: Client WebSocket connection
608
+ data: Parsed chat message
609
+ """
610
+ content = data.get("content")
611
+ user_message_id = data.get("message_id")
612
+ use_tools = data.get("use_tools", True) # Default to using tools
613
+ model = data.get("model") # Optional model override
614
+
615
+ if not content or not isinstance(content, str):
616
+ await self._send_error(websocket, "Missing or invalid 'content' field")
617
+ return
618
+
619
+ if not self.llm_service:
620
+ await websocket.send(
621
+ json.dumps(
622
+ {
623
+ "type": "chat_error",
624
+ "message_id": user_message_id,
625
+ "error": "LLM service not configured",
626
+ }
627
+ )
628
+ )
629
+ return
630
+
631
+ # Get or create conversation history for this client
632
+ client_info = self.clients.get(websocket)
633
+ if not client_info:
634
+ logger.warning("Chat message from unregistered client")
635
+ return
636
+ client_id = client_info["id"]
637
+
638
+ if client_id not in self._chat_history:
639
+ self._chat_history[client_id] = []
640
+
641
+ history = self._chat_history[client_id]
642
+
643
+ # Add user message to history
644
+ history.append({"role": "user", "content": content})
645
+
646
+ # Generate assistant message ID
647
+ assistant_message_id = f"assistant-{uuid4().hex[:12]}"
648
+
649
+ try:
650
+ # Build messages for LLM
651
+ system_prompt = (
652
+ "You are Gobby, a helpful AI assistant with access to tools. "
653
+ "You help users with coding, development tasks, and general questions. "
654
+ "You can use tools to manage tasks, store memories, and access session info. "
655
+ "Be concise and helpful."
656
+ )
657
+
658
+ messages = [{"role": "system", "content": system_prompt}] + history[
659
+ -20:
660
+ ] # Last 20 messages
661
+
662
+ full_response = ""
663
+ tool_calls_count = 0
664
+
665
+ if use_tools:
666
+ # Stream with MCP tools
667
+ from gobby.llm.claude import (
668
+ DoneEvent,
669
+ TextChunk,
670
+ ToolCallEvent,
671
+ ToolResultEvent,
672
+ )
673
+
674
+ # Default allowed tools - gobby MCP server from .mcp.json
675
+ # The server name is "gobby" which exposes all gobby tools
676
+ allowed_tools = [
677
+ "mcp__gobby__*",
678
+ ]
679
+
680
+ async for event in self.llm_service.stream_chat_with_tools(
681
+ messages, allowed_tools, model=model
682
+ ):
683
+ if isinstance(event, TextChunk):
684
+ full_response += event.content
685
+ await websocket.send(
686
+ json.dumps(
687
+ {
688
+ "type": "chat_stream",
689
+ "message_id": assistant_message_id,
690
+ "content": event.content,
691
+ "done": False,
692
+ }
693
+ )
694
+ )
695
+ elif isinstance(event, ToolCallEvent):
696
+ await websocket.send(
697
+ json.dumps(
698
+ {
699
+ "type": "tool_status",
700
+ "message_id": assistant_message_id,
701
+ "tool_call_id": event.tool_call_id,
702
+ "status": "calling",
703
+ "tool_name": event.tool_name,
704
+ "server_name": event.server_name,
705
+ "arguments": event.arguments,
706
+ }
707
+ )
708
+ )
709
+ elif isinstance(event, ToolResultEvent):
710
+ await websocket.send(
711
+ json.dumps(
712
+ {
713
+ "type": "tool_status",
714
+ "message_id": assistant_message_id,
715
+ "tool_call_id": event.tool_call_id,
716
+ "status": "completed" if event.success else "error",
717
+ "result": event.result,
718
+ "error": event.error,
719
+ }
720
+ )
721
+ )
722
+ elif isinstance(event, DoneEvent):
723
+ tool_calls_count = event.tool_calls_count
724
+ # Send final done message
725
+ await websocket.send(
726
+ json.dumps(
727
+ {
728
+ "type": "chat_stream",
729
+ "message_id": assistant_message_id,
730
+ "content": "",
731
+ "done": True,
732
+ "tool_calls_count": tool_calls_count,
733
+ }
734
+ )
735
+ )
736
+ else:
737
+ # Stream without tools (original behavior)
738
+ async for chunk in self.llm_service.stream_chat(messages):
739
+ full_response += chunk
740
+ await websocket.send(
741
+ json.dumps(
742
+ {
743
+ "type": "chat_stream",
744
+ "message_id": assistant_message_id,
745
+ "content": chunk,
746
+ "done": False,
747
+ }
748
+ )
749
+ )
750
+
751
+ # Send final done message
752
+ await websocket.send(
753
+ json.dumps(
754
+ {
755
+ "type": "chat_stream",
756
+ "message_id": assistant_message_id,
757
+ "content": "",
758
+ "done": True,
759
+ }
760
+ )
761
+ )
762
+
763
+ # Add assistant response to history
764
+ history.append({"role": "assistant", "content": full_response})
765
+
766
+ # Trim history if too long
767
+ if len(history) > 100:
768
+ self._chat_history[client_id] = history[-50:]
769
+
770
+ except Exception:
771
+ logger.exception(f"Chat error for client {client_id}")
772
+ await websocket.send(
773
+ json.dumps(
774
+ {
775
+ "type": "chat_error",
776
+ "message_id": assistant_message_id,
777
+ "error": "An internal error occurred",
778
+ }
779
+ )
780
+ )
781
+
560
782
  async def broadcast(self, message: dict[str, Any]) -> None:
561
783
  """
562
784
  Broadcast message to all connected clients.
@@ -724,6 +946,97 @@ class WebSocketServer:
724
946
 
725
947
  await self.broadcast(message)
726
948
 
949
+ async def broadcast_pipeline_event(
950
+ self,
951
+ event: str,
952
+ execution_id: str,
953
+ **kwargs: Any,
954
+ ) -> None:
955
+ """
956
+ Broadcast pipeline execution event to subscribed clients.
957
+
958
+ Used for real-time pipeline execution updates:
959
+ - pipeline_started: Execution began
960
+ - pipeline_completed: Execution finished successfully
961
+ - pipeline_failed: Execution failed with error
962
+ - step_started: A step began executing
963
+ - step_completed: A step finished successfully
964
+ - step_failed: A step failed with error
965
+ - step_output: Streaming output from a step
966
+ - approval_required: Step is waiting for approval
967
+
968
+ Args:
969
+ event: Event type
970
+ execution_id: Pipeline execution ID
971
+ **kwargs: Additional event data (step_id, output, error, etc.)
972
+ """
973
+ if not self.clients:
974
+ return # No clients connected
975
+
976
+ message = {
977
+ "type": "pipeline_event",
978
+ "event": event,
979
+ "execution_id": execution_id,
980
+ "timestamp": datetime.now(UTC).isoformat(),
981
+ **kwargs,
982
+ }
983
+
984
+ message_str = json.dumps(message)
985
+
986
+ for websocket in list(self.clients.keys()):
987
+ try:
988
+ # Only send to clients subscribed to pipeline_event or *
989
+ subs = getattr(websocket, "subscriptions", None)
990
+ if subs is not None:
991
+ if "pipeline_event" not in subs and "*" not in subs:
992
+ continue
993
+
994
+ await websocket.send(message_str)
995
+ except ConnectionClosed:
996
+ pass
997
+ except Exception as e:
998
+ logger.warning(f"Pipeline event broadcast failed: {e}")
999
+
1000
+ async def broadcast_terminal_output(
1001
+ self,
1002
+ run_id: str,
1003
+ data: str,
1004
+ ) -> None:
1005
+ """
1006
+ Broadcast terminal output to subscribed clients.
1007
+
1008
+ Used for streaming PTY output from embedded agents to web terminals.
1009
+
1010
+ Args:
1011
+ run_id: Agent run ID
1012
+ data: Raw terminal output data
1013
+ """
1014
+ if not self.clients:
1015
+ return # No clients connected
1016
+
1017
+ message = {
1018
+ "type": "terminal_output",
1019
+ "run_id": run_id,
1020
+ "data": data,
1021
+ "timestamp": datetime.now(UTC).isoformat(),
1022
+ }
1023
+
1024
+ message_str = json.dumps(message)
1025
+
1026
+ for websocket in list(self.clients.keys()):
1027
+ try:
1028
+ # Only send to clients subscribed to terminal_output or *
1029
+ subs = getattr(websocket, "subscriptions", None)
1030
+ if subs is not None:
1031
+ if "terminal_output" not in subs and "*" not in subs:
1032
+ continue
1033
+
1034
+ await websocket.send(message_str)
1035
+ except ConnectionClosed:
1036
+ pass
1037
+ except Exception as e:
1038
+ logger.warning(f"Terminal broadcast failed: {e}")
1039
+
727
1040
  async def start(self) -> None:
728
1041
  """
729
1042
  Start WebSocket server.
@@ -226,10 +226,46 @@ class TranscriptAnalyzer:
226
226
  tool_name = block.get("name", "unknown")
227
227
  tool_input = block.get("input", {})
228
228
 
229
- # MCP tool calls - show server.tool
229
+ # MCP tool calls - show server.tool with details for gobby-tasks
230
230
  if tool_name in ("mcp__gobby__call_tool", "mcp_call_tool"):
231
231
  server = tool_input.get("server_name", "unknown")
232
232
  tool = tool_input.get("tool_name", "unknown")
233
+ args = tool_input.get("arguments", {})
234
+
235
+ # Enhanced formatting for gobby-tasks operations
236
+ if server == "gobby-tasks":
237
+ if tool == "create_task":
238
+ title = args.get("title", "Untitled")
239
+ parent = args.get("parent_task_id", "")
240
+ if parent:
241
+ return f"Created task: {title} (parent: {parent})"
242
+ return f"Created task: {title}"
243
+ elif tool == "update_task":
244
+ task_id = args.get("task_id", "?")
245
+ status = args.get("status")
246
+ if status:
247
+ return f"Updated task {task_id}: status → {status}"
248
+ return f"Updated task {task_id}"
249
+ elif tool == "close_task":
250
+ task_id = args.get("task_id", "?")
251
+ reason = args.get("reason", "")
252
+ if reason:
253
+ # Truncate long reasons
254
+ if len(reason) > 40:
255
+ reason = reason[:37] + "..."
256
+ return f"Closed task {task_id}: {reason}"
257
+ return f"Closed task {task_id}"
258
+ elif tool == "claim_task":
259
+ task_id = args.get("task_id", "?")
260
+ return f"Claimed task {task_id}"
261
+ elif tool == "get_task":
262
+ task_id = args.get("task_id", "?")
263
+ return f"Fetched task {task_id}"
264
+
265
+ # Generic MCP call formatting - extract meaningful context from args
266
+ context = self._extract_mcp_context(args)
267
+ if context:
268
+ return f"{server}.{tool}: {context}"
233
269
  return f"Called {server}.{tool}"
234
270
 
235
271
  # Bash - show the command (truncated)
@@ -287,6 +323,56 @@ class TranscriptAnalyzer:
287
323
  # Default - just show the tool name
288
324
  return f"Called {tool_name}"
289
325
 
326
+ def _extract_mcp_context(self, args: dict[str, Any]) -> str:
327
+ """
328
+ Extract meaningful context from MCP tool arguments.
329
+
330
+ Looks for common argument patterns and returns the most relevant value
331
+ to describe what the tool call is doing.
332
+
333
+ Args:
334
+ args: Tool arguments dict
335
+
336
+ Returns:
337
+ Extracted context string (truncated to 100 chars) or empty string
338
+ """
339
+ if not args:
340
+ return ""
341
+
342
+ # Priority order for extracting context
343
+ # 1. Search/query related - what are we looking for?
344
+ for key in ("query", "search", "pattern", "topic"):
345
+ if key in args and args[key]:
346
+ return self._truncate(str(args[key]), 100)
347
+
348
+ # 2. Identity/naming - what entity are we working with?
349
+ for key in ("title", "name", "task_id", "id", "ref"):
350
+ if key in args and args[key]:
351
+ return self._truncate(str(args[key]), 100)
352
+
353
+ # 3. Resource paths - what file/resource?
354
+ for key in ("path", "file_path", "uri", "url", "file"):
355
+ if key in args and args[key]:
356
+ return self._truncate(str(args[key]), 100)
357
+
358
+ # 4. Descriptive content - why/what?
359
+ for key in ("description", "reason", "message", "content"):
360
+ if key in args and args[key]:
361
+ return self._truncate(str(args[key]), 100)
362
+
363
+ # 5. Fallback: first non-empty string value
364
+ for key, value in args.items():
365
+ if isinstance(value, str) and value and key not in ("session_id", "server_name"):
366
+ return self._truncate(value, 100)
367
+
368
+ return ""
369
+
370
+ def _truncate(self, text: str, max_len: int) -> str:
371
+ """Truncate text to max_len, adding ellipsis if needed."""
372
+ if len(text) <= max_len:
373
+ return text
374
+ return text[: max_len - 3] + "..."
375
+
290
376
  def _extract_todowrite(self, turns: list[dict[str, Any]]) -> list[dict[str, Any]]:
291
377
  """
292
378
  Extract the most recent TodoWrite state from transcript.
gobby/sessions/manager.py CHANGED
@@ -91,7 +91,7 @@ class SessionManager:
91
91
  Args:
92
92
  external_id: External session identifier (e.g., Claude Code session ID)
93
93
  machine_id: Machine identifier
94
- source: CLI source identifier (e.g., "claude", "gemini", "codex") - REQUIRED
94
+ source: CLI source identifier (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot") - REQUIRED
95
95
  project_id: Project ID (required - sessions must belong to a project)
96
96
  parent_session_id: Optional parent session ID for handoff
97
97
  jsonl_path: Optional path to session transcript JSONL file
@@ -179,7 +179,7 @@ class SessionManager:
179
179
 
180
180
  Args:
181
181
  machine_id: Machine identifier
182
- source: CLI source identifier (e.g., "claude", "gemini", "codex") - REQUIRED
182
+ source: CLI source identifier (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot") - REQUIRED
183
183
  project_id: Project ID (required for matching)
184
184
  max_attempts: Maximum polling attempts (1 per second)
185
185
 
@@ -274,7 +274,7 @@ class SessionManager:
274
274
 
275
275
  Args:
276
276
  external_id: External session identifier
277
- source: CLI source identifier (e.g., "claude", "gemini", "codex")
277
+ source: CLI source identifier (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot")
278
278
  machine_id: Machine identifier
279
279
  project_id: Project identifier
280
280
 
@@ -343,7 +343,7 @@ class SessionManager:
343
343
 
344
344
  Args:
345
345
  external_id: External session identifier
346
- source: CLI source identifier (e.g., "claude", "gemini", "codex")
346
+ source: CLI source identifier (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot")
347
347
 
348
348
  Returns:
349
349
  session_id or None if not cached
@@ -357,7 +357,7 @@ class SessionManager:
357
357
 
358
358
  Args:
359
359
  external_id: External session identifier
360
- source: CLI source identifier (e.g., "claude", "gemini", "codex")
360
+ source: CLI source identifier (e.g., "claude", "gemini", "codex", "cursor", "windsurf", "copilot")
361
361
  session_id: Database session ID
362
362
  """
363
363
  with self._session_mapping_lock:
@@ -24,6 +24,9 @@ PARSER_REGISTRY: dict[str, type[TranscriptParser]] = {
24
24
  "gemini": GeminiTranscriptParser,
25
25
  "antigravity": GeminiTranscriptParser,
26
26
  "codex": CodexTranscriptParser,
27
+ "cursor": ClaudeTranscriptParser,
28
+ "windsurf": ClaudeTranscriptParser,
29
+ "copilot": ClaudeTranscriptParser,
27
30
  }
28
31
 
29
32
 
@@ -289,6 +289,11 @@ class ClaudeTranscriptParser:
289
289
  self.logger.warning(f"Invalid JSON at line {index}")
290
290
  return None
291
291
 
292
+ # Ensure data is a dict (JSON could be a string, number, etc.)
293
+ if not isinstance(data, dict):
294
+ self.logger.debug(f"Skipping non-object JSON at line {index}")
295
+ return None
296
+
292
297
  # Extract basic fields
293
298
  msg_type = data.get("type", "unknown")
294
299
  timestamp_str = data.get("timestamp") or datetime.now(UTC).isoformat()
@@ -60,6 +60,11 @@ class CodexTranscriptParser:
60
60
  self.logger.warning(f"Invalid JSON at line {index}")
61
61
  return None
62
62
 
63
+ # Ensure data is a dict (JSON could be a string, number, etc.)
64
+ if not isinstance(data, dict):
65
+ self.logger.debug(f"Skipping non-object JSON at line {index}")
66
+ return None
67
+
63
68
  timestamp = datetime.now(UTC)
64
69
  if "timestamp" in data:
65
70
  try:
@@ -116,6 +116,11 @@ class GeminiTranscriptParser:
116
116
  self.logger.warning(f"Invalid JSON at line {index}")
117
117
  return None
118
118
 
119
+ # Ensure data is a dict (JSON could be a string, number, etc.)
120
+ if not isinstance(data, dict):
121
+ self.logger.debug(f"Skipping non-object JSON at line {index}")
122
+ return None
123
+
119
124
  # Extract timestamp
120
125
  timestamp_str = data.get("timestamp") or datetime.now(UTC).isoformat()
121
126
  try:
@@ -0,0 +1,25 @@
1
+ """Skill hub providers for searching and installing skills from registries."""
2
+
3
+ from gobby.skills.hubs.base import (
4
+ DownloadResult,
5
+ HubProvider,
6
+ HubSkillDetails,
7
+ HubSkillInfo,
8
+ )
9
+ from gobby.skills.hubs.claude_plugins import ClaudePluginsProvider
10
+ from gobby.skills.hubs.clawdhub import ClawdHubProvider
11
+ from gobby.skills.hubs.github_collection import GitHubCollectionProvider
12
+ from gobby.skills.hubs.manager import HubManager
13
+ from gobby.skills.hubs.skillhub import SkillHubProvider
14
+
15
+ __all__ = [
16
+ "ClaudePluginsProvider",
17
+ "ClawdHubProvider",
18
+ "DownloadResult",
19
+ "GitHubCollectionProvider",
20
+ "HubManager",
21
+ "HubProvider",
22
+ "HubSkillDetails",
23
+ "HubSkillInfo",
24
+ "SkillHubProvider",
25
+ ]