gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1056 @@
|
|
|
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
|
+
"external_id": session.external_id,
|
|
737
|
+
"source": session.source,
|
|
738
|
+
"project_id": session.project_id,
|
|
739
|
+
"status": session.status,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
@registry.tool(
|
|
743
|
+
name="list_sessions",
|
|
744
|
+
description="""List sessions with optional filtering.
|
|
745
|
+
|
|
746
|
+
WARNING: Do NOT use this to find your own session_id!
|
|
747
|
+
- `list_sessions(status="active", limit=1)` will NOT reliably return YOUR session
|
|
748
|
+
- Multiple sessions can be active simultaneously (parallel agents, multiple terminals)
|
|
749
|
+
- Use `get_current(external_id, source)` instead - it uses your unique session key
|
|
750
|
+
|
|
751
|
+
This tool is for browsing/listing sessions, not for self-identification.""",
|
|
752
|
+
)
|
|
753
|
+
def list_sessions(
|
|
754
|
+
project_id: str | None = None,
|
|
755
|
+
status: str | None = None,
|
|
756
|
+
source: str | None = None,
|
|
757
|
+
limit: int = 20,
|
|
758
|
+
) -> dict[str, Any]:
|
|
759
|
+
"""
|
|
760
|
+
List sessions with filters.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
project_id: Filter by project ID
|
|
764
|
+
status: Filter by status (active, paused, expired, archived, handoff_ready)
|
|
765
|
+
source: Filter by CLI source (claude, gemini, codex)
|
|
766
|
+
limit: Max results (default 20)
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
List of sessions and count
|
|
770
|
+
"""
|
|
771
|
+
if session_manager is None:
|
|
772
|
+
return {"error": "Session manager not available"}
|
|
773
|
+
|
|
774
|
+
sessions = session_manager.list(
|
|
775
|
+
project_id=project_id,
|
|
776
|
+
status=status,
|
|
777
|
+
source=source,
|
|
778
|
+
limit=limit,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
total = session_manager.count(
|
|
782
|
+
project_id=project_id,
|
|
783
|
+
status=status,
|
|
784
|
+
source=source,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# Detect likely misuse pattern: trying to find own session
|
|
788
|
+
if status == "active" and limit == 1:
|
|
789
|
+
return {
|
|
790
|
+
"warning": (
|
|
791
|
+
"list_sessions(status='active', limit=1) will NOT reliably get YOUR session_id! "
|
|
792
|
+
"Multiple sessions can be active simultaneously. "
|
|
793
|
+
"Use get_current(external_id='<your-external-id>', source='claude') instead."
|
|
794
|
+
),
|
|
795
|
+
"hint": "Your external_id is in your transcript path: /path/to/<external_id>.jsonl",
|
|
796
|
+
"sessions": [s.to_dict() for s in sessions],
|
|
797
|
+
"count": len(sessions),
|
|
798
|
+
"total": total,
|
|
799
|
+
"limit": limit,
|
|
800
|
+
"filters": {
|
|
801
|
+
"project_id": project_id,
|
|
802
|
+
"status": status,
|
|
803
|
+
"source": source,
|
|
804
|
+
},
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
"sessions": [s.to_dict() for s in sessions],
|
|
809
|
+
"count": len(sessions),
|
|
810
|
+
"total": total,
|
|
811
|
+
"limit": limit,
|
|
812
|
+
"filters": {
|
|
813
|
+
"project_id": project_id,
|
|
814
|
+
"status": status,
|
|
815
|
+
"source": source,
|
|
816
|
+
},
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
@registry.tool(
|
|
820
|
+
name="session_stats",
|
|
821
|
+
description="Get session statistics for a project.",
|
|
822
|
+
)
|
|
823
|
+
def session_stats(project_id: str | None = None) -> dict[str, Any]:
|
|
824
|
+
"""
|
|
825
|
+
Get session statistics.
|
|
826
|
+
|
|
827
|
+
Args:
|
|
828
|
+
project_id: Filter by project ID (optional)
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
Statistics including total, by_status, by_source
|
|
832
|
+
"""
|
|
833
|
+
if session_manager is None:
|
|
834
|
+
return {"error": "Session manager not available"}
|
|
835
|
+
|
|
836
|
+
total = session_manager.count(project_id=project_id)
|
|
837
|
+
by_status = session_manager.count_by_status()
|
|
838
|
+
|
|
839
|
+
# Count by source
|
|
840
|
+
by_source: dict[str, int] = {}
|
|
841
|
+
for src in ["claude_code", "gemini", "codex"]:
|
|
842
|
+
count = session_manager.count(project_id=project_id, source=src)
|
|
843
|
+
if count > 0:
|
|
844
|
+
by_source[src] = count
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
"total": total,
|
|
848
|
+
"by_status": by_status,
|
|
849
|
+
"by_source": by_source,
|
|
850
|
+
"project_id": project_id,
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
@registry.tool(
|
|
854
|
+
name="get_session_commits",
|
|
855
|
+
description="Get git commits made during a session timeframe.",
|
|
856
|
+
)
|
|
857
|
+
def get_session_commits(
|
|
858
|
+
session_id: str,
|
|
859
|
+
max_commits: int = 20,
|
|
860
|
+
) -> dict[str, Any]:
|
|
861
|
+
"""
|
|
862
|
+
Get git commits made during a session's active timeframe.
|
|
863
|
+
|
|
864
|
+
Uses session.created_at and session.updated_at to filter
|
|
865
|
+
git log within that timeframe.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
session_id: Session ID
|
|
869
|
+
max_commits: Maximum commits to return (default 20)
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
Session ID, list of commits, and count
|
|
873
|
+
"""
|
|
874
|
+
import subprocess # nosec B404 - subprocess needed for git commands
|
|
875
|
+
from datetime import datetime
|
|
876
|
+
from pathlib import Path
|
|
877
|
+
|
|
878
|
+
if session_manager is None:
|
|
879
|
+
return {"error": "Session manager not available"}
|
|
880
|
+
|
|
881
|
+
# Get session
|
|
882
|
+
session = session_manager.get(session_id)
|
|
883
|
+
if not session:
|
|
884
|
+
# Try prefix match
|
|
885
|
+
sessions = session_manager.list(limit=100)
|
|
886
|
+
matches = [s for s in sessions if s.id.startswith(session_id)]
|
|
887
|
+
if len(matches) == 1:
|
|
888
|
+
session = matches[0]
|
|
889
|
+
elif len(matches) > 1:
|
|
890
|
+
return {
|
|
891
|
+
"error": f"Ambiguous session ID prefix '{session_id}'",
|
|
892
|
+
"matches": [s.id for s in matches[:5]],
|
|
893
|
+
}
|
|
894
|
+
else:
|
|
895
|
+
return {"error": f"Session {session_id} not found"}
|
|
896
|
+
|
|
897
|
+
# Get working directory from transcript path or project
|
|
898
|
+
cwd = None
|
|
899
|
+
if session.jsonl_path:
|
|
900
|
+
cwd = str(Path(session.jsonl_path).parent)
|
|
901
|
+
|
|
902
|
+
# Format timestamps for git --since/--until
|
|
903
|
+
# Git expects ISO format or relative dates
|
|
904
|
+
# Session timestamps may be ISO strings or datetime objects
|
|
905
|
+
if isinstance(session.created_at, str):
|
|
906
|
+
since_time = datetime.fromisoformat(session.created_at.replace("Z", "+00:00"))
|
|
907
|
+
else:
|
|
908
|
+
since_time = session.created_at
|
|
909
|
+
|
|
910
|
+
if session.updated_at:
|
|
911
|
+
if isinstance(session.updated_at, str):
|
|
912
|
+
until_time = datetime.fromisoformat(session.updated_at.replace("Z", "+00:00"))
|
|
913
|
+
else:
|
|
914
|
+
until_time = session.updated_at
|
|
915
|
+
else:
|
|
916
|
+
until_time = datetime.now(UTC)
|
|
917
|
+
|
|
918
|
+
# Format as ISO 8601 for git
|
|
919
|
+
since_str = since_time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
920
|
+
until_str = until_time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
921
|
+
|
|
922
|
+
try:
|
|
923
|
+
# Get commits within timeframe
|
|
924
|
+
cmd = [
|
|
925
|
+
"git",
|
|
926
|
+
"log",
|
|
927
|
+
f"--since={since_str}",
|
|
928
|
+
f"--until={until_str}",
|
|
929
|
+
f"-{max_commits}",
|
|
930
|
+
"--format=%H|%s|%aI", # hash|subject|author-date-iso
|
|
931
|
+
]
|
|
932
|
+
|
|
933
|
+
result = subprocess.run( # nosec B603 - cmd built from hardcoded git arguments
|
|
934
|
+
cmd,
|
|
935
|
+
capture_output=True,
|
|
936
|
+
text=True,
|
|
937
|
+
timeout=10,
|
|
938
|
+
cwd=cwd,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
if result.returncode != 0:
|
|
942
|
+
return {
|
|
943
|
+
"session_id": session.id,
|
|
944
|
+
"error": "Git command failed",
|
|
945
|
+
"stderr": result.stderr.strip(),
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
commits = []
|
|
949
|
+
for line in result.stdout.strip().split("\n"):
|
|
950
|
+
if "|" in line:
|
|
951
|
+
parts = line.split("|", 2)
|
|
952
|
+
if len(parts) >= 2:
|
|
953
|
+
commit = {
|
|
954
|
+
"hash": parts[0],
|
|
955
|
+
"message": parts[1],
|
|
956
|
+
}
|
|
957
|
+
if len(parts) >= 3:
|
|
958
|
+
commit["timestamp"] = parts[2]
|
|
959
|
+
commits.append(commit)
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
"session_id": session.id,
|
|
963
|
+
"commits": commits,
|
|
964
|
+
"count": len(commits),
|
|
965
|
+
"timeframe": {
|
|
966
|
+
"since": since_str,
|
|
967
|
+
"until": until_str,
|
|
968
|
+
},
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
except subprocess.TimeoutExpired:
|
|
972
|
+
return {
|
|
973
|
+
"session_id": session.id,
|
|
974
|
+
"error": "Git command timed out",
|
|
975
|
+
}
|
|
976
|
+
except FileNotFoundError:
|
|
977
|
+
return {
|
|
978
|
+
"session_id": session.id,
|
|
979
|
+
"error": "Git not found or not a git repository",
|
|
980
|
+
}
|
|
981
|
+
except Exception as e:
|
|
982
|
+
return {
|
|
983
|
+
"session_id": session.id,
|
|
984
|
+
"error": f"Failed to get commits: {e!s}",
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
@registry.tool(
|
|
988
|
+
name="mark_loop_complete",
|
|
989
|
+
description="""Mark the autonomous loop as complete, preventing session chaining.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
session_id: (REQUIRED) Your session ID. Get it from:
|
|
993
|
+
1. Your injected context (look for 'session_id: xxx')
|
|
994
|
+
2. Or call get_current(external_id, source) first""",
|
|
995
|
+
)
|
|
996
|
+
def mark_loop_complete(session_id: str) -> dict[str, Any]:
|
|
997
|
+
"""
|
|
998
|
+
Mark the autonomous loop as complete for a session.
|
|
999
|
+
|
|
1000
|
+
This sets stop_reason='completed' in the workflow state, which
|
|
1001
|
+
signals the auto-loop workflow to NOT chain a new session
|
|
1002
|
+
when this session ends.
|
|
1003
|
+
|
|
1004
|
+
Use this when:
|
|
1005
|
+
- A task is fully complete and no more work is needed
|
|
1006
|
+
- You want to exit the autonomous loop gracefully
|
|
1007
|
+
- The user has explicitly asked to stop
|
|
1008
|
+
|
|
1009
|
+
Args:
|
|
1010
|
+
session_id: Session ID (REQUIRED)
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
Success status and session details
|
|
1014
|
+
"""
|
|
1015
|
+
assert session_manager, "Session manager not available" # nosec B101
|
|
1016
|
+
|
|
1017
|
+
# Find session - session_id is now required
|
|
1018
|
+
session = session_manager.get(session_id)
|
|
1019
|
+
|
|
1020
|
+
if not session:
|
|
1021
|
+
return {"error": f"Session {session_id} not found", "session_id": session_id}
|
|
1022
|
+
|
|
1023
|
+
# Load and update workflow state
|
|
1024
|
+
from gobby.storage.database import LocalDatabase
|
|
1025
|
+
from gobby.workflows.definitions import WorkflowState
|
|
1026
|
+
from gobby.workflows.state_manager import WorkflowStateManager
|
|
1027
|
+
|
|
1028
|
+
db = LocalDatabase()
|
|
1029
|
+
state_manager = WorkflowStateManager(db)
|
|
1030
|
+
|
|
1031
|
+
# Get or create state for session
|
|
1032
|
+
state = state_manager.get_state(session.id)
|
|
1033
|
+
if not state:
|
|
1034
|
+
# Create minimal state just to hold the variable
|
|
1035
|
+
state = WorkflowState(
|
|
1036
|
+
session_id=session.id,
|
|
1037
|
+
workflow_name="auto-loop",
|
|
1038
|
+
step="active",
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# Mark loop complete using the action function
|
|
1042
|
+
from gobby.workflows.state_actions import mark_loop_complete as action_mark_complete
|
|
1043
|
+
|
|
1044
|
+
action_mark_complete(state)
|
|
1045
|
+
|
|
1046
|
+
# Save updated state
|
|
1047
|
+
state_manager.save_state(state)
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
"success": True,
|
|
1051
|
+
"session_id": session.id,
|
|
1052
|
+
"stop_reason": "completed",
|
|
1053
|
+
"message": "Autonomous loop marked complete - session will not chain",
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return registry
|