gobby 0.2.6__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -1,1055 +0,0 @@
1
- """
2
- Internal MCP tools for Gobby Session System.
3
-
4
- Exposes functionality for:
5
- - Session CRUD Operations
6
- - Session Message Retrieval
7
- - Message Search (FTS)
8
- - Handoff Context Management
9
-
10
- These tools are registered with the InternalToolRegistry and accessed
11
- via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- from datetime import UTC
17
- from typing import TYPE_CHECKING, Any
18
-
19
- from gobby.mcp_proxy.tools.internal import InternalToolRegistry
20
-
21
- if TYPE_CHECKING:
22
- from gobby.sessions.analyzer import HandoffContext
23
- from gobby.storage.session_messages import LocalSessionMessageManager
24
- from gobby.storage.sessions import LocalSessionManager
25
-
26
-
27
- def _format_handoff_markdown(ctx: HandoffContext, notes: str | None = None) -> str:
28
- """
29
- Format HandoffContext as markdown for session handoff.
30
-
31
- Args:
32
- ctx: HandoffContext with extracted session data
33
- notes: Optional additional notes to include
34
-
35
- Returns:
36
- Formatted markdown string
37
- """
38
- sections: list[str] = ["## Continuation Context", ""]
39
-
40
- # Active task section
41
- if ctx.active_gobby_task:
42
- task = ctx.active_gobby_task
43
- sections.append("### Active Task")
44
- sections.append(f"**{task.get('title', 'Untitled')}** ({task.get('id', 'unknown')})")
45
- sections.append(f"Status: {task.get('status', 'unknown')}")
46
- sections.append("")
47
-
48
- # Todo state section
49
- if ctx.todo_state:
50
- sections.append("### In-Progress Work")
51
- for todo in ctx.todo_state:
52
- status = todo.get("status", "pending")
53
- marker = "x" if status == "completed" else ">" if status == "in_progress" else " "
54
- sections.append(f"- [{marker}] {todo.get('content', '')}")
55
- sections.append("")
56
-
57
- # Git commits section
58
- if ctx.git_commits:
59
- sections.append("### Commits This Session")
60
- for commit in ctx.git_commits:
61
- sections.append(f"- `{commit.get('hash', '')[:7]}` {commit.get('message', '')}")
62
- sections.append("")
63
-
64
- # Git status section
65
- if ctx.git_status:
66
- sections.append("### Uncommitted Changes")
67
- sections.append("```")
68
- sections.append(ctx.git_status)
69
- sections.append("```")
70
- sections.append("")
71
-
72
- # Files modified section
73
- if ctx.files_modified:
74
- sections.append("### Files Being Modified")
75
- for f in ctx.files_modified:
76
- sections.append(f"- {f}")
77
- sections.append("")
78
-
79
- # Initial goal section
80
- if ctx.initial_goal:
81
- sections.append("### Original Goal")
82
- sections.append(ctx.initial_goal)
83
- sections.append("")
84
-
85
- # Recent activity section
86
- if ctx.recent_activity:
87
- sections.append("### Recent Activity")
88
- for activity in ctx.recent_activity[-5:]:
89
- sections.append(f"- {activity}")
90
- sections.append("")
91
-
92
- # Notes section (if provided)
93
- if notes:
94
- sections.append("### Notes")
95
- sections.append(notes)
96
- sections.append("")
97
-
98
- return "\n".join(sections)
99
-
100
-
101
- def _format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
102
- """Format transcript turns for LLM analysis."""
103
- formatted: list[str] = []
104
- for i, turn in enumerate(turns):
105
- message = turn.get("message", {})
106
- role = message.get("role", "unknown")
107
- content = message.get("content", "")
108
-
109
- if isinstance(content, list):
110
- text_parts: list[str] = []
111
- for block in content:
112
- if isinstance(block, dict):
113
- if block.get("type") == "text":
114
- text_parts.append(str(block.get("text", "")))
115
- elif block.get("type") == "tool_use":
116
- text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
117
- content = " ".join(text_parts)
118
-
119
- formatted.append(f"[Turn {i + 1} - {role}]: {content}")
120
-
121
- return "\n\n".join(formatted)
122
-
123
-
124
- def create_session_messages_registry(
125
- message_manager: LocalSessionMessageManager | None = None,
126
- session_manager: LocalSessionManager | None = None,
127
- ) -> InternalToolRegistry:
128
- """
129
- Create a sessions tool registry with session and message tools.
130
-
131
- Args:
132
- message_manager: LocalSessionMessageManager instance for message operations
133
- session_manager: LocalSessionManager instance for session CRUD
134
-
135
- Returns:
136
- InternalToolRegistry with all session tools registered
137
- """
138
- registry = InternalToolRegistry(
139
- name="gobby-sessions",
140
- description="Session management and message querying - CRUD, retrieval, search",
141
- )
142
-
143
- # --- Message Tools ---
144
- # Only register if message_manager is available
145
-
146
- if message_manager is not None:
147
-
148
- @registry.tool(
149
- name="get_session_messages",
150
- description="Get messages for a session.",
151
- )
152
- async def get_session_messages(
153
- session_id: str,
154
- limit: int = 50,
155
- offset: int = 0,
156
- full_content: bool = False,
157
- ) -> dict[str, Any]:
158
- """
159
- Get messages for a session.
160
-
161
- Args:
162
- session_id: The session ID
163
- limit: Max messages to return
164
- offset: Offset for pagination
165
- full_content: If True, returns full content. If False (default), truncates large content.
166
- """
167
- try:
168
- assert message_manager, "Message manager not available" # nosec B101
169
- messages = await message_manager.get_messages(
170
- session_id=session_id,
171
- limit=limit,
172
- offset=offset,
173
- )
174
-
175
- # Truncate content if not full_content
176
- if not full_content:
177
- for msg in messages:
178
- if "content" in msg and msg["content"] and isinstance(msg["content"], str):
179
- if len(msg["content"]) > 500:
180
- msg["content"] = msg["content"][:500] + "... (truncated)"
181
-
182
- if "tool_calls" in msg and msg["tool_calls"]:
183
- for tc in msg["tool_calls"]:
184
- if (
185
- "input" in tc
186
- and isinstance(tc["input"], str)
187
- and len(tc["input"]) > 200
188
- ):
189
- tc["input"] = tc["input"][:200] + "... (truncated)"
190
-
191
- if "tool_result" in msg and msg["tool_result"]:
192
- tr = msg["tool_result"]
193
- if (
194
- "content" in tr
195
- and isinstance(tr["content"], str)
196
- and len(tr["content"]) > 200
197
- ):
198
- tr["content"] = tr["content"][:200] + "... (truncated)"
199
-
200
- session_total = await message_manager.count_messages(session_id)
201
-
202
- return {
203
- "success": True,
204
- "messages": messages,
205
- "total_count": session_total,
206
- "returned_count": len(messages),
207
- "limit": limit,
208
- "offset": offset,
209
- "truncated": not full_content,
210
- }
211
- except Exception as e:
212
- return {"success": False, "error": str(e)}
213
-
214
- @registry.tool(
215
- name="search_messages",
216
- description="Search messages using Full Text Search (FTS).",
217
- )
218
- async def search_messages(
219
- query: str,
220
- session_id: str | None = None,
221
- limit: int = 20,
222
- full_content: bool = False,
223
- ) -> dict[str, Any]:
224
- """
225
- Search messages.
226
-
227
- Args:
228
- query: Search query
229
- session_id: Optional session filter
230
- limit: Max results
231
- full_content: If True, returns full content. If False (default), truncates large content.
232
- """
233
- try:
234
- assert message_manager, "Message manager not available" # nosec B101
235
- results = await message_manager.search_messages(
236
- query_text=query,
237
- session_id=session_id,
238
- limit=limit,
239
- )
240
-
241
- # Truncate content if not full_content
242
- if not full_content:
243
- for msg in results:
244
- if "content" in msg and msg["content"] and isinstance(msg["content"], str):
245
- if len(msg["content"]) > 500:
246
- msg["content"] = msg["content"][:500] + "... (truncated)"
247
-
248
- return {
249
- "success": True,
250
- "results": results,
251
- "count": len(results),
252
- "truncated": not full_content,
253
- }
254
- except Exception as e:
255
- return {"success": False, "error": str(e)}
256
-
257
- # --- Handoff Tools ---
258
- # Only register if session_manager is available
259
-
260
- if session_manager is not None:
261
-
262
- @registry.tool(
263
- name="get_handoff_context",
264
- description="Get the handoff context (compact_markdown) for a session.",
265
- )
266
- def get_handoff_context(session_id: str) -> dict[str, Any]:
267
- """
268
- Retrieve stored handoff context.
269
-
270
- Args:
271
- session_id: Session ID
272
-
273
- Returns:
274
- Session ID, compact_markdown, and whether context exists
275
- """
276
- assert session_manager, "Session manager not available" # nosec B101
277
- session = session_manager.get(session_id)
278
- if not session:
279
- return {"error": f"Session {session_id} not found", "found": False}
280
-
281
- return {
282
- "session_id": session_id,
283
- "compact_markdown": session.compact_markdown,
284
- "has_context": bool(session.compact_markdown),
285
- }
286
-
287
- @registry.tool(
288
- name="create_handoff",
289
- description="""Create handoff context by extracting structured data from the session transcript.
290
-
291
- Args:
292
- session_id: (REQUIRED) Your session ID. Get it from:
293
- 1. Your injected context (look for 'session_id: xxx')
294
- 2. Or call get_current(external_id, source) first""",
295
- )
296
- async def create_handoff(
297
- session_id: str,
298
- notes: str | None = None,
299
- compact: bool = False,
300
- full: bool = False,
301
- write_file: bool = True,
302
- output_path: str = ".gobby/session_summaries/",
303
- ) -> dict[str, Any]:
304
- """
305
- Create handoff context for a session.
306
-
307
- Generates compact (TranscriptAnalyzer) and/or full (LLM) summaries.
308
- Always saves to database. Optionally writes to file.
309
-
310
- Args:
311
- session_id: Session ID (REQUIRED)
312
- notes: Additional notes to include in handoff
313
- compact: Generate compact summary only (default: False, neither = both)
314
- full: Generate full LLM summary only (default: False, neither = both)
315
- write_file: Also write to file (default: True). DB is always written.
316
- output_path: Directory for file output (default: .gobby/session_summaries/ in project)
317
-
318
- Returns:
319
- Success status, markdown lengths, and extracted context summary
320
- """
321
- import json
322
- import subprocess # nosec B404 - subprocess needed for git commands
323
- import time
324
- from pathlib import Path
325
-
326
- from gobby.sessions.analyzer import TranscriptAnalyzer
327
-
328
- if session_manager is None:
329
- return {"success": False, "error": "Session manager not available"}
330
-
331
- # Find session - session_id is now required
332
- session = session_manager.get(session_id)
333
- if not session:
334
- # Try prefix match
335
- sessions = session_manager.list(limit=100)
336
- matches = [s for s in sessions if s.id.startswith(session_id)]
337
- if len(matches) == 1:
338
- session = matches[0]
339
- elif len(matches) > 1:
340
- return {
341
- "error": f"Ambiguous session ID prefix '{session_id}'",
342
- "matches": [s.id for s in matches[:5]],
343
- }
344
-
345
- if not session:
346
- return {"success": False, "error": "No session found", "session_id": session_id}
347
-
348
- # Get transcript path
349
- transcript_path = session.jsonl_path
350
- if not transcript_path:
351
- return {
352
- "success": False,
353
- "error": "No transcript path for session",
354
- "session_id": session.id,
355
- }
356
-
357
- path = Path(transcript_path)
358
- if not path.exists():
359
- return {
360
- "success": False,
361
- "error": "Transcript file not found",
362
- "path": transcript_path,
363
- }
364
-
365
- # Read and parse transcript
366
- turns = []
367
- with open(path) as f:
368
- for line in f:
369
- if line.strip():
370
- turns.append(json.loads(line))
371
-
372
- # Analyze transcript
373
- analyzer = TranscriptAnalyzer()
374
- handoff_ctx = analyzer.extract_handoff_context(turns)
375
-
376
- # Enrich with real-time git status
377
- if not handoff_ctx.git_status:
378
- try:
379
- result = subprocess.run( # nosec B603 B607 - hardcoded git command
380
- ["git", "status", "--short"],
381
- capture_output=True,
382
- text=True,
383
- timeout=5,
384
- cwd=path.parent,
385
- )
386
- handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
387
- except Exception:
388
- pass # nosec B110 - git status is optional, ignore failures
389
-
390
- # Get recent git commits
391
- try:
392
- result = subprocess.run( # nosec B603 B607 - hardcoded git command
393
- ["git", "log", "--oneline", "-10", "--format=%H|%s"],
394
- capture_output=True,
395
- text=True,
396
- timeout=5,
397
- cwd=path.parent,
398
- )
399
- if result.returncode == 0:
400
- commits = []
401
- for line in result.stdout.strip().split("\n"):
402
- if "|" in line:
403
- hash_val, message = line.split("|", 1)
404
- commits.append({"hash": hash_val, "message": message})
405
- if commits:
406
- handoff_ctx.git_commits = commits
407
- except Exception:
408
- pass # nosec B110 - git log is optional, ignore failures
409
-
410
- # Determine what to generate (neither flag = both)
411
- generate_compact = compact or not full
412
- generate_full = full or not compact
413
-
414
- # Generate content
415
- compact_markdown = None
416
- full_markdown = None
417
- full_error = None
418
-
419
- if generate_compact:
420
- compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
421
-
422
- if generate_full:
423
- try:
424
- from gobby.config.app import load_config
425
- from gobby.llm.claude import ClaudeLLMProvider
426
- from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
427
-
428
- config = load_config()
429
- provider = ClaudeLLMProvider(config)
430
- transcript_parser = ClaudeTranscriptParser()
431
-
432
- # Get prompt template from config
433
- prompt_template = None
434
- if hasattr(config, "session_summary") and config.session_summary:
435
- prompt_template = getattr(config.session_summary, "prompt", None)
436
-
437
- if not prompt_template:
438
- raise ValueError(
439
- "No prompt template configured. "
440
- "Set 'session_summary.prompt' in ~/.gobby/config.yaml"
441
- )
442
-
443
- # Prepare context for LLM
444
- last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
445
- last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
446
-
447
- context = {
448
- "transcript_summary": _format_turns_for_llm(last_turns),
449
- "last_messages": last_messages,
450
- "git_status": handoff_ctx.git_status or "",
451
- "file_changes": "",
452
- "external_id": session.id[:12],
453
- "session_id": session.id,
454
- "session_source": session.source,
455
- }
456
-
457
- full_markdown = await provider.generate_summary(
458
- context, prompt_template=prompt_template
459
- )
460
-
461
- except Exception as e:
462
- full_error = str(e)
463
- if full and not compact:
464
- return {
465
- "success": False,
466
- "error": f"Failed to generate full summary: {e}",
467
- "session_id": session.id,
468
- }
469
-
470
- # Always save to database
471
- if compact_markdown:
472
- session_manager.update_compact_markdown(session.id, compact_markdown)
473
- if full_markdown:
474
- session_manager.update_summary(session.id, summary_markdown=full_markdown)
475
-
476
- # Save to file if requested
477
- files_written = []
478
- if write_file:
479
- try:
480
- summary_dir = Path(output_path)
481
- if not summary_dir.is_absolute():
482
- summary_dir = Path.cwd() / summary_dir
483
- summary_dir.mkdir(parents=True, exist_ok=True)
484
- timestamp = int(time.time())
485
-
486
- if full_markdown:
487
- full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
488
- full_file.write_text(full_markdown, encoding="utf-8")
489
- files_written.append(str(full_file))
490
-
491
- if compact_markdown:
492
- compact_file = (
493
- summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
494
- )
495
- compact_file.write_text(compact_markdown, encoding="utf-8")
496
- files_written.append(str(compact_file))
497
-
498
- except Exception as e:
499
- return {
500
- "success": False,
501
- "error": f"Failed to write file: {e}",
502
- "session_id": session.id,
503
- }
504
-
505
- return {
506
- "success": True,
507
- "session_id": session.id,
508
- "compact_length": len(compact_markdown) if compact_markdown else 0,
509
- "full_length": len(full_markdown) if full_markdown else 0,
510
- "full_error": full_error,
511
- "files_written": files_written,
512
- "context_summary": {
513
- "has_active_task": bool(handoff_ctx.active_gobby_task),
514
- "todo_count": len(handoff_ctx.todo_state),
515
- "files_modified_count": len(handoff_ctx.files_modified),
516
- "git_commits_count": len(handoff_ctx.git_commits),
517
- "has_initial_goal": bool(handoff_ctx.initial_goal),
518
- },
519
- }
520
-
521
- @registry.tool(
522
- name="pickup",
523
- description="Restore context from a previous session's handoff. For CLIs/IDEs without hooks.",
524
- )
525
- def pickup(
526
- session_id: str | None = None,
527
- project_id: str | None = None,
528
- source: str | None = None,
529
- link_child_session_id: str | None = None,
530
- ) -> dict[str, Any]:
531
- """
532
- Restore context from a previous session's handoff.
533
-
534
- This tool is designed for CLIs and IDEs that don't have a hooks system.
535
- It finds the most recent handoff-ready session and returns its context
536
- for injection into a new session.
537
-
538
- Args:
539
- session_id: Specific session ID to pickup from (optional)
540
- project_id: Project ID to find parent session in (optional)
541
- source: Filter by CLI source - claude_code, gemini, codex (optional)
542
- link_child_session_id: If provided, links this session as a child
543
-
544
- Returns:
545
- Handoff context markdown and session metadata
546
- """
547
- from gobby.utils.machine_id import get_machine_id
548
-
549
- if session_manager is None:
550
- return {"error": "Session manager not available"}
551
-
552
- parent_session = None
553
-
554
- # Option 1: Direct session_id lookup
555
- if session_id:
556
- parent_session = session_manager.get(session_id)
557
- if not parent_session:
558
- # Try prefix match
559
- sessions = session_manager.list(limit=100)
560
- matches = [s for s in sessions if s.id.startswith(session_id)]
561
- if len(matches) == 1:
562
- parent_session = matches[0]
563
- elif len(matches) > 1:
564
- return {
565
- "error": f"Ambiguous session ID prefix '{session_id}'",
566
- "matches": [s.id for s in matches[:5]],
567
- }
568
-
569
- # Option 2: Find parent by project_id and source
570
- if not parent_session and project_id:
571
- machine_id = get_machine_id()
572
- if machine_id:
573
- parent_session = session_manager.find_parent(
574
- machine_id=machine_id,
575
- project_id=project_id,
576
- source=source,
577
- status="handoff_ready",
578
- )
579
-
580
- # Option 3: Find most recent handoff_ready session
581
- if not parent_session:
582
- sessions = session_manager.list(status="handoff_ready", limit=1)
583
- parent_session = sessions[0] if sessions else None
584
-
585
- if not parent_session:
586
- return {
587
- "found": False,
588
- "message": "No handoff-ready session found",
589
- "filters": {
590
- "session_id": session_id,
591
- "project_id": project_id,
592
- "source": source,
593
- },
594
- }
595
-
596
- # Get handoff context (prefer compact_markdown, fall back to summary_markdown)
597
- context = parent_session.compact_markdown or parent_session.summary_markdown
598
-
599
- if not context:
600
- return {
601
- "found": True,
602
- "session_id": parent_session.id,
603
- "has_context": False,
604
- "message": "Session found but has no handoff context",
605
- }
606
-
607
- # Optionally link child session
608
- if link_child_session_id:
609
- session_manager.update_parent_session_id(link_child_session_id, parent_session.id)
610
-
611
- return {
612
- "found": True,
613
- "session_id": parent_session.id,
614
- "has_context": True,
615
- "context": context,
616
- "context_type": (
617
- "compact_markdown" if parent_session.compact_markdown else "summary_markdown"
618
- ),
619
- "parent_title": parent_session.title,
620
- "parent_status": parent_session.status,
621
- "linked_child": link_child_session_id,
622
- }
623
-
624
- # --- Session CRUD Tools ---
625
- # Only register if session_manager is available
626
-
627
- if session_manager is not None:
628
-
629
- @registry.tool(
630
- name="get_session",
631
- description="Get session details by ID. Use the session_id from your injected context (look for 'session_id: xxx' in system reminders).",
632
- )
633
- def get_session(session_id: str) -> dict[str, Any]:
634
- """
635
- Get session details by internal session ID.
636
-
637
- Your session_id is injected into your context at session start.
638
- Look for 'session_id: xxx' in your system reminders.
639
-
640
- Args:
641
- session_id: Internal session ID (supports prefix matching)
642
-
643
- Returns:
644
- Session dict with all fields, or error if not found
645
- """
646
- # Support prefix matching like CLI does
647
- if session_manager is None:
648
- return {"error": "Session manager not available"}
649
-
650
- session = session_manager.get(session_id)
651
- if not session:
652
- # Try prefix match
653
- sessions = session_manager.list(limit=100)
654
- matches = [s for s in sessions if s.id.startswith(session_id)]
655
- if len(matches) == 1:
656
- session = matches[0]
657
- elif len(matches) > 1:
658
- return {
659
- "error": f"Ambiguous session ID prefix '{session_id}' matches {len(matches)} sessions",
660
- "matches": [s.id for s in matches[:5]],
661
- }
662
- else:
663
- return {"error": f"Session {session_id} not found", "found": False}
664
-
665
- return {
666
- "found": True,
667
- **session.to_dict(),
668
- }
669
-
670
- @registry.tool(
671
- name="get_current",
672
- description="""Get YOUR current session ID - the CORRECT way to look up your session.
673
-
674
- Use this when session_id wasn't in your injected context. Pass your external_id
675
- (from transcript path or GOBBY_SESSION_ID env) and source (claude, gemini, codex).
676
-
677
- DO NOT use list_sessions to find your session - it won't work with multiple active sessions.""",
678
- )
679
- def get_current(
680
- external_id: str,
681
- source: str,
682
- ) -> dict[str, Any]:
683
- """
684
- Look up your internal session_id from external_id and source.
685
-
686
- The agent passes external_id (from injected context or GOBBY_SESSION_ID env var)
687
- and source (claude, gemini, codex). project_id and machine_id are
688
- auto-resolved from config files.
689
-
690
- Args:
691
- external_id: Your CLI's session ID (from context or GOBBY_SESSION_ID env)
692
- source: CLI source - "claude", "gemini", or "codex"
693
-
694
- Returns:
695
- session_id: Internal Gobby session ID (use for parent_session_id, etc.)
696
- Plus basic session metadata
697
- """
698
- from gobby.utils.machine_id import get_machine_id
699
- from gobby.utils.project_context import get_project_context
700
-
701
- if session_manager is None:
702
- return {"error": "Session manager not available"}
703
-
704
- # Auto-resolve context
705
- machine_id = get_machine_id()
706
- project_ctx = get_project_context()
707
- project_id = project_ctx.get("id") if project_ctx else None
708
-
709
- if not machine_id:
710
- return {"error": "Could not determine machine_id"}
711
- if not project_id:
712
- return {"error": "Could not determine project_id (not in a gobby project?)"}
713
-
714
- # Use find_by_external_id with full composite key (safe lookup)
715
- session = session_manager.find_by_external_id(
716
- external_id=external_id,
717
- machine_id=machine_id,
718
- project_id=project_id,
719
- source=source,
720
- )
721
-
722
- if not session:
723
- return {
724
- "found": False,
725
- "error": "Session not found",
726
- "lookup": {
727
- "external_id": external_id,
728
- "source": source,
729
- "project_id": project_id,
730
- },
731
- }
732
-
733
- return {
734
- "found": True,
735
- "session_id": session.id,
736
- "project_id": session.project_id,
737
- "status": session.status,
738
- "agent_run_id": session.agent_run_id,
739
- }
740
-
741
- @registry.tool(
742
- name="list_sessions",
743
- description="""List sessions with optional filtering.
744
-
745
- WARNING: Do NOT use this to find your own session_id!
746
- - `list_sessions(status="active", limit=1)` will NOT reliably return YOUR session
747
- - Multiple sessions can be active simultaneously (parallel agents, multiple terminals)
748
- - Use `get_current(external_id, source)` instead - it uses your unique session key
749
-
750
- This tool is for browsing/listing sessions, not for self-identification.""",
751
- )
752
- def list_sessions(
753
- project_id: str | None = None,
754
- status: str | None = None,
755
- source: str | None = None,
756
- limit: int = 20,
757
- ) -> dict[str, Any]:
758
- """
759
- List sessions with filters.
760
-
761
- Args:
762
- project_id: Filter by project ID
763
- status: Filter by status (active, paused, expired, archived, handoff_ready)
764
- source: Filter by CLI source (claude, gemini, codex)
765
- limit: Max results (default 20)
766
-
767
- Returns:
768
- List of sessions and count
769
- """
770
- if session_manager is None:
771
- return {"error": "Session manager not available"}
772
-
773
- sessions = session_manager.list(
774
- project_id=project_id,
775
- status=status,
776
- source=source,
777
- limit=limit,
778
- )
779
-
780
- total = session_manager.count(
781
- project_id=project_id,
782
- status=status,
783
- source=source,
784
- )
785
-
786
- # Detect likely misuse pattern: trying to find own session
787
- if status == "active" and limit == 1:
788
- return {
789
- "warning": (
790
- "list_sessions(status='active', limit=1) will NOT reliably get YOUR session_id! "
791
- "Multiple sessions can be active simultaneously. "
792
- "Use get_current(external_id='<your-external-id>', source='claude') instead."
793
- ),
794
- "hint": "Your external_id is in your transcript path: /path/to/<external_id>.jsonl",
795
- "sessions": [s.to_dict() for s in sessions],
796
- "count": len(sessions),
797
- "total": total,
798
- "limit": limit,
799
- "filters": {
800
- "project_id": project_id,
801
- "status": status,
802
- "source": source,
803
- },
804
- }
805
-
806
- return {
807
- "sessions": [s.to_dict() for s in sessions],
808
- "count": len(sessions),
809
- "total": total,
810
- "limit": limit,
811
- "filters": {
812
- "project_id": project_id,
813
- "status": status,
814
- "source": source,
815
- },
816
- }
817
-
818
- @registry.tool(
819
- name="session_stats",
820
- description="Get session statistics for a project.",
821
- )
822
- def session_stats(project_id: str | None = None) -> dict[str, Any]:
823
- """
824
- Get session statistics.
825
-
826
- Args:
827
- project_id: Filter by project ID (optional)
828
-
829
- Returns:
830
- Statistics including total, by_status, by_source
831
- """
832
- if session_manager is None:
833
- return {"error": "Session manager not available"}
834
-
835
- total = session_manager.count(project_id=project_id)
836
- by_status = session_manager.count_by_status()
837
-
838
- # Count by source
839
- by_source: dict[str, int] = {}
840
- for src in ["claude_code", "gemini", "codex"]:
841
- count = session_manager.count(project_id=project_id, source=src)
842
- if count > 0:
843
- by_source[src] = count
844
-
845
- return {
846
- "total": total,
847
- "by_status": by_status,
848
- "by_source": by_source,
849
- "project_id": project_id,
850
- }
851
-
852
- @registry.tool(
853
- name="get_session_commits",
854
- description="Get git commits made during a session timeframe.",
855
- )
856
- def get_session_commits(
857
- session_id: str,
858
- max_commits: int = 20,
859
- ) -> dict[str, Any]:
860
- """
861
- Get git commits made during a session's active timeframe.
862
-
863
- Uses session.created_at and session.updated_at to filter
864
- git log within that timeframe.
865
-
866
- Args:
867
- session_id: Session ID
868
- max_commits: Maximum commits to return (default 20)
869
-
870
- Returns:
871
- Session ID, list of commits, and count
872
- """
873
- import subprocess # nosec B404 - subprocess needed for git commands
874
- from datetime import datetime
875
- from pathlib import Path
876
-
877
- if session_manager is None:
878
- return {"error": "Session manager not available"}
879
-
880
- # Get session
881
- session = session_manager.get(session_id)
882
- if not session:
883
- # Try prefix match
884
- sessions = session_manager.list(limit=100)
885
- matches = [s for s in sessions if s.id.startswith(session_id)]
886
- if len(matches) == 1:
887
- session = matches[0]
888
- elif len(matches) > 1:
889
- return {
890
- "error": f"Ambiguous session ID prefix '{session_id}'",
891
- "matches": [s.id for s in matches[:5]],
892
- }
893
- else:
894
- return {"error": f"Session {session_id} not found"}
895
-
896
- # Get working directory from transcript path or project
897
- cwd = None
898
- if session.jsonl_path:
899
- cwd = str(Path(session.jsonl_path).parent)
900
-
901
- # Format timestamps for git --since/--until
902
- # Git expects ISO format or relative dates
903
- # Session timestamps may be ISO strings or datetime objects
904
- if isinstance(session.created_at, str):
905
- since_time = datetime.fromisoformat(session.created_at.replace("Z", "+00:00"))
906
- else:
907
- since_time = session.created_at
908
-
909
- if session.updated_at:
910
- if isinstance(session.updated_at, str):
911
- until_time = datetime.fromisoformat(session.updated_at.replace("Z", "+00:00"))
912
- else:
913
- until_time = session.updated_at
914
- else:
915
- until_time = datetime.now(UTC)
916
-
917
- # Format as ISO 8601 for git
918
- since_str = since_time.strftime("%Y-%m-%dT%H:%M:%S")
919
- until_str = until_time.strftime("%Y-%m-%dT%H:%M:%S")
920
-
921
- try:
922
- # Get commits within timeframe
923
- cmd = [
924
- "git",
925
- "log",
926
- f"--since={since_str}",
927
- f"--until={until_str}",
928
- f"-{max_commits}",
929
- "--format=%H|%s|%aI", # hash|subject|author-date-iso
930
- ]
931
-
932
- result = subprocess.run( # nosec B603 - cmd built from hardcoded git arguments
933
- cmd,
934
- capture_output=True,
935
- text=True,
936
- timeout=10,
937
- cwd=cwd,
938
- )
939
-
940
- if result.returncode != 0:
941
- return {
942
- "session_id": session.id,
943
- "error": "Git command failed",
944
- "stderr": result.stderr.strip(),
945
- }
946
-
947
- commits = []
948
- for line in result.stdout.strip().split("\n"):
949
- if "|" in line:
950
- parts = line.split("|", 2)
951
- if len(parts) >= 2:
952
- commit = {
953
- "hash": parts[0],
954
- "message": parts[1],
955
- }
956
- if len(parts) >= 3:
957
- commit["timestamp"] = parts[2]
958
- commits.append(commit)
959
-
960
- return {
961
- "session_id": session.id,
962
- "commits": commits,
963
- "count": len(commits),
964
- "timeframe": {
965
- "since": since_str,
966
- "until": until_str,
967
- },
968
- }
969
-
970
- except subprocess.TimeoutExpired:
971
- return {
972
- "session_id": session.id,
973
- "error": "Git command timed out",
974
- }
975
- except FileNotFoundError:
976
- return {
977
- "session_id": session.id,
978
- "error": "Git not found or not a git repository",
979
- }
980
- except Exception as e:
981
- return {
982
- "session_id": session.id,
983
- "error": f"Failed to get commits: {e!s}",
984
- }
985
-
986
- @registry.tool(
987
- name="mark_loop_complete",
988
- description="""Mark the autonomous loop as complete, preventing session chaining.
989
-
990
- Args:
991
- session_id: (REQUIRED) Your session ID. Get it from:
992
- 1. Your injected context (look for 'session_id: xxx')
993
- 2. Or call get_current(external_id, source) first""",
994
- )
995
- def mark_loop_complete(session_id: str) -> dict[str, Any]:
996
- """
997
- Mark the autonomous loop as complete for a session.
998
-
999
- This sets stop_reason='completed' in the workflow state, which
1000
- signals the auto-loop workflow to NOT chain a new session
1001
- when this session ends.
1002
-
1003
- Use this when:
1004
- - A task is fully complete and no more work is needed
1005
- - You want to exit the autonomous loop gracefully
1006
- - The user has explicitly asked to stop
1007
-
1008
- Args:
1009
- session_id: Session ID (REQUIRED)
1010
-
1011
- Returns:
1012
- Success status and session details
1013
- """
1014
- assert session_manager, "Session manager not available" # nosec B101
1015
-
1016
- # Find session - session_id is now required
1017
- session = session_manager.get(session_id)
1018
-
1019
- if not session:
1020
- return {"error": f"Session {session_id} not found", "session_id": session_id}
1021
-
1022
- # Load and update workflow state
1023
- from gobby.storage.database import LocalDatabase
1024
- from gobby.workflows.definitions import WorkflowState
1025
- from gobby.workflows.state_manager import WorkflowStateManager
1026
-
1027
- db = LocalDatabase()
1028
- state_manager = WorkflowStateManager(db)
1029
-
1030
- # Get or create state for session
1031
- state = state_manager.get_state(session.id)
1032
- if not state:
1033
- # Create minimal state just to hold the variable
1034
- state = WorkflowState(
1035
- session_id=session.id,
1036
- workflow_name="auto-loop",
1037
- step="active",
1038
- )
1039
-
1040
- # Mark loop complete using the action function
1041
- from gobby.workflows.state_actions import mark_loop_complete as action_mark_complete
1042
-
1043
- action_mark_complete(state)
1044
-
1045
- # Save updated state
1046
- state_manager.save_state(state)
1047
-
1048
- return {
1049
- "success": True,
1050
- "session_id": session.id,
1051
- "stop_reason": "completed",
1052
- "message": "Autonomous loop marked complete - session will not chain",
1053
- }
1054
-
1055
- return registry