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
@@ -0,0 +1,503 @@
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
+ from gobby.utils.project_context import get_project_context
127
+
128
+ def _resolve_session_id(ref: str) -> str:
129
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID."""
130
+ project_ctx = get_project_context()
131
+ project_id = project_ctx.get("id") if project_ctx else None
132
+
133
+ return session_manager.resolve_session_reference(ref, project_id)
134
+
135
+ @registry.tool(
136
+ name="get_handoff_context",
137
+ description="Get the handoff context (compact_markdown) for a session. Accepts #N, N, UUID, or prefix.",
138
+ )
139
+ def get_handoff_context(session_id: str) -> dict[str, Any]:
140
+ """
141
+ Retrieve stored handoff context.
142
+
143
+ Args:
144
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
145
+
146
+ Returns:
147
+ Session ID, compact_markdown, and whether context exists
148
+ """
149
+ if not session_manager:
150
+ raise RuntimeError("Session manager not available")
151
+
152
+ # Get project_id for project-scoped resolution
153
+ project_ctx = get_project_context()
154
+ project_id = project_ctx.get("id") if project_ctx else None
155
+
156
+ # Resolve #N format, UUID, or prefix
157
+ try:
158
+ resolved_id = session_manager.resolve_session_reference(session_id, project_id)
159
+ session = session_manager.get(resolved_id)
160
+ except ValueError:
161
+ session = None
162
+ if not session:
163
+ return {"error": f"Session {session_id} not found", "found": False}
164
+
165
+ return {
166
+ "session_id": session.id,
167
+ "ref": f"#{session.seq_num}" if session.seq_num else session.id[:8],
168
+ "compact_markdown": session.compact_markdown,
169
+ "has_context": bool(session.compact_markdown),
170
+ }
171
+
172
+ @registry.tool(
173
+ name="create_handoff",
174
+ description="""Create handoff context by extracting structured data from the session transcript. Accepts #N, N, UUID, or prefix for session_id.
175
+
176
+ Args:
177
+ session_id: (REQUIRED) Your session ID. Accepts #N, N, UUID, or prefix. Get it from:
178
+ 1. Your injected context (look for 'Session Ref: #N' or 'session_id: xxx')
179
+ 2. Or call get_current_session(external_id, source) first""",
180
+ )
181
+ async def create_handoff(
182
+ session_id: str,
183
+ notes: str | None = None,
184
+ compact: bool = False,
185
+ full: bool = False,
186
+ write_file: bool = True,
187
+ output_path: str = ".gobby/session_summaries/",
188
+ ) -> dict[str, Any]:
189
+ """
190
+ Create handoff context for a session.
191
+
192
+ Generates compact (TranscriptAnalyzer) and/or full (LLM) summaries.
193
+ Always saves to database. Optionally writes to file.
194
+
195
+ Args:
196
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (REQUIRED)
197
+ notes: Additional notes to include in handoff
198
+ compact: Generate compact summary only (default: False, neither = both)
199
+ full: Generate full LLM summary only (default: False, neither = both)
200
+ write_file: Also write to file (default: True). DB is always written.
201
+ output_path: Directory for file output (default: .gobby/session_summaries/ in project)
202
+
203
+ Returns:
204
+ Success status, markdown lengths, and extracted context summary
205
+ """
206
+ import json
207
+ import subprocess # nosec B404 - subprocess needed for git commands
208
+ import time
209
+ from pathlib import Path
210
+
211
+ from gobby.sessions.analyzer import TranscriptAnalyzer
212
+
213
+ if session_manager is None:
214
+ return {"success": False, "error": "Session manager not available"}
215
+
216
+ # Resolve session reference (#N, N, UUID, or prefix)
217
+ try:
218
+ resolved_id = _resolve_session_id(session_id)
219
+ session = session_manager.get(resolved_id)
220
+ except ValueError as e:
221
+ return {"success": False, "error": str(e), "session_id": session_id}
222
+
223
+ if not session:
224
+ return {"success": False, "error": "No session found", "session_id": session_id}
225
+
226
+ # Get transcript path
227
+ transcript_path = session.jsonl_path
228
+ if not transcript_path:
229
+ return {
230
+ "success": False,
231
+ "error": "No transcript path for session",
232
+ "session_id": session.id,
233
+ }
234
+
235
+ path = Path(transcript_path)
236
+ if not path.exists():
237
+ return {
238
+ "success": False,
239
+ "error": "Transcript file not found",
240
+ "path": transcript_path,
241
+ }
242
+
243
+ # Read and parse transcript
244
+ turns = []
245
+ with open(path, encoding="utf-8") as f:
246
+ for line in f:
247
+ if line.strip():
248
+ turns.append(json.loads(line))
249
+
250
+ # Analyze transcript
251
+ analyzer = TranscriptAnalyzer()
252
+ handoff_ctx = analyzer.extract_handoff_context(turns)
253
+
254
+ # Enrich with real-time git status
255
+ if not handoff_ctx.git_status:
256
+ try:
257
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
258
+ ["git", "status", "--short"],
259
+ capture_output=True,
260
+ text=True,
261
+ timeout=5,
262
+ cwd=path.parent,
263
+ )
264
+ handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
265
+ except Exception:
266
+ pass # nosec B110 - git status is optional, ignore failures
267
+
268
+ # Get recent git commits
269
+ try:
270
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
271
+ ["git", "log", "--oneline", "-10", "--format=%H|%s"],
272
+ capture_output=True,
273
+ text=True,
274
+ timeout=5,
275
+ cwd=path.parent,
276
+ )
277
+ if result.returncode == 0:
278
+ commits = []
279
+ for line in result.stdout.strip().split("\n"):
280
+ if "|" in line:
281
+ hash_val, message = line.split("|", 1)
282
+ commits.append({"hash": hash_val, "message": message})
283
+ if commits:
284
+ handoff_ctx.git_commits = commits
285
+ except Exception:
286
+ pass # nosec B110 - git log is optional, ignore failures
287
+
288
+ # Determine what to generate (neither flag = both)
289
+ generate_compact = compact or not full
290
+ generate_full = full or not compact
291
+
292
+ # Generate content
293
+ compact_markdown = None
294
+ full_markdown = None
295
+ full_error = None
296
+
297
+ if generate_compact:
298
+ compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
299
+
300
+ if generate_full:
301
+ try:
302
+ from gobby.config.app import load_config
303
+ from gobby.llm.claude import ClaudeLLMProvider
304
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
305
+
306
+ config = load_config()
307
+ provider = ClaudeLLMProvider(config)
308
+ transcript_parser = ClaudeTranscriptParser()
309
+
310
+ # Get prompt template from config
311
+ prompt_template = None
312
+ if hasattr(config, "session_summary") and config.session_summary:
313
+ prompt_template = getattr(config.session_summary, "prompt", None)
314
+
315
+ if not prompt_template:
316
+ raise ValueError(
317
+ "No prompt template configured. "
318
+ "Set 'session_summary.prompt' in ~/.gobby/config.yaml"
319
+ )
320
+
321
+ # Prepare context for LLM
322
+ last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
323
+ last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
324
+
325
+ context = {
326
+ "transcript_summary": _format_turns_for_llm(last_turns),
327
+ "last_messages": last_messages,
328
+ "git_status": handoff_ctx.git_status or "",
329
+ "file_changes": "",
330
+ "external_id": session.id[:12],
331
+ "session_id": session.id,
332
+ "session_source": session.source,
333
+ }
334
+
335
+ full_markdown = await provider.generate_summary(
336
+ context, prompt_template=prompt_template
337
+ )
338
+
339
+ except Exception as e:
340
+ full_error = str(e)
341
+ if full and not compact:
342
+ return {
343
+ "success": False,
344
+ "error": f"Failed to generate full summary: {e}",
345
+ "session_id": session.id,
346
+ }
347
+
348
+ # Always save to database
349
+ if compact_markdown:
350
+ session_manager.update_compact_markdown(session.id, compact_markdown)
351
+ if full_markdown:
352
+ session_manager.update_summary(session.id, summary_markdown=full_markdown)
353
+
354
+ # Save to file if requested
355
+ files_written = []
356
+ if write_file:
357
+ try:
358
+ summary_dir = Path(output_path)
359
+ if not summary_dir.is_absolute():
360
+ summary_dir = Path.cwd() / summary_dir
361
+ summary_dir.mkdir(parents=True, exist_ok=True)
362
+ timestamp = int(time.time())
363
+
364
+ if full_markdown:
365
+ full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
366
+ full_file.write_text(full_markdown, encoding="utf-8")
367
+ files_written.append(str(full_file))
368
+
369
+ if compact_markdown:
370
+ compact_file = summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
371
+ compact_file.write_text(compact_markdown, encoding="utf-8")
372
+ files_written.append(str(compact_file))
373
+
374
+ except Exception as e:
375
+ return {
376
+ "success": False,
377
+ "error": f"Failed to write file: {e}",
378
+ "session_id": session.id,
379
+ }
380
+
381
+ return {
382
+ "success": True,
383
+ "session_id": session.id,
384
+ "compact_length": len(compact_markdown) if compact_markdown else 0,
385
+ "full_length": len(full_markdown) if full_markdown else 0,
386
+ "full_error": full_error,
387
+ "files_written": files_written,
388
+ "context_summary": {
389
+ "has_active_task": bool(handoff_ctx.active_gobby_task),
390
+ "todo_count": len(handoff_ctx.todo_state),
391
+ "files_modified_count": len(handoff_ctx.files_modified),
392
+ "git_commits_count": len(handoff_ctx.git_commits),
393
+ "has_initial_goal": bool(handoff_ctx.initial_goal),
394
+ },
395
+ }
396
+
397
+ @registry.tool(
398
+ name="pickup",
399
+ description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks. Accepts #N, N, UUID, or prefix for session_id.",
400
+ )
401
+ def pickup(
402
+ session_id: str | None = None,
403
+ project_id: str | None = None,
404
+ source: str | None = None,
405
+ link_child_session_id: str | None = None,
406
+ ) -> dict[str, Any]:
407
+ """
408
+ Restore context from a previous session's handoff.
409
+
410
+ This tool is designed for CLIs and IDEs that don't have a hooks system.
411
+ It finds the most recent handoff-ready session and returns its context
412
+ for injection into a new session.
413
+
414
+ Args:
415
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix (optional)
416
+ project_id: Project ID to find parent session in (optional)
417
+ source: Filter by CLI source - claude_code, gemini, codex (optional)
418
+ link_child_session_id: Session to link as child - supports #N, N, UUID, or prefix (optional)
419
+
420
+ Returns:
421
+ Handoff context markdown and session metadata
422
+ """
423
+ from gobby.utils.machine_id import get_machine_id
424
+
425
+ if session_manager is None:
426
+ return {"error": "Session manager not available"}
427
+
428
+ parent_session = None
429
+
430
+ # Option 1: Direct session_id lookup with resolution
431
+ if session_id:
432
+ try:
433
+ resolved_id = _resolve_session_id(session_id)
434
+ parent_session = session_manager.get(resolved_id)
435
+ except ValueError as e:
436
+ return {"error": str(e)}
437
+
438
+ # Option 2: Find parent by project_id and source
439
+ if not parent_session and project_id:
440
+ machine_id = get_machine_id()
441
+ if machine_id:
442
+ parent_session = session_manager.find_parent(
443
+ machine_id=machine_id,
444
+ project_id=project_id,
445
+ source=source,
446
+ status="handoff_ready",
447
+ )
448
+
449
+ # Option 3: Find most recent handoff_ready session
450
+ if not parent_session:
451
+ sessions = session_manager.list(status="handoff_ready", limit=1)
452
+ parent_session = sessions[0] if sessions else None
453
+
454
+ if not parent_session:
455
+ return {
456
+ "found": False,
457
+ "message": "No handoff-ready session found",
458
+ "filters": {
459
+ "session_id": session_id,
460
+ "project_id": project_id,
461
+ "source": source,
462
+ },
463
+ }
464
+
465
+ # Get handoff context (prefer compact_markdown, fall back to summary_markdown)
466
+ context = parent_session.compact_markdown or parent_session.summary_markdown
467
+
468
+ if not context:
469
+ return {
470
+ "found": True,
471
+ "session_id": parent_session.id,
472
+ "has_context": False,
473
+ "message": "Session found but has no handoff context",
474
+ }
475
+
476
+ # Optionally link child session (resolve if using #N format)
477
+ resolved_child_id = None
478
+ if link_child_session_id:
479
+ try:
480
+ resolved_child_id = _resolve_session_id(link_child_session_id)
481
+ session_manager.update_parent_session_id(resolved_child_id, parent_session.id)
482
+ except ValueError as e:
483
+ # Do not fallback to raw reference - propagate the error
484
+ return {
485
+ "found": True,
486
+ "session_id": parent_session.id,
487
+ "has_context": True,
488
+ "error": f"Failed to resolve child session '{link_child_session_id}': {e}",
489
+ "context": context,
490
+ }
491
+
492
+ return {
493
+ "found": True,
494
+ "session_id": parent_session.id,
495
+ "has_context": True,
496
+ "context": context,
497
+ "context_type": (
498
+ "compact_markdown" if parent_session.compact_markdown else "summary_markdown"
499
+ ),
500
+ "parent_title": parent_session.title,
501
+ "parent_status": parent_session.status,
502
+ "linked_child": resolved_child_id or link_child_session_id,
503
+ }
@@ -0,0 +1,166 @@
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
+ from gobby.storage.sessions import LocalSessionManager
16
+
17
+
18
+ def register_message_tools(
19
+ registry: InternalToolRegistry,
20
+ message_manager: LocalSessionMessageManager,
21
+ session_manager: LocalSessionManager | None = None,
22
+ ) -> None:
23
+ """
24
+ Register message retrieval and search tools with a registry.
25
+
26
+ Args:
27
+ registry: The InternalToolRegistry to register tools with
28
+ message_manager: LocalSessionMessageManager instance for message operations
29
+ session_manager: LocalSessionManager for resolving session references
30
+ """
31
+
32
+ def _resolve_session_id(session_id: str) -> str:
33
+ """Resolve session reference (#N, N, UUID, or prefix) to UUID.
34
+
35
+ Returns:
36
+ str: Resolved UUID on success
37
+
38
+ Raises:
39
+ ValueError: If session reference cannot be resolved (when session_manager exists)
40
+ """
41
+ if not session_manager:
42
+ return session_id # Fall back to raw value if no manager (backward compat)
43
+
44
+ from gobby.utils.project_context import get_project_context
45
+
46
+ project_ctx = get_project_context()
47
+ project_id = project_ctx.get("id") if project_ctx else None
48
+
49
+ return session_manager.resolve_session_reference(session_id, project_id)
50
+
51
+ @registry.tool(
52
+ name="get_session_messages",
53
+ description="Get messages for a session. Accepts #N, N, UUID, or prefix for session_id.",
54
+ )
55
+ async def get_session_messages(
56
+ session_id: str,
57
+ limit: int = 50,
58
+ offset: int = 0,
59
+ full_content: bool = False,
60
+ ) -> dict[str, Any]:
61
+ """
62
+ Get messages for a session.
63
+
64
+ Args:
65
+ session_id: Session reference - supports #N, N (seq_num), UUID, or prefix
66
+ limit: Max messages to return
67
+ offset: Offset for pagination
68
+ full_content: If True, returns full content. If False (default), truncates large content.
69
+ """
70
+ try:
71
+ if not message_manager:
72
+ raise RuntimeError("Message manager not available")
73
+
74
+ resolved_id = _resolve_session_id(session_id)
75
+ messages = await message_manager.get_messages(
76
+ session_id=resolved_id,
77
+ limit=limit,
78
+ offset=offset,
79
+ )
80
+
81
+ # Truncate content if not full_content
82
+ if not full_content:
83
+ for msg in messages:
84
+ if "content" in msg and msg["content"] and isinstance(msg["content"], str):
85
+ if len(msg["content"]) > 500:
86
+ msg["content"] = msg["content"][:500] + "... (truncated)"
87
+
88
+ if "tool_calls" in msg and msg["tool_calls"]:
89
+ for tc in msg["tool_calls"]:
90
+ if (
91
+ "input" in tc
92
+ and isinstance(tc["input"], str)
93
+ and len(tc["input"]) > 200
94
+ ):
95
+ tc["input"] = tc["input"][:200] + "... (truncated)"
96
+
97
+ if "tool_result" in msg and msg["tool_result"]:
98
+ tr = msg["tool_result"]
99
+ if (
100
+ "content" in tr
101
+ and isinstance(tr["content"], str)
102
+ and len(tr["content"]) > 200
103
+ ):
104
+ tr["content"] = tr["content"][:200] + "... (truncated)"
105
+
106
+ session_total = await message_manager.count_messages(resolved_id)
107
+
108
+ return {
109
+ "success": True,
110
+ "messages": messages,
111
+ "total_count": session_total,
112
+ "returned_count": len(messages),
113
+ "limit": limit,
114
+ "offset": offset,
115
+ "truncated": not full_content,
116
+ }
117
+ except Exception as e:
118
+ return {"success": False, "error": str(e)}
119
+
120
+ @registry.tool(
121
+ name="search_messages",
122
+ description="Search messages using Full Text Search (FTS). Accepts #N, N, UUID, or prefix for session_id.",
123
+ )
124
+ async def search_messages(
125
+ query: str,
126
+ session_id: str | None = None,
127
+ limit: int = 20,
128
+ full_content: bool = False,
129
+ ) -> dict[str, Any]:
130
+ """
131
+ Search messages.
132
+
133
+ Args:
134
+ query: Search query
135
+ session_id: Optional session filter - supports #N, N (seq_num), UUID, or prefix
136
+ limit: Max results
137
+ full_content: If True, returns full content. If False (default), truncates large content.
138
+ """
139
+ try:
140
+ if not message_manager:
141
+ raise RuntimeError("Message manager not available")
142
+
143
+ resolved_session_id = None
144
+ if session_id:
145
+ resolved_session_id = _resolve_session_id(session_id)
146
+ results = await message_manager.search_messages(
147
+ query_text=query,
148
+ session_id=resolved_session_id,
149
+ limit=limit,
150
+ )
151
+
152
+ # Truncate content if not full_content
153
+ if not full_content:
154
+ for msg in results:
155
+ if "content" in msg and msg["content"] and isinstance(msg["content"], str):
156
+ if len(msg["content"]) > 500:
157
+ msg["content"] = msg["content"][:500] + "... (truncated)"
158
+
159
+ return {
160
+ "success": True,
161
+ "results": results,
162
+ "count": len(results),
163
+ "truncated": not full_content,
164
+ }
165
+ except Exception as e:
166
+ return {"success": False, "error": str(e)}