gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  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/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,499 @@
1
+ """Handoff helper functions and tools for session management.
2
+
3
+ This module contains:
4
+ - Helper functions for formatting handoff context
5
+ - MCP tools for creating and retrieving handoffs
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
14
+ from gobby.sessions.analyzer import HandoffContext
15
+ from gobby.storage.sessions import LocalSessionManager
16
+
17
+
18
+ def _format_handoff_markdown(ctx: HandoffContext, notes: str | None = None) -> str:
19
+ """
20
+ Format HandoffContext as markdown for session handoff.
21
+
22
+ Args:
23
+ ctx: HandoffContext with extracted session data
24
+ notes: Optional additional notes to include
25
+
26
+ Returns:
27
+ Formatted markdown string
28
+ """
29
+ sections: list[str] = ["## Continuation Context", ""]
30
+
31
+ # Active task section
32
+ if ctx.active_gobby_task:
33
+ task = ctx.active_gobby_task
34
+ sections.append("### Active Task")
35
+ sections.append(f"**{task.get('title', 'Untitled')}** ({task.get('id', 'unknown')})")
36
+ sections.append(f"Status: {task.get('status', 'unknown')}")
37
+ sections.append("")
38
+
39
+ # Todo state section
40
+ if ctx.todo_state:
41
+ sections.append("### In-Progress Work")
42
+ for todo in ctx.todo_state:
43
+ status = todo.get("status", "pending")
44
+ marker = "x" if status == "completed" else ">" if status == "in_progress" else " "
45
+ sections.append(f"- [{marker}] {todo.get('content', '')}")
46
+ sections.append("")
47
+
48
+ # Git commits section
49
+ if ctx.git_commits:
50
+ sections.append("### Commits This Session")
51
+ for commit in ctx.git_commits:
52
+ sections.append(f"- `{commit.get('hash', '')[:7]}` {commit.get('message', '')}")
53
+ sections.append("")
54
+
55
+ # Git status section
56
+ if ctx.git_status:
57
+ sections.append("### Uncommitted Changes")
58
+ sections.append("```")
59
+ sections.append(ctx.git_status)
60
+ sections.append("```")
61
+ sections.append("")
62
+
63
+ # Files modified section
64
+ if ctx.files_modified:
65
+ sections.append("### Files Being Modified")
66
+ for f in ctx.files_modified:
67
+ sections.append(f"- {f}")
68
+ sections.append("")
69
+
70
+ # Initial goal section
71
+ if ctx.initial_goal:
72
+ sections.append("### Original Goal")
73
+ sections.append(ctx.initial_goal)
74
+ sections.append("")
75
+
76
+ # Recent activity section
77
+ if ctx.recent_activity:
78
+ sections.append("### Recent Activity")
79
+ for activity in ctx.recent_activity[-5:]:
80
+ sections.append(f"- {activity}")
81
+ sections.append("")
82
+
83
+ # Notes section (if provided)
84
+ if notes:
85
+ sections.append("### Notes")
86
+ sections.append(notes)
87
+ sections.append("")
88
+
89
+ return "\n".join(sections)
90
+
91
+
92
+ def _format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
93
+ """Format transcript turns for LLM analysis."""
94
+ formatted: list[str] = []
95
+ for i, turn in enumerate(turns):
96
+ message = turn.get("message", {})
97
+ role = message.get("role", "unknown")
98
+ content = message.get("content", "")
99
+
100
+ if isinstance(content, list):
101
+ text_parts: list[str] = []
102
+ for block in content:
103
+ if isinstance(block, dict):
104
+ if block.get("type") == "text":
105
+ text_parts.append(str(block.get("text", "")))
106
+ elif block.get("type") == "tool_use":
107
+ text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
108
+ content = " ".join(text_parts)
109
+
110
+ formatted.append(f"[Turn {i + 1} - {role}]: {content}")
111
+
112
+ return "\n\n".join(formatted)
113
+
114
+
115
+ def register_handoff_tools(
116
+ registry: InternalToolRegistry,
117
+ session_manager: LocalSessionManager,
118
+ ) -> None:
119
+ """
120
+ Register handoff tools with a registry.
121
+
122
+ Args:
123
+ registry: The InternalToolRegistry to register tools with
124
+ session_manager: LocalSessionManager instance for session operations
125
+ """
126
+
127
+ @registry.tool(
128
+ name="get_handoff_context",
129
+ description="Get the handoff context (compact_markdown) for a session. Accepts #N, UUID, or prefix.",
130
+ )
131
+ def get_handoff_context(session_id: str) -> dict[str, Any]:
132
+ """
133
+ Retrieve stored handoff context.
134
+
135
+ Args:
136
+ session_id: Session reference - supports #N (project-scoped), UUID, or prefix
137
+
138
+ Returns:
139
+ Session ID, compact_markdown, and whether context exists
140
+ """
141
+ from gobby.utils.project_context import get_project_context
142
+
143
+ if not session_manager:
144
+ raise RuntimeError("Session manager not available")
145
+
146
+ # Get project_id for project-scoped resolution
147
+ project_ctx = get_project_context()
148
+ project_id = project_ctx.get("id") if project_ctx else None
149
+
150
+ # Resolve #N format, UUID, or prefix
151
+ try:
152
+ resolved_id = session_manager.resolve_session_reference(session_id, project_id)
153
+ session = session_manager.get(resolved_id)
154
+ except ValueError:
155
+ session = None
156
+ if not session:
157
+ return {"error": f"Session {session_id} not found", "found": False}
158
+
159
+ return {
160
+ "session_id": session.id,
161
+ "ref": f"#{session.seq_num}" if session.seq_num else session.id[:8],
162
+ "compact_markdown": session.compact_markdown,
163
+ "has_context": bool(session.compact_markdown),
164
+ }
165
+
166
+ @registry.tool(
167
+ name="create_handoff",
168
+ description="""Create handoff context by extracting structured data from the session transcript.
169
+
170
+ Args:
171
+ session_id: (REQUIRED) Your session ID. Get it from:
172
+ 1. Your injected context (look for 'session_id: xxx')
173
+ 2. Or call get_current(external_id, source) first""",
174
+ )
175
+ async def create_handoff(
176
+ session_id: str,
177
+ notes: str | None = None,
178
+ compact: bool = False,
179
+ full: bool = False,
180
+ write_file: bool = True,
181
+ output_path: str = ".gobby/session_summaries/",
182
+ ) -> dict[str, Any]:
183
+ """
184
+ Create handoff context for a session.
185
+
186
+ Generates compact (TranscriptAnalyzer) and/or full (LLM) summaries.
187
+ Always saves to database. Optionally writes to file.
188
+
189
+ Args:
190
+ session_id: Session ID (REQUIRED)
191
+ notes: Additional notes to include in handoff
192
+ compact: Generate compact summary only (default: False, neither = both)
193
+ full: Generate full LLM summary only (default: False, neither = both)
194
+ write_file: Also write to file (default: True). DB is always written.
195
+ output_path: Directory for file output (default: .gobby/session_summaries/ in project)
196
+
197
+ Returns:
198
+ Success status, markdown lengths, and extracted context summary
199
+ """
200
+ import json
201
+ import subprocess # nosec B404 - subprocess needed for git commands
202
+ import time
203
+ from pathlib import Path
204
+
205
+ from gobby.sessions.analyzer import TranscriptAnalyzer
206
+
207
+ if session_manager is None:
208
+ return {"success": False, "error": "Session manager not available"}
209
+
210
+ # Find session - session_id is now required
211
+ session = session_manager.get(session_id)
212
+ if not session:
213
+ # Try prefix match
214
+ sessions = session_manager.list(limit=100)
215
+ matches = [s for s in sessions if s.id.startswith(session_id)]
216
+ if len(matches) == 1:
217
+ session = matches[0]
218
+ elif len(matches) > 1:
219
+ return {
220
+ "error": f"Ambiguous session ID prefix '{session_id}'",
221
+ "matches": [s.id for s in matches[:5]],
222
+ }
223
+
224
+ if not session:
225
+ return {"success": False, "error": "No session found", "session_id": session_id}
226
+
227
+ # Get transcript path
228
+ transcript_path = session.jsonl_path
229
+ if not transcript_path:
230
+ return {
231
+ "success": False,
232
+ "error": "No transcript path for session",
233
+ "session_id": session.id,
234
+ }
235
+
236
+ path = Path(transcript_path)
237
+ if not path.exists():
238
+ return {
239
+ "success": False,
240
+ "error": "Transcript file not found",
241
+ "path": transcript_path,
242
+ }
243
+
244
+ # Read and parse transcript
245
+ turns = []
246
+ with open(path, encoding="utf-8") as f:
247
+ for line in f:
248
+ if line.strip():
249
+ turns.append(json.loads(line))
250
+
251
+ # Analyze transcript
252
+ analyzer = TranscriptAnalyzer()
253
+ handoff_ctx = analyzer.extract_handoff_context(turns)
254
+
255
+ # Enrich with real-time git status
256
+ if not handoff_ctx.git_status:
257
+ try:
258
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
259
+ ["git", "status", "--short"],
260
+ capture_output=True,
261
+ text=True,
262
+ timeout=5,
263
+ cwd=path.parent,
264
+ )
265
+ handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
266
+ except Exception:
267
+ pass # nosec B110 - git status is optional, ignore failures
268
+
269
+ # Get recent git commits
270
+ try:
271
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
272
+ ["git", "log", "--oneline", "-10", "--format=%H|%s"],
273
+ capture_output=True,
274
+ text=True,
275
+ timeout=5,
276
+ cwd=path.parent,
277
+ )
278
+ if result.returncode == 0:
279
+ commits = []
280
+ for line in result.stdout.strip().split("\n"):
281
+ if "|" in line:
282
+ hash_val, message = line.split("|", 1)
283
+ commits.append({"hash": hash_val, "message": message})
284
+ if commits:
285
+ handoff_ctx.git_commits = commits
286
+ except Exception:
287
+ pass # nosec B110 - git log is optional, ignore failures
288
+
289
+ # Determine what to generate (neither flag = both)
290
+ generate_compact = compact or not full
291
+ generate_full = full or not compact
292
+
293
+ # Generate content
294
+ compact_markdown = None
295
+ full_markdown = None
296
+ full_error = None
297
+
298
+ if generate_compact:
299
+ compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
300
+
301
+ if generate_full:
302
+ try:
303
+ from gobby.config.app import load_config
304
+ from gobby.llm.claude import ClaudeLLMProvider
305
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
306
+
307
+ config = load_config()
308
+ provider = ClaudeLLMProvider(config)
309
+ transcript_parser = ClaudeTranscriptParser()
310
+
311
+ # Get prompt template from config
312
+ prompt_template = None
313
+ if hasattr(config, "session_summary") and config.session_summary:
314
+ prompt_template = getattr(config.session_summary, "prompt", None)
315
+
316
+ if not prompt_template:
317
+ raise ValueError(
318
+ "No prompt template configured. "
319
+ "Set 'session_summary.prompt' in ~/.gobby/config.yaml"
320
+ )
321
+
322
+ # Prepare context for LLM
323
+ last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
324
+ last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
325
+
326
+ context = {
327
+ "transcript_summary": _format_turns_for_llm(last_turns),
328
+ "last_messages": last_messages,
329
+ "git_status": handoff_ctx.git_status or "",
330
+ "file_changes": "",
331
+ "external_id": session.id[:12],
332
+ "session_id": session.id,
333
+ "session_source": session.source,
334
+ }
335
+
336
+ full_markdown = await provider.generate_summary(
337
+ context, prompt_template=prompt_template
338
+ )
339
+
340
+ except Exception as e:
341
+ full_error = str(e)
342
+ if full and not compact:
343
+ return {
344
+ "success": False,
345
+ "error": f"Failed to generate full summary: {e}",
346
+ "session_id": session.id,
347
+ }
348
+
349
+ # Always save to database
350
+ if compact_markdown:
351
+ session_manager.update_compact_markdown(session.id, compact_markdown)
352
+ if full_markdown:
353
+ session_manager.update_summary(session.id, summary_markdown=full_markdown)
354
+
355
+ # Save to file if requested
356
+ files_written = []
357
+ if write_file:
358
+ try:
359
+ summary_dir = Path(output_path)
360
+ if not summary_dir.is_absolute():
361
+ summary_dir = Path.cwd() / summary_dir
362
+ summary_dir.mkdir(parents=True, exist_ok=True)
363
+ timestamp = int(time.time())
364
+
365
+ if full_markdown:
366
+ full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
367
+ full_file.write_text(full_markdown, encoding="utf-8")
368
+ files_written.append(str(full_file))
369
+
370
+ if compact_markdown:
371
+ compact_file = summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
372
+ compact_file.write_text(compact_markdown, encoding="utf-8")
373
+ files_written.append(str(compact_file))
374
+
375
+ except Exception as e:
376
+ return {
377
+ "success": False,
378
+ "error": f"Failed to write file: {e}",
379
+ "session_id": session.id,
380
+ }
381
+
382
+ return {
383
+ "success": True,
384
+ "session_id": session.id,
385
+ "compact_length": len(compact_markdown) if compact_markdown else 0,
386
+ "full_length": len(full_markdown) if full_markdown else 0,
387
+ "full_error": full_error,
388
+ "files_written": files_written,
389
+ "context_summary": {
390
+ "has_active_task": bool(handoff_ctx.active_gobby_task),
391
+ "todo_count": len(handoff_ctx.todo_state),
392
+ "files_modified_count": len(handoff_ctx.files_modified),
393
+ "git_commits_count": len(handoff_ctx.git_commits),
394
+ "has_initial_goal": bool(handoff_ctx.initial_goal),
395
+ },
396
+ }
397
+
398
+ @registry.tool(
399
+ name="pickup",
400
+ description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks.",
401
+ )
402
+ def pickup(
403
+ session_id: str | None = None,
404
+ project_id: str | None = None,
405
+ source: str | None = None,
406
+ link_child_session_id: str | None = None,
407
+ ) -> dict[str, Any]:
408
+ """
409
+ Restore context from a previous session's handoff.
410
+
411
+ This tool is designed for CLIs and IDEs that don't have a hooks system.
412
+ It finds the most recent handoff-ready session and returns its context
413
+ for injection into a new session.
414
+
415
+ Args:
416
+ session_id: Specific session ID to pickup from (optional)
417
+ project_id: Project ID to find parent session in (optional)
418
+ source: Filter by CLI source - claude_code, gemini, codex (optional)
419
+ link_child_session_id: If provided, links this session as a child
420
+
421
+ Returns:
422
+ Handoff context markdown and session metadata
423
+ """
424
+ from gobby.utils.machine_id import get_machine_id
425
+
426
+ if session_manager is None:
427
+ return {"error": "Session manager not available"}
428
+
429
+ parent_session = None
430
+
431
+ # Option 1: Direct session_id lookup
432
+ if session_id:
433
+ parent_session = session_manager.get(session_id)
434
+ if not parent_session:
435
+ # Try prefix match
436
+ sessions = session_manager.list(limit=100)
437
+ matches = [s for s in sessions if s.id.startswith(session_id)]
438
+ if len(matches) == 1:
439
+ parent_session = matches[0]
440
+ elif len(matches) > 1:
441
+ return {
442
+ "error": f"Ambiguous session ID prefix '{session_id}'",
443
+ "matches": [s.id for s in matches[:5]],
444
+ }
445
+
446
+ # Option 2: Find parent by project_id and source
447
+ if not parent_session and project_id:
448
+ machine_id = get_machine_id()
449
+ if machine_id:
450
+ parent_session = session_manager.find_parent(
451
+ machine_id=machine_id,
452
+ project_id=project_id,
453
+ source=source,
454
+ status="handoff_ready",
455
+ )
456
+
457
+ # Option 3: Find most recent handoff_ready session
458
+ if not parent_session:
459
+ sessions = session_manager.list(status="handoff_ready", limit=1)
460
+ parent_session = sessions[0] if sessions else None
461
+
462
+ if not parent_session:
463
+ return {
464
+ "found": False,
465
+ "message": "No handoff-ready session found",
466
+ "filters": {
467
+ "session_id": session_id,
468
+ "project_id": project_id,
469
+ "source": source,
470
+ },
471
+ }
472
+
473
+ # Get handoff context (prefer compact_markdown, fall back to summary_markdown)
474
+ context = parent_session.compact_markdown or parent_session.summary_markdown
475
+
476
+ if not context:
477
+ return {
478
+ "found": True,
479
+ "session_id": parent_session.id,
480
+ "has_context": False,
481
+ "message": "Session found but has no handoff context",
482
+ }
483
+
484
+ # Optionally link child session
485
+ if link_child_session_id:
486
+ session_manager.update_parent_session_id(link_child_session_id, parent_session.id)
487
+
488
+ return {
489
+ "found": True,
490
+ "session_id": parent_session.id,
491
+ "has_context": True,
492
+ "context": context,
493
+ "context_type": (
494
+ "compact_markdown" if parent_session.compact_markdown else "summary_markdown"
495
+ ),
496
+ "parent_title": parent_session.title,
497
+ "parent_status": parent_session.status,
498
+ "linked_child": link_child_session_id,
499
+ }
@@ -0,0 +1,138 @@
1
+ """Message retrieval and search tools for session management.
2
+
3
+ This module contains MCP tools for:
4
+ - Getting messages for a session (get_session_messages)
5
+ - Searching messages using FTS (search_messages)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from gobby.mcp_proxy.tools.internal import InternalToolRegistry
14
+ from gobby.storage.session_messages import LocalSessionMessageManager
15
+
16
+
17
+ def register_message_tools(
18
+ registry: InternalToolRegistry,
19
+ message_manager: LocalSessionMessageManager,
20
+ ) -> None:
21
+ """
22
+ Register message retrieval and search tools with a registry.
23
+
24
+ Args:
25
+ registry: The InternalToolRegistry to register tools with
26
+ message_manager: LocalSessionMessageManager instance for message operations
27
+ """
28
+
29
+ @registry.tool(
30
+ name="get_session_messages",
31
+ description="Get messages for a session.",
32
+ )
33
+ async def get_session_messages(
34
+ session_id: str,
35
+ limit: int = 50,
36
+ offset: int = 0,
37
+ full_content: bool = False,
38
+ ) -> dict[str, Any]:
39
+ """
40
+ Get messages for a session.
41
+
42
+ Args:
43
+ session_id: The session ID
44
+ limit: Max messages to return
45
+ offset: Offset for pagination
46
+ full_content: If True, returns full content. If False (default), truncates large content.
47
+ """
48
+ try:
49
+ if not message_manager:
50
+ raise RuntimeError("Message manager not available")
51
+ messages = await message_manager.get_messages(
52
+ session_id=session_id,
53
+ limit=limit,
54
+ offset=offset,
55
+ )
56
+
57
+ # Truncate content if not full_content
58
+ if not full_content:
59
+ for msg in messages:
60
+ if "content" in msg and msg["content"] and isinstance(msg["content"], str):
61
+ if len(msg["content"]) > 500:
62
+ msg["content"] = msg["content"][:500] + "... (truncated)"
63
+
64
+ if "tool_calls" in msg and msg["tool_calls"]:
65
+ for tc in msg["tool_calls"]:
66
+ if (
67
+ "input" in tc
68
+ and isinstance(tc["input"], str)
69
+ and len(tc["input"]) > 200
70
+ ):
71
+ tc["input"] = tc["input"][:200] + "... (truncated)"
72
+
73
+ if "tool_result" in msg and msg["tool_result"]:
74
+ tr = msg["tool_result"]
75
+ if (
76
+ "content" in tr
77
+ and isinstance(tr["content"], str)
78
+ and len(tr["content"]) > 200
79
+ ):
80
+ tr["content"] = tr["content"][:200] + "... (truncated)"
81
+
82
+ session_total = await message_manager.count_messages(session_id)
83
+
84
+ return {
85
+ "success": True,
86
+ "messages": messages,
87
+ "total_count": session_total,
88
+ "returned_count": len(messages),
89
+ "limit": limit,
90
+ "offset": offset,
91
+ "truncated": not full_content,
92
+ }
93
+ except Exception as e:
94
+ return {"success": False, "error": str(e)}
95
+
96
+ @registry.tool(
97
+ name="search_messages",
98
+ description="Search messages using Full Text Search (FTS).",
99
+ )
100
+ async def search_messages(
101
+ query: str,
102
+ session_id: str | None = None,
103
+ limit: int = 20,
104
+ full_content: bool = False,
105
+ ) -> dict[str, Any]:
106
+ """
107
+ Search messages.
108
+
109
+ Args:
110
+ query: Search query
111
+ session_id: Optional session filter
112
+ limit: Max results
113
+ full_content: If True, returns full content. If False (default), truncates large content.
114
+ """
115
+ try:
116
+ if not message_manager:
117
+ raise RuntimeError("Message manager not available")
118
+ results = await message_manager.search_messages(
119
+ query_text=query,
120
+ session_id=session_id,
121
+ limit=limit,
122
+ )
123
+
124
+ # Truncate content if not full_content
125
+ if not full_content:
126
+ for msg in results:
127
+ if "content" in msg and msg["content"] and isinstance(msg["content"], str):
128
+ if len(msg["content"]) > 500:
129
+ msg["content"] = msg["content"][:500] + "... (truncated)"
130
+
131
+ return {
132
+ "success": True,
133
+ "results": results,
134
+ "count": len(results),
135
+ "truncated": not full_content,
136
+ }
137
+ except Exception as e:
138
+ return {"success": False, "error": str(e)}