gobby 0.2.6__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -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
@@ -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
 
@@ -11,7 +11,7 @@ import logging
11
11
  import os
12
12
  from typing import Any
13
13
 
14
- from gobby.config.app import SessionLifecycleConfig
14
+ from gobby.config.sessions import SessionLifecycleConfig
15
15
  from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
16
16
  from gobby.sessions.transcripts.codex import CodexTranscriptParser
17
17
  from gobby.sessions.transcripts.gemini import GeminiTranscriptParser
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})")
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from gobby.servers.websocket import WebSocketServer
15
+ from gobby.storage.sessions import LocalSessionManager
15
16
 
16
17
  from gobby.sessions.transcripts import get_parser
17
18
  from gobby.sessions.transcripts.base import TranscriptParser
@@ -36,11 +37,13 @@ class SessionMessageProcessor:
36
37
  db: DatabaseProtocol,
37
38
  poll_interval: float = 2.0,
38
39
  websocket_server: "WebSocketServer | None" = None,
40
+ session_manager: "LocalSessionManager | None" = None,
39
41
  ):
40
42
  self.db = db
41
43
  self.message_manager = LocalSessionMessageManager(db)
42
44
  self.poll_interval = poll_interval
43
45
  self.websocket_server: WebSocketServer | None = websocket_server
46
+ self.session_manager: LocalSessionManager | None = session_manager
44
47
 
45
48
  # Track active sessions: session_id -> transcript_path
46
49
  self._active_sessions: dict[str, str] = {}
@@ -196,6 +199,13 @@ class SessionMessageProcessor:
196
199
  # Store messages
197
200
  await self.message_manager.store_messages(session_id, parsed_messages)
198
201
 
202
+ # Extract and store model from parsed messages (if present)
203
+ if self.session_manager:
204
+ for msg in parsed_messages:
205
+ if msg.model:
206
+ self.session_manager.update_model(session_id, msg.model)
207
+ break # Only need the first model found
208
+
199
209
  # Broadcast new messages
200
210
  if self.websocket_server:
201
211
  for msg in parsed_messages:
@@ -40,6 +40,7 @@ class ParsedMessage:
40
40
  raw_json: dict[str, Any]
41
41
  usage: TokenUsage | None = None
42
42
  tool_use_id: str | None = None
43
+ model: str | None = None
43
44
 
44
45
 
45
46
  @runtime_checkable
@@ -355,6 +355,8 @@ class ClaudeTranscriptParser:
355
355
  # Skip unknown message types (e.g., 'progress', 'error' internal events)
356
356
  return None
357
357
 
358
+ usage, model = self._extract_usage(data)
359
+
358
360
  return ParsedMessage(
359
361
  index=index,
360
362
  role=role,
@@ -365,12 +367,20 @@ class ClaudeTranscriptParser:
365
367
  tool_result=tool_result,
366
368
  timestamp=timestamp,
367
369
  raw_json=data,
368
- usage=self._extract_usage(data),
370
+ usage=usage,
369
371
  tool_use_id=tool_use_id,
372
+ model=model,
370
373
  )
371
374
 
372
- def _extract_usage(self, data: dict[str, Any]) -> TokenUsage | None:
373
- """Extract token usage from message data."""
375
+ def _extract_usage(self, data: dict[str, Any]) -> tuple[TokenUsage | None, str | None]:
376
+ """Extract token usage and model from message data.
377
+
378
+ Returns:
379
+ Tuple of (TokenUsage | None, model string | None)
380
+ """
381
+ # Extract model from message object
382
+ model = data.get("message", {}).get("model")
383
+
374
384
  # Check for top-level usage field (some formats)
375
385
  usage_data = data.get("usage")
376
386
 
@@ -379,7 +389,7 @@ class ClaudeTranscriptParser:
379
389
  usage_data = data.get("message", {}).get("usage")
380
390
 
381
391
  if not usage_data:
382
- return None
392
+ return None, model
383
393
 
384
394
  # Use explicit presence checks to handle 0 correctly
385
395
  input_tokens = (
@@ -413,7 +423,7 @@ class ClaudeTranscriptParser:
413
423
  cache_creation_tokens=cache_creation_tokens,
414
424
  cache_read_tokens=cache_read_tokens,
415
425
  total_cost_usd=total_cost_usd,
416
- )
426
+ ), model
417
427
 
418
428
  def parse_lines(self, lines: list[str], start_index: int = 0) -> list[ParsedMessage]:
