gobby 0.2.7__py3-none-any.whl → 0.2.9__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 +1 -1
- gobby/adapters/claude_code.py +99 -61
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/app_context.py +59 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/cli/utils.py +5 -17
- gobby/clones/git.py +177 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +87 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +573 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/hook_manager.py +21 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/claude.py +377 -42
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/registries.py +35 -4
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +46 -12
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
- gobby/mcp_proxy/tools/workflows/_query.py +207 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/extractor.py +15 -1
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +36 -10
- gobby/servers/http.py +186 -149
- gobby/servers/routes/admin.py +12 -0
- gobby/servers/routes/mcp/endpoints/execution.py +15 -7
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +4 -4
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +46 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +87 -7
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +5 -0
- gobby/workflows/context_actions.py +21 -24
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +109 -1
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/engine.py +96 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/lifecycle_evaluator.py +2 -1
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
- gobby/cli/tui.py +0 -34
- gobby/hooks/event_handlers.py +0 -909
- gobby/mcp_proxy/tools/workflows.py +0 -973
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
gobby/servers/routes/admin.py
CHANGED
|
@@ -175,6 +175,17 @@ def create_admin_router(server: "HTTPServer") -> APIRouter:
|
|
|
175
175
|
except Exception as e:
|
|
176
176
|
logger.warning(f"Failed to get memory stats: {e}")
|
|
177
177
|
|
|
178
|
+
# Get artifact statistics
|
|
179
|
+
artifact_stats = {"count": 0}
|
|
180
|
+
if server.session_manager is not None:
|
|
181
|
+
try:
|
|
182
|
+
from gobby.storage.artifacts import LocalArtifactManager
|
|
183
|
+
|
|
184
|
+
artifact_manager = LocalArtifactManager(server.session_manager.db)
|
|
185
|
+
artifact_stats["count"] = artifact_manager.count_artifacts()
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.warning(f"Failed to get artifact stats: {e}")
|
|
188
|
+
|
|
178
189
|
# Get skills statistics
|
|
179
190
|
skills_stats: dict[str, Any] = {"total": 0}
|
|
180
191
|
if server._internal_manager:
|
|
@@ -231,6 +242,7 @@ def create_admin_router(server: "HTTPServer") -> APIRouter:
|
|
|
231
242
|
"sessions": session_stats,
|
|
232
243
|
"tasks": task_stats,
|
|
233
244
|
"memory": memory_stats,
|
|
245
|
+
"artifacts": artifact_stats,
|
|
234
246
|
"skills": skills_stats,
|
|
235
247
|
"plugins": plugin_stats,
|
|
236
248
|
"response_time_ms": response_time_ms,
|
|
@@ -318,12 +318,16 @@ async def get_tool_schema(
|
|
|
318
318
|
schema = registry.get_schema(tool_name)
|
|
319
319
|
if schema:
|
|
320
320
|
response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
# Build response with description only if present
|
|
322
|
+
result: dict[str, Any] = {
|
|
323
|
+
"name": schema.get("name", tool_name),
|
|
324
|
+
"inputSchema": schema.get("inputSchema"),
|
|
323
325
|
"server": server_name,
|
|
324
|
-
"inputSchema": schema,
|
|
325
326
|
"response_time_ms": response_time_ms,
|
|
326
327
|
}
|
|
328
|
+
if schema.get("description"):
|
|
329
|
+
result["description"] = schema["description"]
|
|
330
|
+
return result
|
|
327
331
|
raise HTTPException(
|
|
328
332
|
status_code=404,
|
|
329
333
|
detail={
|
|
@@ -340,15 +344,19 @@ async def get_tool_schema(
|
|
|
340
344
|
|
|
341
345
|
# Get from external MCP server
|
|
342
346
|
try:
|
|
343
|
-
|
|
347
|
+
tool_info = await server.mcp_manager.get_tool_info(server_name, tool_name)
|
|
344
348
|
response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
345
349
|
|
|
346
|
-
|
|
347
|
-
|
|
350
|
+
# Build response with description only if present
|
|
351
|
+
response: dict[str, Any] = {
|
|
352
|
+
"name": tool_info.get("name", tool_name),
|
|
353
|
+
"inputSchema": tool_info.get("inputSchema"),
|
|
348
354
|
"server": server_name,
|
|
349
|
-
"inputSchema": schema,
|
|
350
355
|
"response_time_ms": response_time_ms,
|
|
351
356
|
}
|
|
357
|
+
if tool_info.get("description"):
|
|
358
|
+
response["description"] = tool_info["description"]
|
|
359
|
+
return response
|
|
352
360
|
|
|
353
361
|
except (KeyError, ValueError) as e:
|
|
354
362
|
# Tool or server not found - 404
|
|
@@ -255,17 +255,17 @@ async def refresh_mcp_tools(
|
|
|
255
255
|
try:
|
|
256
256
|
session = await server.mcp_manager.ensure_connected(server_name)
|
|
257
257
|
tools_result = await session.list_tools()
|
|
258
|
-
for
|
|
258
|
+
for mcp_tool in tools_result.tools:
|
|
259
259
|
schema = None
|
|
260
|
-
if hasattr(
|
|
261
|
-
if hasattr(
|
|
262
|
-
schema =
|
|
263
|
-
elif isinstance(
|
|
264
|
-
schema =
|
|
260
|
+
if hasattr(mcp_tool, "inputSchema"):
|
|
261
|
+
if hasattr(mcp_tool.inputSchema, "model_dump"):
|
|
262
|
+
schema = mcp_tool.inputSchema.model_dump()
|
|
263
|
+
elif isinstance(mcp_tool.inputSchema, dict):
|
|
264
|
+
schema = mcp_tool.inputSchema
|
|
265
265
|
tools.append(
|
|
266
266
|
{
|
|
267
|
-
"name": getattr(
|
|
268
|
-
"description": getattr(
|
|
267
|
+
"name": getattr(mcp_tool, "name", ""),
|
|
268
|
+
"description": getattr(mcp_tool, "description", ""),
|
|
269
269
|
"inputSchema": schema,
|
|
270
270
|
}
|
|
271
271
|
)
|
|
@@ -19,6 +19,44 @@ if TYPE_CHECKING:
|
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
|
+
# Map hook types to hookEventName for additionalContext
|
|
23
|
+
# Only these hook types support hookSpecificOutput in Claude Code
|
|
24
|
+
HOOK_EVENT_NAME_MAP: dict[str, str] = {
|
|
25
|
+
"pre-tool-use": "PreToolUse",
|
|
26
|
+
"post-tool-use": "PostToolUse",
|
|
27
|
+
"post-tool-use-failure": "PostToolUse",
|
|
28
|
+
"user-prompt-submit": "UserPromptSubmit",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _graceful_error_response(hook_type: str, error_msg: str) -> dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Create a graceful degradation response for hook errors.
|
|
35
|
+
|
|
36
|
+
Instead of returning HTTP 500 (which causes Claude Code to show a confusing
|
|
37
|
+
"hook failed" warning), return a successful response that:
|
|
38
|
+
1. Allows the tool to proceed (continue=True)
|
|
39
|
+
2. Explains the error via additionalContext (so agents understand what happened)
|
|
40
|
+
|
|
41
|
+
This prevents agents from being confused by non-fatal hook errors.
|
|
42
|
+
"""
|
|
43
|
+
response: dict[str, Any] = {
|
|
44
|
+
"continue": True,
|
|
45
|
+
"decision": "approve",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Add helpful context for supported hook types
|
|
49
|
+
hook_event_name = HOOK_EVENT_NAME_MAP.get(hook_type)
|
|
50
|
+
if hook_event_name:
|
|
51
|
+
response["hookSpecificOutput"] = {
|
|
52
|
+
"hookEventName": hook_event_name,
|
|
53
|
+
"additionalContext": (
|
|
54
|
+
f"Gobby hook error (non-fatal): {error_msg}. Tool execution will proceed normally."
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response
|
|
59
|
+
|
|
22
60
|
|
|
23
61
|
def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
24
62
|
"""
|
|
@@ -51,6 +89,7 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
|
51
89
|
start_time = time.perf_counter()
|
|
52
90
|
metrics.inc_counter("http_requests_total")
|
|
53
91
|
metrics.inc_counter("hooks_total")
|
|
92
|
+
hook_type: str | None = None # Track for error handling
|
|
54
93
|
|
|
55
94
|
try:
|
|
56
95
|
# Parse request
|
|
@@ -109,27 +148,35 @@ def create_hooks_router(server: "HTTPServer") -> APIRouter:
|
|
|
109
148
|
return result
|
|
110
149
|
|
|
111
150
|
except ValueError as e:
|
|
151
|
+
# Invalid request - still return graceful response
|
|
112
152
|
metrics.inc_counter("hooks_failed_total")
|
|
113
153
|
logger.warning(
|
|
114
154
|
f"Invalid hook request: {hook_type}",
|
|
115
155
|
extra={"hook_type": hook_type, "error": str(e)},
|
|
116
156
|
)
|
|
117
|
-
|
|
157
|
+
return _graceful_error_response(hook_type, str(e))
|
|
118
158
|
|
|
119
159
|
except Exception as e:
|
|
160
|
+
# Hook execution error - return graceful response so tool proceeds
|
|
161
|
+
# This prevents confusing "hook failed" warnings in Claude Code
|
|
120
162
|
metrics.inc_counter("hooks_failed_total")
|
|
121
163
|
logger.error(
|
|
122
164
|
f"Hook execution failed: {hook_type}",
|
|
123
165
|
exc_info=True,
|
|
124
166
|
extra={"hook_type": hook_type},
|
|
125
167
|
)
|
|
126
|
-
|
|
168
|
+
return _graceful_error_response(hook_type, str(e))
|
|
127
169
|
|
|
128
170
|
except HTTPException:
|
|
171
|
+
# Re-raise 400 errors (bad request) - these are client errors
|
|
129
172
|
raise
|
|
130
173
|
except Exception as e:
|
|
174
|
+
# Outer exception - return graceful response to prevent CLI warning
|
|
131
175
|
metrics.inc_counter("hooks_failed_total")
|
|
132
176
|
logger.error("Hook endpoint error", exc_info=True)
|
|
133
|
-
|
|
177
|
+
if hook_type:
|
|
178
|
+
return _graceful_error_response(hook_type, str(e))
|
|
179
|
+
# Fallback: return basic success to prevent CLI hook failure
|
|
180
|
+
return {"continue": True, "decision": "approve"}
|
|
134
181
|
|
|
135
182
|
return router
|
gobby/servers/websocket.py
CHANGED
|
@@ -10,6 +10,7 @@ Local-first version: Authentication is optional (defaults to always-allow).
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
from collections.abc import Callable, Coroutine
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from datetime import UTC, datetime
|
|
@@ -21,6 +22,7 @@ from websockets.datastructures import Headers
|
|
|
21
22
|
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
|
|
22
23
|
from websockets.http11 import Response
|
|
23
24
|
|
|
25
|
+
from gobby.agents.registry import get_running_agent_registry
|
|
24
26
|
from gobby.mcp_proxy.manager import MCPClientManager
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
@@ -256,6 +258,9 @@ class WebSocketServer:
|
|
|
256
258
|
elif msg_type == "stop_request":
|
|
257
259
|
await self._handle_stop_request(websocket, data)
|
|
258
260
|
|
|
261
|
+
elif msg_type == "terminal_input":
|
|
262
|
+
await self._handle_terminal_input(websocket, data)
|
|
263
|
+
|
|
259
264
|
else:
|
|
260
265
|
logger.warning(f"Unknown message type: {msg_type}")
|
|
261
266
|
await self._send_error(websocket, f"Unknown message type: {msg_type}")
|
|
@@ -496,11 +501,62 @@ class WebSocketServer:
|
|
|
496
501
|
)
|
|
497
502
|
|
|
498
503
|
logger.info(f"Stop requested for session {session_id} via WebSocket")
|
|
499
|
-
|
|
500
504
|
except Exception as e:
|
|
501
505
|
logger.error(f"Error handling stop request: {e}")
|
|
502
506
|
await self._send_error(websocket, f"Failed to signal stop: {str(e)}")
|
|
503
507
|
|
|
508
|
+
async def _handle_terminal_input(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
509
|
+
"""
|
|
510
|
+
Handle terminal input for a running agent.
|
|
511
|
+
|
|
512
|
+
Message format:
|
|
513
|
+
{
|
|
514
|
+
"type": "terminal_input",
|
|
515
|
+
"run_id": "uuid",
|
|
516
|
+
"data": "raw input string"
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
websocket: Client WebSocket connection
|
|
521
|
+
data: Parsed terminal input message
|
|
522
|
+
"""
|
|
523
|
+
run_id = data.get("run_id")
|
|
524
|
+
input_data = data.get("data")
|
|
525
|
+
|
|
526
|
+
if not run_id or input_data is None:
|
|
527
|
+
# Don't send error for every keystroke if malformed, just log debug
|
|
528
|
+
logger.debug(
|
|
529
|
+
f"Invalid terminal_input: run_id={run_id}, data_len={len(str(input_data)) if input_data else 0}"
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
if not isinstance(input_data, str):
|
|
534
|
+
# input_data must be a string to encode; log and skip non-strings
|
|
535
|
+
logger.debug(
|
|
536
|
+
f"Invalid terminal_input type: run_id={run_id}, data_type={type(input_data).__name__}"
|
|
537
|
+
)
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
registry = get_running_agent_registry()
|
|
541
|
+
# Look up by run_id
|
|
542
|
+
agent = registry.get(run_id)
|
|
543
|
+
|
|
544
|
+
if not agent:
|
|
545
|
+
# Be silent on missing agent to avoid spamming errors if frontend is out of sync
|
|
546
|
+
# or if agent just died.
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
if agent.master_fd is None:
|
|
550
|
+
logger.warning(f"Agent {run_id} has no PTY master_fd")
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
# Write key/input to PTY off the event loop
|
|
555
|
+
encoded_data = input_data.encode("utf-8")
|
|
556
|
+
await asyncio.to_thread(os.write, agent.master_fd, encoded_data)
|
|
557
|
+
except OSError as e:
|
|
558
|
+
logger.warning(f"Failed to write to agent {run_id} PTY: {e}")
|
|
559
|
+
|
|
504
560
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
505
561
|
"""
|
|
506
562
|
Broadcast message to all connected clients.
|
gobby/sessions/analyzer.py
CHANGED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
|
-
from datetime import datetime
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
15
|
from gobby.sessions.transcripts.base import TranscriptParser
|
|
@@ -32,8 +32,8 @@ class HandoffContext:
|
|
|
32
32
|
key_decisions: list[str] | None = None
|
|
33
33
|
active_worktree: dict[str, Any] | None = None
|
|
34
34
|
"""Worktree context if session is operating in a worktree."""
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# Note: active_skills field removed - redundant with _build_skill_injection_context()
|
|
36
|
+
# which already handles skill restoration on session start
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class TranscriptAnalyzer:
|
|
@@ -207,7 +207,7 @@ class TranscriptAnalyzer:
|
|
|
207
207
|
context.git_commits.append(
|
|
208
208
|
{
|
|
209
209
|
"command": command,
|
|
210
|
-
"timestamp": datetime.now().isoformat(), # Approx time
|
|
210
|
+
"timestamp": datetime.now(UTC).isoformat(), # Approx time
|
|
211
211
|
}
|
|
212
212
|
)
|
|
213
213
|
|
gobby/sessions/manager.py
CHANGED
|
@@ -82,6 +82,8 @@ class SessionManager:
|
|
|
82
82
|
git_branch: str | None = None,
|
|
83
83
|
project_path: str | None = None,
|
|
84
84
|
terminal_context: dict[str, Any] | None = None,
|
|
85
|
+
workflow_name: str | None = None,
|
|
86
|
+
agent_depth: int = 0,
|
|
85
87
|
) -> str:
|
|
86
88
|
"""
|
|
87
89
|
Register new session with local storage.
|
|
@@ -96,6 +98,9 @@ class SessionManager:
|
|
|
96
98
|
title: Optional session title/summary
|
|
97
99
|
git_branch: Optional git branch name
|
|
98
100
|
project_path: Optional project path (for git extraction if git_branch not provided)
|
|
101
|
+
terminal_context: Optional terminal context for correlation
|
|
102
|
+
workflow_name: Optional workflow to auto-activate for this session
|
|
103
|
+
agent_depth: Depth in agent hierarchy (0 = root session)
|
|
99
104
|
|
|
100
105
|
Returns:
|
|
101
106
|
session_id (database UUID)
|
|
@@ -125,6 +130,8 @@ class SessionManager:
|
|
|
125
130
|
git_branch=git_branch,
|
|
126
131
|
parent_session_id=parent_session_id,
|
|
127
132
|
terminal_context=terminal_context,
|
|
133
|
+
workflow_name=workflow_name,
|
|
134
|
+
agent_depth=agent_depth,
|
|
128
135
|
)
|
|
129
136
|
|
|
130
137
|
session_id: str = session.id
|
|
@@ -143,6 +150,8 @@ class SessionManager:
|
|
|
143
150
|
"project_id": project_id,
|
|
144
151
|
"title": title,
|
|
145
152
|
"git_branch": git_branch,
|
|
153
|
+
"workflow_name": workflow_name,
|
|
154
|
+
"agent_depth": agent_depth,
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
self.logger.debug(f"Registered session {session_id} (external_id={external_id})")
|
|
@@ -38,13 +38,34 @@ class GeminiTranscriptParser:
|
|
|
38
38
|
) -> list[dict[str, Any]]:
|
|
39
39
|
"""
|
|
40
40
|
Extract last N user<>agent message pairs.
|
|
41
|
+
|
|
42
|
+
Handles both Gemini CLI's type-based format and legacy role/content format.
|
|
41
43
|
"""
|
|
42
44
|
messages: list[dict[str, str]] = []
|
|
43
45
|
for turn in reversed(turns):
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
role
|
|
47
|
-
content
|
|
46
|
+
# Handle Gemini CLI's type-based format
|
|
47
|
+
event_type = turn.get("type")
|
|
48
|
+
role: str | None = None
|
|
49
|
+
content: str | Any = None
|
|
50
|
+
|
|
51
|
+
if event_type == "message":
|
|
52
|
+
role = turn.get("role")
|
|
53
|
+
content = turn.get("content")
|
|
54
|
+
elif event_type in ("init", "result"):
|
|
55
|
+
# Skip non-message events
|
|
56
|
+
continue
|
|
57
|
+
elif event_type == "tool_use":
|
|
58
|
+
# Include tool calls as assistant messages
|
|
59
|
+
role = "assistant"
|
|
60
|
+
tool_name = turn.get("tool_name") or turn.get("function_name", "unknown")
|
|
61
|
+
content = f"[Tool call: {tool_name}]"
|
|
62
|
+
elif event_type == "tool_result":
|
|
63
|
+
# Skip tool results for message extraction
|
|
64
|
+
continue
|
|
65
|
+
else:
|
|
66
|
+
# Fallback: legacy format with role/content at top level
|
|
67
|
+
role = turn.get("role") or turn.get("message", {}).get("role")
|
|
68
|
+
content = turn.get("content") or turn.get("message", {}).get("content")
|
|
48
69
|
|
|
49
70
|
if role in ["user", "model", "assistant"]:
|
|
50
71
|
norm_role = "assistant" if role == "model" else role
|
|
@@ -53,7 +74,7 @@ class GeminiTranscriptParser:
|
|
|
53
74
|
if isinstance(content, list):
|
|
54
75
|
content = " ".join(str(part) for part in content)
|
|
55
76
|
|
|
56
|
-
messages.insert(0, {"role": norm_role, "content": str(content)})
|
|
77
|
+
messages.insert(0, {"role": norm_role, "content": str(content or "")})
|
|
57
78
|
if len(messages) >= num_pairs * 2:
|
|
58
79
|
break
|
|
59
80
|
return messages
|
|
@@ -78,6 +99,13 @@ class GeminiTranscriptParser:
|
|
|
78
99
|
def parse_line(self, line: str, index: int) -> ParsedMessage | None:
|
|
79
100
|
"""
|
|
80
101
|
Parse a single line from the transcript JSONL.
|
|
102
|
+
|
|
103
|
+
Gemini CLI uses type-based events in JSONL format:
|
|
104
|
+
- {"type":"init", "session_id":"...", "model":"...", "timestamp":"..."}
|
|
105
|
+
- {"type":"message", "role":"user"|"model", "content":"...", ...}
|
|
106
|
+
- {"type":"tool_use", "tool_name":"Bash", "parameters":{...}, ...}
|
|
107
|
+
- {"type":"tool_result", "tool_id":"...", "status":"success", "output":"...", ...}
|
|
108
|
+
- {"type":"result", "status":"success", "stats":{...}, ...}
|
|
81
109
|
"""
|
|
82
110
|
if not line.strip():
|
|
83
111
|
return None
|
|
@@ -95,45 +123,83 @@ class GeminiTranscriptParser:
|
|
|
95
123
|
except ValueError:
|
|
96
124
|
timestamp = datetime.now(UTC)
|
|
97
125
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
role = data.get("role")
|
|
101
|
-
content = data.get("content")
|
|
126
|
+
# Handle Gemini CLI's type-based event format
|
|
127
|
+
event_type = data.get("type")
|
|
102
128
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
# Initialize defaults
|
|
130
|
+
role: str | None = None
|
|
131
|
+
content: str | Any = ""
|
|
132
|
+
content_type = "text"
|
|
133
|
+
tool_name: str | None = None
|
|
134
|
+
tool_input: dict[str, Any] | None = None
|
|
135
|
+
tool_result: dict[str, Any] | None = None
|
|
107
136
|
|
|
108
|
-
if
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
if msg_type == "user":
|
|
112
|
-
role = "user"
|
|
113
|
-
elif msg_type == "model":
|
|
114
|
-
role = "assistant"
|
|
137
|
+
if event_type == "init":
|
|
138
|
+
# Session initialization event - skip or treat as system
|
|
139
|
+
return None
|
|
115
140
|
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
elif event_type == "message":
|
|
142
|
+
# Message event with role (user/model)
|
|
143
|
+
role = data.get("role")
|
|
144
|
+
content = data.get("content", "")
|
|
145
|
+
|
|
146
|
+
elif event_type in ("user", "model"):
|
|
147
|
+
# Role specified directly in type field
|
|
148
|
+
role = event_type
|
|
149
|
+
content = data.get("content", "")
|
|
150
|
+
|
|
151
|
+
elif event_type == "tool_use":
|
|
152
|
+
# Tool invocation event
|
|
118
153
|
role = "assistant"
|
|
154
|
+
content_type = "tool_use"
|
|
155
|
+
tool_name = data.get("tool_name") or data.get("function_name")
|
|
156
|
+
tool_input = data.get("parameters") or data.get("args") or data.get("input")
|
|
157
|
+
content = f"Tool call: {tool_name}"
|
|
158
|
+
|
|
159
|
+
elif event_type == "tool_result":
|
|
160
|
+
# Tool result event
|
|
161
|
+
role = "tool"
|
|
162
|
+
content_type = "tool_result"
|
|
163
|
+
tool_name = data.get("tool_name")
|
|
164
|
+
output = data.get("output") or data.get("result") or ""
|
|
165
|
+
tool_result = {"output": output, "status": data.get("status", "unknown")}
|
|
166
|
+
content = str(output)[:500] if output else "" # Truncate long outputs
|
|
167
|
+
|
|
168
|
+
elif event_type == "result":
|
|
169
|
+
# Final result event - skip
|
|
170
|
+
return None
|
|
119
171
|
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
|
|
172
|
+
else:
|
|
173
|
+
# Fallback: try legacy format with role/content at top level
|
|
174
|
+
role = data.get("role")
|
|
175
|
+
content = data.get("content")
|
|
176
|
+
|
|
177
|
+
# Check nested message structure
|
|
178
|
+
if not role and "message" in data:
|
|
179
|
+
msg = data["message"]
|
|
180
|
+
role = msg.get("role")
|
|
181
|
+
content = msg.get("content")
|
|
182
|
+
|
|
183
|
+
# Handle tool_result at top level (legacy)
|
|
184
|
+
if not role and "tool_result" in data:
|
|
123
185
|
role = "tool"
|
|
186
|
+
content_type = "tool_result"
|
|
124
187
|
content = str(data["tool_result"])
|
|
125
|
-
|
|
126
|
-
|
|
188
|
+
|
|
189
|
+
# If still no role in fallback, skip this line
|
|
190
|
+
if not role:
|
|
127
191
|
return None
|
|
128
192
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
193
|
+
# Validate role is set - skip lines with missing role
|
|
194
|
+
if not role:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
# Normalize role: model -> assistant
|
|
198
|
+
if role == "model":
|
|
199
|
+
role = "assistant"
|
|
134
200
|
|
|
201
|
+
# Normalize content - handle list content (rich parts)
|
|
135
202
|
if isinstance(content, list):
|
|
136
|
-
# Handle potential rich content
|
|
137
203
|
text_parts: list[str] = []
|
|
138
204
|
for part in content:
|
|
139
205
|
if isinstance(part, str):
|
|
@@ -141,7 +207,7 @@ class GeminiTranscriptParser:
|
|
|
141
207
|
elif isinstance(part, dict):
|
|
142
208
|
if "text" in part:
|
|
143
209
|
text_parts.append(str(part["text"]))
|
|
144
|
-
# Check for tool calls
|
|
210
|
+
# Check for tool calls embedded in content
|
|
145
211
|
if "functionCall" in part:
|
|
146
212
|
content_type = "tool_use"
|
|
147
213
|
tool_name = part["functionCall"].get("name")
|
gobby/skills/parser.py
CHANGED
|
@@ -64,6 +64,8 @@ class ParsedSkill:
|
|
|
64
64
|
scripts: List of script file paths (relative to skill dir)
|
|
65
65
|
references: List of reference file paths (relative to skill dir)
|
|
66
66
|
assets: List of asset file paths (relative to skill dir)
|
|
67
|
+
always_apply: Whether skill should always be injected at session start
|
|
68
|
+
injection_format: How to inject skill (summary, full, content)
|
|
67
69
|
"""
|
|
68
70
|
|
|
69
71
|
name: str
|
|
@@ -80,6 +82,8 @@ class ParsedSkill:
|
|
|
80
82
|
scripts: list[str] | None = None
|
|
81
83
|
references: list[str] | None = None
|
|
82
84
|
assets: list[str] | None = None
|
|
85
|
+
always_apply: bool = False
|
|
86
|
+
injection_format: str = "summary"
|
|
83
87
|
|
|
84
88
|
def get_category(self) -> str | None:
|
|
85
89
|
"""Get category from top-level or metadata.skillport.category."""
|
|
@@ -139,6 +143,8 @@ class ParsedSkill:
|
|
|
139
143
|
"scripts": self.scripts,
|
|
140
144
|
"references": self.references,
|
|
141
145
|
"assets": self.assets,
|
|
146
|
+
"always_apply": self.always_apply,
|
|
147
|
+
"injection_format": self.injection_format,
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
|
|
@@ -232,6 +238,7 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
232
238
|
# This allows both top-level and nested formats to work
|
|
233
239
|
top_level_always_apply = frontmatter.get("alwaysApply")
|
|
234
240
|
top_level_category = frontmatter.get("category")
|
|
241
|
+
top_level_injection_format = frontmatter.get("injectionFormat")
|
|
235
242
|
|
|
236
243
|
if top_level_always_apply is not None or top_level_category is not None:
|
|
237
244
|
if metadata is None:
|
|
@@ -251,6 +258,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
251
258
|
if version is not None:
|
|
252
259
|
version = str(version)
|
|
253
260
|
|
|
261
|
+
# Extract always_apply: check top-level first, then metadata.skillport.alwaysApply
|
|
262
|
+
always_apply = False
|
|
263
|
+
if top_level_always_apply is not None:
|
|
264
|
+
always_apply = bool(top_level_always_apply)
|
|
265
|
+
elif metadata and isinstance(metadata, dict):
|
|
266
|
+
skillport = metadata.get("skillport", {})
|
|
267
|
+
if isinstance(skillport, dict) and skillport.get("alwaysApply"):
|
|
268
|
+
always_apply = bool(skillport["alwaysApply"])
|
|
269
|
+
|
|
270
|
+
# Extract injection_format: check top-level first, default to "summary"
|
|
271
|
+
injection_format = "summary"
|
|
272
|
+
if top_level_injection_format is not None:
|
|
273
|
+
injection_format = str(top_level_injection_format)
|
|
274
|
+
|
|
254
275
|
return ParsedSkill(
|
|
255
276
|
name=name,
|
|
256
277
|
description=description,
|
|
@@ -261,6 +282,8 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
261
282
|
allowed_tools=allowed_tools,
|
|
262
283
|
metadata=metadata,
|
|
263
284
|
source_path=source_path,
|
|
285
|
+
always_apply=always_apply,
|
|
286
|
+
injection_format=injection_format,
|
|
264
287
|
)
|
|
265
288
|
|
|
266
289
|
|
gobby/skills/sync.py
CHANGED
|
@@ -23,10 +23,9 @@ def get_bundled_skills_path() -> Path:
|
|
|
23
23
|
Returns:
|
|
24
24
|
Path to src/gobby/install/shared/skills/
|
|
25
25
|
"""
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return Path(__file__).parent.parent / "install" / "shared" / "skills"
|
|
26
|
+
from gobby.paths import get_install_dir
|
|
27
|
+
|
|
28
|
+
return get_install_dir() / "shared" / "skills"
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
|
|
@@ -101,6 +100,8 @@ def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
|
|
|
101
100
|
source_ref=None,
|
|
102
101
|
project_id=None, # Global scope
|
|
103
102
|
enabled=True,
|
|
103
|
+
always_apply=parsed.always_apply,
|
|
104
|
+
injection_format=parsed.injection_format,
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
logger.info(f"Synced bundled skill: {parsed.name}")
|
gobby/storage/artifacts.py
CHANGED
|
@@ -283,3 +283,22 @@ class LocalArtifactManager:
|
|
|
283
283
|
|
|
284
284
|
rows = self.db.fetchall(sql, tuple(params))
|
|
285
285
|
return [Artifact.from_row(row) for row in rows]
|
|
286
|
+
|
|
287
|
+
def count_artifacts(self, session_id: str | None = None) -> int:
|
|
288
|
+
"""Count total artifacts, optionally filtered by session.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
session_id: Optional session ID to filter by
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Total artifact count
|
|
295
|
+
"""
|
|
296
|
+
if session_id:
|
|
297
|
+
row = self.db.fetchone(
|
|
298
|
+
"SELECT COUNT(*) FROM session_artifacts WHERE session_id = ?",
|
|
299
|
+
(session_id,),
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
row = self.db.fetchone("SELECT COUNT(*) FROM session_artifacts")
|
|
303
|
+
|
|
304
|
+
return row[0] if row else 0
|