gobby 0.2.6__py3-none-any.whl → 0.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- 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 +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- 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/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
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
|
|
@@ -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/lifecycle.py
CHANGED
|
@@ -11,7 +11,7 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from gobby.config.
|
|
14
|
+
from gobby.config.sessions import SessionLifecycleConfig
|
|
15
15
|
from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
|
|
16
16
|
from gobby.sessions.transcripts.codex import CodexTranscriptParser
|
|
17
17
|
from gobby.sessions.transcripts.gemini import GeminiTranscriptParser
|
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})")
|
gobby/sessions/processor.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from gobby.servers.websocket import WebSocketServer
|
|
15
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
15
16
|
|
|
16
17
|
from gobby.sessions.transcripts import get_parser
|
|
17
18
|
from gobby.sessions.transcripts.base import TranscriptParser
|
|
@@ -36,11 +37,13 @@ class SessionMessageProcessor:
|
|
|
36
37
|
db: DatabaseProtocol,
|
|
37
38
|
poll_interval: float = 2.0,
|
|
38
39
|
websocket_server: "WebSocketServer | None" = None,
|
|
40
|
+
session_manager: "LocalSessionManager | None" = None,
|
|
39
41
|
):
|
|
40
42
|
self.db = db
|
|
41
43
|
self.message_manager = LocalSessionMessageManager(db)
|
|
42
44
|
self.poll_interval = poll_interval
|
|
43
45
|
self.websocket_server: WebSocketServer | None = websocket_server
|
|
46
|
+
self.session_manager: LocalSessionManager | None = session_manager
|
|
44
47
|
|
|
45
48
|
# Track active sessions: session_id -> transcript_path
|
|
46
49
|
self._active_sessions: dict[str, str] = {}
|
|
@@ -196,6 +199,13 @@ class SessionMessageProcessor:
|
|
|
196
199
|
# Store messages
|
|
197
200
|
await self.message_manager.store_messages(session_id, parsed_messages)
|
|
198
201
|
|
|
202
|
+
# Extract and store model from parsed messages (if present)
|
|
203
|
+
if self.session_manager:
|
|
204
|
+
for msg in parsed_messages:
|
|
205
|
+
if msg.model:
|
|
206
|
+
self.session_manager.update_model(session_id, msg.model)
|
|
207
|
+
break # Only need the first model found
|
|
208
|
+
|
|
199
209
|
# Broadcast new messages
|
|
200
210
|
if self.websocket_server:
|
|
201
211
|
for msg in parsed_messages:
|
|
@@ -355,6 +355,8 @@ class ClaudeTranscriptParser:
|
|
|
355
355
|
# Skip unknown message types (e.g., 'progress', 'error' internal events)
|
|
356
356
|
return None
|
|
357
357
|
|
|
358
|
+
usage, model = self._extract_usage(data)
|
|
359
|
+
|
|
358
360
|
return ParsedMessage(
|
|
359
361
|
index=index,
|
|
360
362
|
role=role,
|
|
@@ -365,12 +367,20 @@ class ClaudeTranscriptParser:
|
|
|
365
367
|
tool_result=tool_result,
|
|
366
368
|
timestamp=timestamp,
|
|
367
369
|
raw_json=data,
|
|
368
|
-
usage=
|
|
370
|
+
usage=usage,
|
|
369
371
|
tool_use_id=tool_use_id,
|
|
372
|
+
model=model,
|
|
370
373
|
)
|
|
371
374
|
|
|
372
|
-
def _extract_usage(self, data: dict[str, Any]) -> TokenUsage | None:
|
|
373
|
-
"""Extract token usage from message data.
|
|
375
|
+
def _extract_usage(self, data: dict[str, Any]) -> tuple[TokenUsage | None, str | None]:
|
|
376
|
+
"""Extract token usage and model from message data.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Tuple of (TokenUsage | None, model string | None)
|
|
380
|
+
"""
|
|
381
|
+
# Extract model from message object
|
|
382
|
+
model = data.get("message", {}).get("model")
|
|
383
|
+
|
|
374
384
|
# Check for top-level usage field (some formats)
|
|
375
385
|
usage_data = data.get("usage")
|
|
376
386
|
|
|
@@ -379,7 +389,7 @@ class ClaudeTranscriptParser:
|
|
|
379
389
|
usage_data = data.get("message", {}).get("usage")
|
|
380
390
|
|
|
381
391
|
if not usage_data:
|
|
382
|
-
return None
|
|
392
|
+
return None, model
|
|
383
393
|
|
|
384
394
|
# Use explicit presence checks to handle 0 correctly
|
|
385
395
|
input_tokens = (
|
|
@@ -413,7 +423,7 @@ class ClaudeTranscriptParser:
|
|
|
413
423
|
cache_creation_tokens=cache_creation_tokens,
|
|
414
424
|
cache_read_tokens=cache_read_tokens,
|
|
415
425
|
total_cost_usd=total_cost_usd,
|
|
416
|
-
)
|
|
426
|
+
), model
|
|
417
427
|
|
|
418
428
|
def parse_lines(self, lines: list[str], start_index: int = 0) -> list[ParsedMessage]:
|
|
419
429
|
"""
|
|
@@ -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
|
@@ -82,9 +82,14 @@ class ParsedSkill:
|
|
|
82
82
|
assets: list[str] | None = None
|
|
83
83
|
|
|
84
84
|
def get_category(self) -> str | None:
|
|
85
|
-
"""Get category from metadata.skillport.category."""
|
|
85
|
+
"""Get category from top-level or metadata.skillport.category."""
|
|
86
86
|
if not self.metadata:
|
|
87
87
|
return None
|
|
88
|
+
# Check top-level first (from frontmatter)
|
|
89
|
+
result = self.metadata.get("category")
|
|
90
|
+
if result is not None:
|
|
91
|
+
return str(result)
|
|
92
|
+
# Fall back to nested skillport.category
|
|
88
93
|
skillport = self.metadata.get("skillport", {})
|
|
89
94
|
result = skillport.get("category")
|
|
90
95
|
return str(result) if result is not None else None
|
|
@@ -102,9 +107,18 @@ class ParsedSkill:
|
|
|
102
107
|
return []
|
|
103
108
|
|
|
104
109
|
def is_always_apply(self) -> bool:
|
|
105
|
-
"""Check if this is a core skill (alwaysApply=true).
|
|
110
|
+
"""Check if this is a core skill (alwaysApply=true).
|
|
111
|
+
|
|
112
|
+
Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
|
|
113
|
+
Top-level takes precedence.
|
|
114
|
+
"""
|
|
106
115
|
if not self.metadata:
|
|
107
116
|
return False
|
|
117
|
+
# Check top-level first (from frontmatter)
|
|
118
|
+
top_level = self.metadata.get("alwaysApply")
|
|
119
|
+
if top_level is not None:
|
|
120
|
+
return bool(top_level)
|
|
121
|
+
# Fall back to nested skillport.alwaysApply
|
|
108
122
|
skillport = self.metadata.get("skillport", {})
|
|
109
123
|
return bool(skillport.get("alwaysApply", False))
|
|
110
124
|
|
|
@@ -214,6 +228,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
|
|
|
214
228
|
# Extract metadata (may contain version, skillport, gobby namespaces)
|
|
215
229
|
metadata = frontmatter.get("metadata")
|
|
216
230
|
|
|
231
|
+
# Handle top-level alwaysApply and category by including them in metadata
|
|
232
|
+
# This allows both top-level and nested formats to work
|
|
233
|
+
top_level_always_apply = frontmatter.get("alwaysApply")
|
|
234
|
+
top_level_category = frontmatter.get("category")
|
|
235
|
+
|
|
236
|
+
if top_level_always_apply is not None or top_level_category is not None:
|
|
237
|
+
if metadata is None:
|
|
238
|
+
metadata = {}
|
|
239
|
+
# Store at top level of metadata (not nested in skillport)
|
|
240
|
+
if top_level_always_apply is not None:
|
|
241
|
+
metadata["alwaysApply"] = top_level_always_apply
|
|
242
|
+
if top_level_category is not None:
|
|
243
|
+
metadata["category"] = top_level_category
|
|
244
|
+
|
|
217
245
|
# Version can be at top level or in metadata
|
|
218
246
|
version = frontmatter.get("version")
|
|
219
247
|
if version is None and metadata and isinstance(metadata, dict):
|
gobby/storage/database.py
CHANGED
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import weakref
|
|
12
12
|
from collections.abc import Iterator
|
|
13
13
|
from contextlib import AbstractContextManager, contextmanager
|
|
14
|
-
from datetime import date, datetime
|
|
14
|
+
from datetime import UTC, date, datetime
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
17
17
|
|
|
@@ -21,6 +21,9 @@ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
|
21
21
|
|
|
22
22
|
def _adapt_datetime(val: datetime) -> str:
|
|
23
23
|
"""Adapt datetime to ISO format string for SQLite storage."""
|
|
24
|
+
# If naive datetime, assume UTC and add timezone info for RFC3339 compliance
|
|
25
|
+
if val.tzinfo is None:
|
|
26
|
+
val = val.replace(tzinfo=UTC)
|
|
24
27
|
return val.isoformat()
|
|
25
28
|
|
|
26
29
|
|
|
@@ -31,7 +34,11 @@ def _adapt_date(val: date) -> str:
|
|
|
31
34
|
|
|
32
35
|
def _convert_datetime(val: bytes) -> datetime:
|
|
33
36
|
"""Convert SQLite datetime string back to datetime object."""
|
|
34
|
-
|
|
37
|
+
dt = datetime.fromisoformat(val.decode())
|
|
38
|
+
# Ensure timezone-aware (treat naive as UTC) for consistency
|
|
39
|
+
if dt.tzinfo is None:
|
|
40
|
+
dt = dt.replace(tzinfo=UTC)
|
|
41
|
+
return dt
|
|
35
42
|
|
|
36
43
|
|
|
37
44
|
def _convert_date(val: bytes) -> date:
|
gobby/storage/memories.py
CHANGED
|
@@ -197,40 +197,51 @@ class LocalMemoryManager:
|
|
|
197
197
|
return row is not None
|
|
198
198
|
|
|
199
199
|
def content_exists(self, content: str, project_id: str | None = None) -> bool:
|
|
200
|
-
"""Check if a memory with identical content already exists.
|
|
201
|
-
# Normalize content same way as ID generation in create_memory
|
|
202
|
-
normalized_content = content.strip()
|
|
203
|
-
project_str = project_id if project_id else ""
|
|
204
|
-
# Use delimiter to match create_memory ID generation
|
|
205
|
-
memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
|
|
200
|
+
"""Check if a memory with identical content already exists.
|
|
206
201
|
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
Uses global deduplication - checks if any memory has the same content,
|
|
203
|
+
regardless of project_id. This prevents duplicates when the same content
|
|
204
|
+
is stored with different or NULL project_ids.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
content: The content to check for
|
|
208
|
+
project_id: Ignored (kept for backward compatibility)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if a memory with identical content exists
|
|
212
|
+
"""
|
|
213
|
+
# Global deduplication: check by content directly, ignoring project_id
|
|
214
|
+
# This fixes the duplicate issue where same content + different project_id
|
|
215
|
+
# would create different memory IDs
|
|
216
|
+
normalized_content = content.strip()
|
|
217
|
+
row = self.db.fetchone(
|
|
218
|
+
"SELECT 1 FROM memories WHERE content = ? LIMIT 1",
|
|
219
|
+
(normalized_content,),
|
|
220
|
+
)
|
|
209
221
|
return row is not None
|
|
210
222
|
|
|
211
223
|
def get_memory_by_content(self, content: str, project_id: str | None = None) -> Memory | None:
|
|
212
|
-
"""Get a memory by its exact content
|
|
224
|
+
"""Get a memory by its exact content.
|
|
213
225
|
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
Uses global lookup - finds any memory with matching content regardless
|
|
227
|
+
of project_id. This matches the behavior of content_exists().
|
|
216
228
|
|
|
217
229
|
Args:
|
|
218
230
|
content: The exact content to look up (will be normalized)
|
|
219
|
-
project_id:
|
|
231
|
+
project_id: Ignored (kept for backward compatibility)
|
|
220
232
|
|
|
221
233
|
Returns:
|
|
222
234
|
The Memory object if found, None otherwise
|
|
223
235
|
"""
|
|
224
|
-
#
|
|
236
|
+
# Global lookup: find by content directly, ignoring project_id
|
|
225
237
|
normalized_content = content.strip()
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
return None
|
|
238
|
+
row = self.db.fetchone(
|
|
239
|
+
"SELECT * FROM memories WHERE content = ? LIMIT 1",
|
|
240
|
+
(normalized_content,),
|
|
241
|
+
)
|
|
242
|
+
if row:
|
|
243
|
+
return Memory.from_row(row)
|
|
244
|
+
return None
|
|
234
245
|
|
|
235
246
|
def update_memory(
|
|
236
247
|
self,
|