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
@@ -175,6 +175,17 @@ def create_admin_router(server: "HTTPServer") -> APIRouter:
175
175
  except Exception as e:
176
176
  logger.warning(f"Failed to get memory stats: {e}")
177
177
 
178
+ # Get artifact statistics
179
+ artifact_stats = {"count": 0}
180
+ if server.session_manager is not None:
181
+ try:
182
+ from gobby.storage.artifacts import LocalArtifactManager
183
+
184
+ artifact_manager = LocalArtifactManager(server.session_manager.db)
185
+ artifact_stats["count"] = artifact_manager.count_artifacts()
186
+ except Exception as e:
187
+ logger.warning(f"Failed to get artifact stats: {e}")
188
+
178
189
  # Get skills statistics
179
190
  skills_stats: dict[str, Any] = {"total": 0}
180
191
  if server._internal_manager:
@@ -231,6 +242,7 @@ def create_admin_router(server: "HTTPServer") -> APIRouter:
231
242
  "sessions": session_stats,
232
243
  "tasks": task_stats,
233
244
  "memory": memory_stats,
245
+ "artifacts": artifact_stats,
234
246
  "skills": skills_stats,
235
247
  "plugins": plugin_stats,
236
248
  "response_time_ms": response_time_ms,
@@ -318,12 +318,16 @@ async def get_tool_schema(
318
318
  schema = registry.get_schema(tool_name)
319
319
  if schema:
320
320
  response_time_ms = (time.perf_counter() - start_time) * 1000
321
- return {
322
- "name": tool_name,
321
+ # Build response with description only if present
322
+ result: dict[str, Any] = {
323
+ "name": schema.get("name", tool_name),
324
+ "inputSchema": schema.get("inputSchema"),
323
325
  "server": server_name,
324
- "inputSchema": schema,
325
326
  "response_time_ms": response_time_ms,
326
327
  }
328
+ if schema.get("description"):
329
+ result["description"] = schema["description"]
330
+ return result
327
331
  raise HTTPException(
328
332
  status_code=404,
329
333
  detail={
@@ -340,15 +344,19 @@ async def get_tool_schema(
340
344
 
341
345
  # Get from external MCP server
342
346
  try:
343
- schema = await server.mcp_manager.get_tool_input_schema(server_name, tool_name)
347
+ tool_info = await server.mcp_manager.get_tool_info(server_name, tool_name)
344
348
  response_time_ms = (time.perf_counter() - start_time) * 1000
345
349
 
346
- return {
347
- "name": tool_name,
350
+ # Build response with description only if present
351
+ response: dict[str, Any] = {
352
+ "name": tool_info.get("name", tool_name),
353
+ "inputSchema": tool_info.get("inputSchema"),
348
354
  "server": server_name,
349
- "inputSchema": schema,
350
355
  "response_time_ms": response_time_ms,
351
356
  }
357
+ if tool_info.get("description"):
358
+ response["description"] = tool_info["description"]
359
+ return response
352
360
 
353
361
  except (KeyError, ValueError) as e:
354
362
  # Tool or server not found - 404
@@ -255,17 +255,17 @@ async def refresh_mcp_tools(
255
255
  try:
256
256
  session = await server.mcp_manager.ensure_connected(server_name)
257
257
  tools_result = await session.list_tools()
258
- for t in tools_result.tools:
258
+ for mcp_tool in tools_result.tools:
259
259
  schema = None
260
- if hasattr(t, "inputSchema"):
261
- if hasattr(t.inputSchema, "model_dump"):
262
- schema = t.inputSchema.model_dump()
263
- elif isinstance(t.inputSchema, dict):
264
- schema = t.inputSchema
260
+ if hasattr(mcp_tool, "inputSchema"):
261
+ if hasattr(mcp_tool.inputSchema, "model_dump"):
262
+ schema = mcp_tool.inputSchema.model_dump()
263
+ elif isinstance(mcp_tool.inputSchema, dict):
264
+ schema = mcp_tool.inputSchema
265
265
  tools.append(
266
266
  {
267
- "name": getattr(t, "name", ""),
268
- "description": getattr(t, "description", ""),
267
+ "name": getattr(mcp_tool, "name", ""),
268
+ "description": getattr(mcp_tool, "description", ""),
269
269
  "inputSchema": schema,
270
270
  }
271
271
  )
@@ -19,6 +19,44 @@ if TYPE_CHECKING:
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
+ # Map hook types to hookEventName for additionalContext
23
+ # Only these hook types support hookSpecificOutput in Claude Code
24
+ HOOK_EVENT_NAME_MAP: dict[str, str] = {
25
+ "pre-tool-use": "PreToolUse",
26
+ "post-tool-use": "PostToolUse",
27
+ "post-tool-use-failure": "PostToolUse",
28
+ "user-prompt-submit": "UserPromptSubmit",
29
+ }
30
+
31
+
32
+ def _graceful_error_response(hook_type: str, error_msg: str) -> dict[str, Any]:
33
+ """
34
+ Create a graceful degradation response for hook errors.
35
+
36
+ Instead of returning HTTP 500 (which causes Claude Code to show a confusing
37
+ "hook failed" warning), return a successful response that:
38
+ 1. Allows the tool to proceed (continue=True)
39
+ 2. Explains the error via additionalContext (so agents understand what happened)
40
+
41
+ This prevents agents from being confused by non-fatal hook errors.
42
+ """
43
+ response: dict[str, Any] = {
44
+ "continue": True,
45
+ "decision": "approve",
46
+ }
47
+
48
+ # Add helpful context for supported hook types
49
+ hook_event_name = HOOK_EVENT_NAME_MAP.get(hook_type)
50
+ if hook_event_name:
51
+ response["hookSpecificOutput"] = {
52
+ "hookEventName": hook_event_name,
53
+ "additionalContext": (
54
+ f"Gobby hook error (non-fatal): {error_msg}. Tool execution will proceed normally."
55
+ ),
56
+ }
57
+
58
+ return response
59
+
22
60
 
23
61
  def create_hooks_router(server: "HTTPServer") -> APIRouter:
24
62
  """
@@ -51,6 +89,7 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
51
89
  start_time = time.perf_counter()
52
90
  metrics.inc_counter("http_requests_total")
53
91
  metrics.inc_counter("hooks_total")
92
+ hook_type: str | None = None # Track for error handling
54
93
 
55
94
  try:
56
95
  # Parse request
@@ -109,27 +148,35 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
109
148
  return result
110
149
 
111
150
  except ValueError as e:
151
+ # Invalid request - still return graceful response
112
152
  metrics.inc_counter("hooks_failed_total")
113
153
  logger.warning(
114
154
  f"Invalid hook request: {hook_type}",
115
155
  extra={"hook_type": hook_type, "error": str(e)},
116
156
  )
117
- raise HTTPException(status_code=400, detail=str(e)) from e
157
+ return _graceful_error_response(hook_type, str(e))
118
158
 
119
159
  except Exception as e:
160
+ # Hook execution error - return graceful response so tool proceeds
161
+ # This prevents confusing "hook failed" warnings in Claude Code
120
162
  metrics.inc_counter("hooks_failed_total")
121
163
  logger.error(
122
164
  f"Hook execution failed: {hook_type}",
123
165
  exc_info=True,
124
166
  extra={"hook_type": hook_type},
125
167
  )
126
- raise HTTPException(status_code=500, detail=str(e)) from e
168
+ return _graceful_error_response(hook_type, str(e))
127
169
 
128
170
  except HTTPException:
171
+ # Re-raise 400 errors (bad request) - these are client errors
129
172
  raise
130
173
  except Exception as e:
174
+ # Outer exception - return graceful response to prevent CLI warning
131
175
  metrics.inc_counter("hooks_failed_total")
132
176
  logger.error("Hook endpoint error", exc_info=True)
133
- raise HTTPException(status_code=500, detail=str(e)) from e
177
+ if hook_type:
178
+ return _graceful_error_response(hook_type, str(e))
179
+ # Fallback: return basic success to prevent CLI hook failure
180
+ return {"continue": True, "decision": "approve"}
134
181
 
135
182
  return router
@@ -10,6 +10,7 @@ Local-first version: Authentication is optional (defaults to always-allow).
10
10
  import asyncio
11
11
  import json
12
12
  import logging
13
+ import os
13
14
  from collections.abc import Callable, Coroutine
14
15
  from dataclasses import dataclass
15
16
  from datetime import UTC, datetime
@@ -21,6 +22,7 @@ from websockets.datastructures import Headers
21
22
  from websockets.exceptions import ConnectionClosed, ConnectionClosedError
22
23
  from websockets.http11 import Response
23
24
 
25
+ from gobby.agents.registry import get_running_agent_registry
24
26
  from gobby.mcp_proxy.manager import MCPClientManager
25
27
 
26
28
  logger = logging.getLogger(__name__)
@@ -256,6 +258,9 @@ class WebSocketServer:
256
258
  elif msg_type == "stop_request":
257
259
  await self._handle_stop_request(websocket, data)
258
260
 
261
+ elif msg_type == "terminal_input":
262
+ await self._handle_terminal_input(websocket, data)
263
+
259
264
  else:
260
265
  logger.warning(f"Unknown message type: {msg_type}")
261
266
  await self._send_error(websocket, f"Unknown message type: {msg_type}")
@@ -496,11 +501,62 @@ class WebSocketServer:
496
501
  )
497
502
 
498
503
  logger.info(f"Stop requested for session {session_id} via WebSocket")
499
-
500
504
  except Exception as e:
501
505
  logger.error(f"Error handling stop request: {e}")
502
506
  await self._send_error(websocket, f"Failed to signal stop: {str(e)}")
503
507
 
508
+ async def _handle_terminal_input(self, websocket: Any, data: dict[str, Any]) -> None:
509
+ """
510
+ Handle terminal input for a running agent.
511
+
512
+ Message format:
513
+ {
514
+ "type": "terminal_input",
515
+ "run_id": "uuid",
516
+ "data": "raw input string"
517
+ }
518
+
519
+ Args:
520
+ websocket: Client WebSocket connection
521
+ data: Parsed terminal input message
522
+ """
523
+ run_id = data.get("run_id")
524
+ input_data = data.get("data")
525
+
526
+ if not run_id or input_data is None:
527
+ # Don't send error for every keystroke if malformed, just log debug
528
+ logger.debug(
529
+ f"Invalid terminal_input: run_id={run_id}, data_len={len(str(input_data)) if input_data else 0}"
530
+ )
531
+ return
532
+
533
+ if not isinstance(input_data, str):
534
+ # input_data must be a string to encode; log and skip non-strings
535
+ logger.debug(
536
+ f"Invalid terminal_input type: run_id={run_id}, data_type={type(input_data).__name__}"
537
+ )
538
+ return
539
+
540
+ registry = get_running_agent_registry()
541
+ # Look up by run_id
542
+ agent = registry.get(run_id)
543
+
544
+ if not agent:
545
+ # Be silent on missing agent to avoid spamming errors if frontend is out of sync
546
+ # or if agent just died.
547
+ return
548
+
549
+ if agent.master_fd is None:
550
+ logger.warning(f"Agent {run_id} has no PTY master_fd")
551
+ return
552
+
553
+ try:
554
+ # Write key/input to PTY off the event loop
555
+ encoded_data = input_data.encode("utf-8")
556
+ await asyncio.to_thread(os.write, agent.master_fd, encoded_data)
557
+ except OSError as e:
558
+ logger.warning(f"Failed to write to agent {run_id} PTY: {e}")
559
+
504
560
  async def broadcast(self, message: dict[str, Any]) -> None:
505
561
  """
506
562
  Broadcast message to all connected clients.
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  from dataclasses import dataclass, field
12
- from datetime import datetime
12
+ from datetime import UTC, datetime
13
13
  from typing import Any
14
14
 
15
15
  from gobby.sessions.transcripts.base import TranscriptParser
@@ -32,8 +32,8 @@ class HandoffContext:
32
32
  key_decisions: list[str] | None = None
33
33
  active_worktree: dict[str, Any] | None = None
34
34
  """Worktree context if session is operating in a worktree."""
35
- active_skills: list[str] = field(default_factory=list)
36
- """List of skill names that were active/injected during the session."""
35
+ # Note: active_skills field removed - redundant with _build_skill_injection_context()
36
+ # which already handles skill restoration on session start
37
37
 
38
38
 
39
39
  class TranscriptAnalyzer:
@@ -207,7 +207,7 @@ class TranscriptAnalyzer:
207
207
  context.git_commits.append(
208
208
  {
209
209
  "command": command,
210
- "timestamp": datetime.now().isoformat(), # Approx time
210
+ "timestamp": datetime.now(UTC).isoformat(), # Approx time
211
211
  }
212
212
  )
213
213
 
gobby/sessions/manager.py CHANGED
@@ -82,6 +82,8 @@ class SessionManager:
82
82
  git_branch: str | None = None,
83
83
  project_path: str | None = None,
84
84
  terminal_context: dict[str, Any] | None = None,
85
+ workflow_name: str | None = None,
86
+ agent_depth: int = 0,
85
87
  ) -> str:
86
88
  """
87
89
  Register new session with local storage.
@@ -96,6 +98,9 @@ class SessionManager:
96
98
  title: Optional session title/summary
97
99
  git_branch: Optional git branch name
98
100
  project_path: Optional project path (for git extraction if git_branch not provided)
101
+ terminal_context: Optional terminal context for correlation
102
+ workflow_name: Optional workflow to auto-activate for this session
103
+ agent_depth: Depth in agent hierarchy (0 = root session)
99
104
 
100
105
  Returns:
101
106
  session_id (database UUID)
@@ -125,6 +130,8 @@ class SessionManager:
125
130
  git_branch=git_branch,
126
131
  parent_session_id=parent_session_id,
127
132
  terminal_context=terminal_context,
133
+ workflow_name=workflow_name,
134
+ agent_depth=agent_depth,
128
135
  )
129
136
 
130
137
  session_id: str = session.id
@@ -143,6 +150,8 @@ class SessionManager:
143
150
  "project_id": project_id,
144
151
  "title": title,
145
152
  "git_branch": git_branch,
153
+ "workflow_name": workflow_name,
154
+ "agent_depth": agent_depth,
146
155
  }
147
156
 
148
157
  self.logger.debug(f"Registered session {session_id} (external_id={external_id})")
@@ -38,13 +38,34 @@ class GeminiTranscriptParser:
38
38
  ) -> list[dict[str, Any]]:
39
39
  """
40
40
  Extract last N user<>agent message pairs.
41
+
42
+ Handles both Gemini CLI's type-based format and legacy role/content format.
41
43
  """
42
44
  messages: list[dict[str, str]] = []
43
45
  for turn in reversed(turns):
44
- # Adapt to generic turn structure
45
- # Assumes generic schema: {"role": "...", "content": "..."} or nested in "message"
46
- role = turn.get("role") or turn.get("message", {}).get("role")
47
- content = turn.get("content") or turn.get("message", {}).get("content")
46
+ # Handle Gemini CLI's type-based format
47
+ event_type = turn.get("type")
48
+ role: str | None = None
49
+ content: str | Any = None
50
+
51
+ if event_type == "message":
52
+ role = turn.get("role")
53
+ content = turn.get("content")
54
+ elif event_type in ("init", "result"):
55
+ # Skip non-message events
56
+ continue
57
+ elif event_type == "tool_use":
58
+ # Include tool calls as assistant messages
59
+ role = "assistant"
60
+ tool_name = turn.get("tool_name") or turn.get("function_name", "unknown")
61
+ content = f"[Tool call: {tool_name}]"
62
+ elif event_type == "tool_result":
63
+ # Skip tool results for message extraction
64
+ continue
65
+ else:
66
+ # Fallback: legacy format with role/content at top level
67
+ role = turn.get("role") or turn.get("message", {}).get("role")
68
+ content = turn.get("content") or turn.get("message", {}).get("content")
48
69
 
49
70
  if role in ["user", "model", "assistant"]:
50
71
  norm_role = "assistant" if role == "model" else role
@@ -53,7 +74,7 @@ class GeminiTranscriptParser:
53
74
  if isinstance(content, list):
54
75
  content = " ".join(str(part) for part in content)
55
76
 
56
- messages.insert(0, {"role": norm_role, "content": str(content)})
77
+ messages.insert(0, {"role": norm_role, "content": str(content or "")})
57
78
  if len(messages) >= num_pairs * 2:
58
79
  break
59
80
  return messages
@@ -78,6 +99,13 @@ class GeminiTranscriptParser:
78
99
  def parse_line(self, line: str, index: int) -> ParsedMessage | None:
79
100
  """
80
101
  Parse a single line from the transcript JSONL.
102
+
103
+ Gemini CLI uses type-based events in JSONL format:
104
+ - {"type":"init", "session_id":"...", "model":"...", "timestamp":"..."}
105
+ - {"type":"message", "role":"user"|"model", "content":"...", ...}
106
+ - {"type":"tool_use", "tool_name":"Bash", "parameters":{...}, ...}
107
+ - {"type":"tool_result", "tool_id":"...", "status":"success", "output":"...", ...}
108
+ - {"type":"result", "status":"success", "stats":{...}, ...}
81
109
  """
82
110
  if not line.strip():
83
111
  return None
@@ -95,45 +123,83 @@ class GeminiTranscriptParser:
95
123
  except ValueError:
96
124
  timestamp = datetime.now(UTC)
97
125
 
98
- # Determine role and content
99
- # Check top-level or nested 'message'
100
- role = data.get("role")
101
- content = data.get("content")
126
+ # Handle Gemini CLI's type-based event format
127
+ event_type = data.get("type")
102
128
 
103
- if not role and "message" in data:
104
- msg = data["message"]
105
- role = msg.get("role")
106
- content = msg.get("content")
129
+ # Initialize defaults
130
+ role: str | None = None
131
+ content: str | Any = ""
132
+ content_type = "text"
133
+ tool_name: str | None = None
134
+ tool_input: dict[str, Any] | None = None
135
+ tool_result: dict[str, Any] | None = None
107
136
 
108
- if not role:
109
- # Try type field common in other schemas
110
- msg_type = data.get("type")
111
- if msg_type == "user":
112
- role = "user"
113
- elif msg_type == "model":
114
- role = "assistant"
137
+ if event_type == "init":
138
+ # Session initialization event - skip or treat as system
139
+ return None
115
140
 
116
- # Normalize role
117
- if role == "model":
141
+ elif event_type == "message":
142
+ # Message event with role (user/model)
143
+ role = data.get("role")
144
+ content = data.get("content", "")
145
+
146
+ elif event_type in ("user", "model"):
147
+ # Role specified directly in type field
148
+ role = event_type
149
+ content = data.get("content", "")
150
+
151
+ elif event_type == "tool_use":
152
+ # Tool invocation event
118
153
  role = "assistant"
154
+ content_type = "tool_use"
155
+ tool_name = data.get("tool_name") or data.get("function_name")
156
+ tool_input = data.get("parameters") or data.get("args") or data.get("input")
157
+ content = f"Tool call: {tool_name}"
158
+
159
+ elif event_type == "tool_result":
160
+ # Tool result event
161
+ role = "tool"
162
+ content_type = "tool_result"
163
+ tool_name = data.get("tool_name")
164
+ output = data.get("output") or data.get("result") or ""
165
+ tool_result = {"output": output, "status": data.get("status", "unknown")}
166
+ content = str(output)[:500] if output else "" # Truncate long outputs
167
+
168
+ elif event_type == "result":
169
+ # Final result event - skip
170
+ return None
119
171
 
120
- if not role:
121
- # Maybe a tool result or system event
122
- if "tool_result" in data:
172
+ else:
173
+ # Fallback: try legacy format with role/content at top level
174
+ role = data.get("role")
175
+ content = data.get("content")
176
+
177
+ # Check nested message structure
178
+ if not role and "message" in data:
179
+ msg = data["message"]
180
+ role = msg.get("role")
181
+ content = msg.get("content")
182
+
183
+ # Handle tool_result at top level (legacy)
184
+ if not role and "tool_result" in data:
123
185
  role = "tool"
186
+ content_type = "tool_result"
124
187
  content = str(data["tool_result"])
125
- else:
126
- # Unknown or uninteresting line
188
+
189
+ # If still no role in fallback, skip this line
190
+ if not role:
127
191
  return None
128
192
 
129
- # Normalize content
130
- content_type = "text"
131
- tool_name = None
132
- tool_input = None
133
- tool_result = None
193
+ # Validate role is set - skip lines with missing role
194
+ if not role:
195
+ return None
196
+
197
+ # Normalize role: model -> assistant
198
+ if role == "model":
199
+ role = "assistant"
134
200
 
201
+ # Normalize content - handle list content (rich parts)
135
202
  if isinstance(content, list):
136
- # Handle potential rich content
137
203
  text_parts: list[str] = []
138
204
  for part in content:
139
205
  if isinstance(part, str):
@@ -141,7 +207,7 @@ class GeminiTranscriptParser:
141
207
  elif isinstance(part, dict):
142
208
  if "text" in part:
143
209
  text_parts.append(str(part["text"]))
144
- # Check for tool calls
210
+ # Check for tool calls embedded in content
145
211
  if "functionCall" in part:
146
212
  content_type = "tool_use"
147
213
  tool_name = part["functionCall"].get("name")
gobby/skills/parser.py CHANGED
@@ -64,6 +64,8 @@ class ParsedSkill:
64
64
  scripts: List of script file paths (relative to skill dir)
65
65
  references: List of reference file paths (relative to skill dir)
66
66
  assets: List of asset file paths (relative to skill dir)
67
+ always_apply: Whether skill should always be injected at session start
68
+ injection_format: How to inject skill (summary, full, content)
67
69
  """
68
70
 
69
71
  name: str
@@ -80,6 +82,8 @@ class ParsedSkill:
80
82
  scripts: list[str] | None = None
81
83
  references: list[str] | None = None
82
84
  assets: list[str] | None = None
85
+ always_apply: bool = False
86
+ injection_format: str = "summary"
83
87
 
84
88
  def get_category(self) -> str | None:
85
89
  """Get category from top-level or metadata.skillport.category."""
@@ -139,6 +143,8 @@ class ParsedSkill:
139
143
  "scripts": self.scripts,
140
144
  "references": self.references,
141
145
  "assets": self.assets,
146
+ "always_apply": self.always_apply,
147
+ "injection_format": self.injection_format,
142
148
  }
143
149
 
144
150
 
@@ -232,6 +238,7 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
232
238
  # This allows both top-level and nested formats to work
233
239
  top_level_always_apply = frontmatter.get("alwaysApply")
234
240
  top_level_category = frontmatter.get("category")
241
+ top_level_injection_format = frontmatter.get("injectionFormat")
235
242
 
236
243
  if top_level_always_apply is not None or top_level_category is not None:
237
244
  if metadata is None:
@@ -251,6 +258,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
251
258
  if version is not None:
252
259
  version = str(version)
253
260
 
261
+ # Extract always_apply: check top-level first, then metadata.skillport.alwaysApply
262
+ always_apply = False
263
+ if top_level_always_apply is not None:
264
+ always_apply = bool(top_level_always_apply)
265
+ elif metadata and isinstance(metadata, dict):
266
+ skillport = metadata.get("skillport", {})
267
+ if isinstance(skillport, dict) and skillport.get("alwaysApply"):
268
+ always_apply = bool(skillport["alwaysApply"])
269
+
270
+ # Extract injection_format: check top-level first, default to "summary"
271
+ injection_format = "summary"
272
+ if top_level_injection_format is not None:
273
+ injection_format = str(top_level_injection_format)
274
+
254
275
  return ParsedSkill(
255
276
  name=name,
256
277
  description=description,
@@ -261,6 +282,8 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
261
282
  allowed_tools=allowed_tools,
262
283
  metadata=metadata,
263
284
  source_path=source_path,
285
+ always_apply=always_apply,
286
+ injection_format=injection_format,
264
287
  )
265
288
 
266
289
 
gobby/skills/sync.py CHANGED
@@ -23,10 +23,9 @@ def get_bundled_skills_path() -> Path:
23
23
  Returns:
24
24
  Path to src/gobby/install/shared/skills/
25
25
  """
26
- # Navigate from this file to install/shared/skills/
27
- # This file: src/gobby/skills/sync.py
28
- # Target: src/gobby/install/shared/skills/
29
- return Path(__file__).parent.parent / "install" / "shared" / "skills"
26
+ from gobby.paths import get_install_dir
27
+
28
+ return get_install_dir() / "shared" / "skills"
30
29
 
31
30
 
32
31
  def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
@@ -101,6 +100,8 @@ def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
101
100
  source_ref=None,
102
101
  project_id=None, # Global scope
103
102
  enabled=True,
103
+ always_apply=parsed.always_apply,
104
+ injection_format=parsed.injection_format,
104
105
  )
105
106
 
106
107
  logger.info(f"Synced bundled skill: {parsed.name}")
@@ -283,3 +283,22 @@ class LocalArtifactManager:
283
283
 
284
284
  rows = self.db.fetchall(sql, tuple(params))
285
285
  return [Artifact.from_row(row) for row in rows]
286
+
287
+ def count_artifacts(self, session_id: str | None = None) -> int:
288
+ """Count total artifacts, optionally filtered by session.
289
+
290
+ Args:
291
+ session_id: Optional session ID to filter by
292
+
293
+ Returns:
294
+ Total artifact count
295
+ """
296
+ if session_id:
297
+ row = self.db.fetchone(
298
+ "SELECT COUNT(*) FROM session_artifacts WHERE session_id = ?",
299
+ (session_id,),
300
+ )
301
+ else:
302
+ row = self.db.fetchone("SELECT COUNT(*) FROM session_artifacts")
303
+
304
+ return row[0] if row else 0