419
429
  """
@@ -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
@@ -82,9 +82,14 @@ class ParsedSkill:
82
82
  assets: list[str] | None = None
83
83
 
84
84
  def get_category(self) -> str | None:
85
- """Get category from metadata.skillport.category."""
85
+ """Get category from top-level or metadata.skillport.category."""
86
86
  if not self.metadata:
87
87
  return None
88
+ # Check top-level first (from frontmatter)
89
+ result = self.metadata.get("category")
90
+ if result is not None:
91
+ return str(result)
92
+ # Fall back to nested skillport.category
88
93
  skillport = self.metadata.get("skillport", {})
89
94
  result = skillport.get("category")
90
95
  return str(result) if result is not None else None
@@ -102,9 +107,18 @@ class ParsedSkill:
102
107
  return []
103
108
 
104
109
  def is_always_apply(self) -> bool:
105
- """Check if this is a core skill (alwaysApply=true)."""
110
+ """Check if this is a core skill (alwaysApply=true).
111
+
112
+ Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
113
+ Top-level takes precedence.
114
+ """
106
115
  if not self.metadata:
107
116
  return False
117
+ # Check top-level first (from frontmatter)
118
+ top_level = self.metadata.get("alwaysApply")
119
+ if top_level is not None:
120
+ return bool(top_level)
121
+ # Fall back to nested skillport.alwaysApply
108
122
  skillport = self.metadata.get("skillport", {})
109
123
  return bool(skillport.get("alwaysApply", False))
110
124
 
@@ -214,6 +228,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
214
228
  # Extract metadata (may contain version, skillport, gobby namespaces)
215
229
  metadata = frontmatter.get("metadata")
216
230
 
231
+ # Handle top-level alwaysApply and category by including them in metadata
232
+ # This allows both top-level and nested formats to work
233
+ top_level_always_apply = frontmatter.get("alwaysApply")
234
+ top_level_category = frontmatter.get("category")
235
+
236
+ if top_level_always_apply is not None or top_level_category is not None:
237
+ if metadata is None:
238
+ metadata = {}
239
+ # Store at top level of metadata (not nested in skillport)
240
+ if top_level_always_apply is not None:
241
+ metadata["alwaysApply"] = top_level_always_apply
242
+ if top_level_category is not None:
243
+ metadata["category"] = top_level_category
244
+
217
245
  # Version can be at top level or in metadata
218
246
  version = frontmatter.get("version")
219
247
  if version is None and metadata and isinstance(metadata, dict):
gobby/storage/database.py CHANGED
@@ -11,7 +11,7 @@ import threading
11
11
  import weakref
12
12
  from collections.abc import Iterator
13
13
  from contextlib import AbstractContextManager, contextmanager
14
- from datetime import date, datetime
14
+ from datetime import UTC, date, datetime
15
15
  from pathlib import Path
16
16
  from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
17
17
 
@@ -21,6 +21,9 @@ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
21
21
 
22
22
  def _adapt_datetime(val: datetime) -> str:
23
23
  """Adapt datetime to ISO format string for SQLite storage."""
24
+ # If naive datetime, assume UTC and add timezone info for RFC3339 compliance
25
+ if val.tzinfo is None:
26
+ val = val.replace(tzinfo=UTC)
24
27
  return val.isoformat()
25
28
 
26
29
 
@@ -31,7 +34,11 @@ def _adapt_date(val: date) -> str:
31
34
 
32
35
  def _convert_datetime(val: bytes) -> datetime:
33
36
  """Convert SQLite datetime string back to datetime object."""
34
- return datetime.fromisoformat(val.decode())
37
+ dt = datetime.fromisoformat(val.decode())
38
+ # Ensure timezone-aware (treat naive as UTC) for consistency
39
+ if dt.tzinfo is None:
40
+ dt = dt.replace(tzinfo=UTC)
41
+ return dt
35
42
 
36
43
 
37
44
  def _convert_date(val: bytes) -> date:
gobby/storage/memories.py CHANGED
@@ -197,40 +197,51 @@ class LocalMemoryManager:
197
197
  return row is not None
198
198
 
199
199
  def content_exists(self, content: str, project_id: str | None = None) -> bool:
200
- """Check if a memory with identical content already exists."""
201
- # Normalize content same way as ID generation in create_memory
202
- normalized_content = content.strip()
203
- project_str = project_id if project_id else ""
204
- # Use delimiter to match create_memory ID generation
205
- memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
200
+ """Check if a memory with identical content already exists.
206
201
 
207
- # Check by ID (content-hash based) for consistent dedup
208
- row = self.db.fetchone("SELECT 1 FROM memories WHERE id = ?", (memory_id,))
202
+ Uses global deduplication - checks if any memory has the same content,
203
+ regardless of project_id. This prevents duplicates when the same content
204
+ is stored with different or NULL project_ids.
205
+
206
+ Args:
207
+ content: The content to check for
208
+ project_id: Ignored (kept for backward compatibility)
209
+
210
+ Returns:
211
+ True if a memory with identical content exists
212
+ """
213
+ # Global deduplication: check by content directly, ignoring project_id
214
+ # This fixes the duplicate issue where same content + different project_id
215
+ # would create different memory IDs
216
+ normalized_content = content.strip()
217
+ row = self.db.fetchone(
218
+ "SELECT 1 FROM memories WHERE content = ? LIMIT 1",
219
+ (normalized_content,),
220
+ )
209
221
  return row is not None
210
222
 
211
223
  def get_memory_by_content(self, content: str, project_id: str | None = None) -> Memory | None:
212
- """Get a memory by its exact content, using the content-derived ID.
224
+ """Get a memory by its exact content.
213
225
 
214
- This provides a reliable way to fetch an existing memory without
215
- relying on search result ordering.
226
+ Uses global lookup - finds any memory with matching content regardless
227
+ of project_id. This matches the behavior of content_exists().
216
228
 
217
229
  Args:
218
230
  content: The exact content to look up (will be normalized)
219
- project_id: Optional project ID for scoping
231
+ project_id: Ignored (kept for backward compatibility)
220
232
 
221
233
  Returns:
222
234
  The Memory object if found, None otherwise
223
235
  """
224
- # Normalize content same way as ID generation in create_memory
236
+ # Global lookup: find by content directly, ignoring project_id
225
237
  normalized_content = content.strip()
226
- project_str = project_id if project_id else ""
227
- # Use delimiter to match create_memory ID generation
228
- memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
229
-
230
- try:
231
- return self.get_memory(memory_id)
232
- except ValueError:
233
- return None
238
+ row = self.db.fetchone(
239
+ "SELECT * FROM memories WHERE content = ? LIMIT 1",
240
+ (normalized_content,),
241
+ )
242
+ if row:
243
+ return Memory.from_row(row)
244
+ return None
234
245
 
235
246
  def update_memory(
236
247
  self,