draft-board 0.1.0-beta.0
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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Cursor agent JSON log normalizer.
|
|
2
|
+
|
|
3
|
+
Parses cursor-agent's stream-json output and converts it to normalized
|
|
4
|
+
log entries for display in the UI. Modeled after vibe-kanban's Rust implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NormalizedEntryType(StrEnum):
|
|
17
|
+
"""Types of normalized log entries."""
|
|
18
|
+
|
|
19
|
+
SYSTEM_MESSAGE = "system_message"
|
|
20
|
+
ASSISTANT_MESSAGE = "assistant_message"
|
|
21
|
+
THINKING = "thinking"
|
|
22
|
+
TOOL_USE = "tool_use"
|
|
23
|
+
ERROR_MESSAGE = "error_message"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolStatus(StrEnum):
|
|
27
|
+
"""Status of a tool call."""
|
|
28
|
+
|
|
29
|
+
CREATED = "created"
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
COMPLETED = "completed"
|
|
32
|
+
FAILED = "failed"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolActionType(StrEnum):
|
|
36
|
+
"""Type of action a tool performs."""
|
|
37
|
+
|
|
38
|
+
READ_FILE = "read_file"
|
|
39
|
+
WRITE_FILE = "write_file"
|
|
40
|
+
EDIT_FILE = "edit_file"
|
|
41
|
+
LIST_DIR = "list_dir"
|
|
42
|
+
SEARCH = "search"
|
|
43
|
+
SHELL = "shell"
|
|
44
|
+
UNKNOWN = "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NormalizedEntry:
|
|
49
|
+
"""A normalized log entry for display."""
|
|
50
|
+
|
|
51
|
+
entry_type: NormalizedEntryType
|
|
52
|
+
content: str
|
|
53
|
+
sequence: int = 0
|
|
54
|
+
tool_name: str | None = None
|
|
55
|
+
action_type: ToolActionType | None = None
|
|
56
|
+
tool_status: ToolStatus | None = None
|
|
57
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CursorLogNormalizer:
|
|
61
|
+
"""Normalizes cursor-agent JSON streaming output.
|
|
62
|
+
|
|
63
|
+
Handles message coalescing (combining streaming deltas into complete messages)
|
|
64
|
+
and converts various JSON event types into normalized entries.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, worktree_path: str = ""):
|
|
68
|
+
self.worktree_path = worktree_path
|
|
69
|
+
self.sequence = 0
|
|
70
|
+
|
|
71
|
+
# Coalescing state
|
|
72
|
+
self.current_thinking_buffer = ""
|
|
73
|
+
self.current_thinking_sequence: int | None = None
|
|
74
|
+
self.current_assistant_buffer = ""
|
|
75
|
+
self.current_assistant_sequence: int | None = None
|
|
76
|
+
|
|
77
|
+
# Track tool calls by call_id
|
|
78
|
+
self.tool_call_sequences: dict[str, int] = {}
|
|
79
|
+
|
|
80
|
+
# Model info
|
|
81
|
+
self.model_reported = False
|
|
82
|
+
self.session_id_reported = False
|
|
83
|
+
self.session_id: str | None = None
|
|
84
|
+
|
|
85
|
+
def _next_sequence(self) -> int:
|
|
86
|
+
"""Get next sequence number."""
|
|
87
|
+
seq = self.sequence
|
|
88
|
+
self.sequence += 1
|
|
89
|
+
return seq
|
|
90
|
+
|
|
91
|
+
def _strip_worktree_prefix(self, path: str) -> str:
|
|
92
|
+
"""Strip worktree path prefix from file paths for cleaner display."""
|
|
93
|
+
if self.worktree_path and path.startswith(self.worktree_path):
|
|
94
|
+
return path[len(self.worktree_path) :].lstrip("/")
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
def process_line(self, line: str) -> list[NormalizedEntry]:
|
|
98
|
+
"""Process a single JSON line and return normalized entries.
|
|
99
|
+
|
|
100
|
+
Returns a list because some lines may produce multiple entries
|
|
101
|
+
(e.g., flushing buffers when switching message types).
|
|
102
|
+
|
|
103
|
+
Supports both Cursor Agent stream-json format AND Claude CLI
|
|
104
|
+
stream-json format (with --output-format stream-json --verbose
|
|
105
|
+
--include-partial-messages).
|
|
106
|
+
"""
|
|
107
|
+
if not line.strip():
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
data = json.loads(line)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
data = None
|
|
114
|
+
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
# Non-JSON line or JSON non-object (string, array, etc.) - treat as system message
|
|
117
|
+
stripped = line.strip()
|
|
118
|
+
if stripped:
|
|
119
|
+
# Filter out [DEBUG] lines — they are internal diagnostics
|
|
120
|
+
if stripped.startswith("[DEBUG]"):
|
|
121
|
+
return []
|
|
122
|
+
return [
|
|
123
|
+
NormalizedEntry(
|
|
124
|
+
entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
|
|
125
|
+
content=stripped,
|
|
126
|
+
sequence=self._next_sequence(),
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
entries: list[NormalizedEntry] = []
|
|
132
|
+
msg_type = data.get("type", "")
|
|
133
|
+
|
|
134
|
+
# Extract session_id if present
|
|
135
|
+
if not self.session_id_reported:
|
|
136
|
+
session_id = data.get("session_id")
|
|
137
|
+
if session_id:
|
|
138
|
+
self.session_id = session_id
|
|
139
|
+
self.session_id_reported = True
|
|
140
|
+
|
|
141
|
+
# Check if we need to flush buffers (switching message types)
|
|
142
|
+
# stream_event with text deltas counts as "assistant-like"
|
|
143
|
+
is_thinking = msg_type == "thinking"
|
|
144
|
+
is_assistant = msg_type == "assistant"
|
|
145
|
+
is_stream_delta = msg_type == "stream_event"
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
not is_thinking
|
|
149
|
+
and not is_stream_delta
|
|
150
|
+
and self.current_thinking_sequence is not None
|
|
151
|
+
):
|
|
152
|
+
# Flush thinking buffer
|
|
153
|
+
self.current_thinking_sequence = None
|
|
154
|
+
self.current_thinking_buffer = ""
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
not is_assistant
|
|
158
|
+
and not is_stream_delta
|
|
159
|
+
and self.current_assistant_sequence is not None
|
|
160
|
+
):
|
|
161
|
+
# Flush assistant buffer
|
|
162
|
+
self.current_assistant_sequence = None
|
|
163
|
+
self.current_assistant_buffer = ""
|
|
164
|
+
|
|
165
|
+
# Process by message type
|
|
166
|
+
if msg_type == "system":
|
|
167
|
+
entries.extend(self._process_system(data))
|
|
168
|
+
elif msg_type == "user":
|
|
169
|
+
# Skip user messages (just the prompt echo)
|
|
170
|
+
pass
|
|
171
|
+
elif msg_type == "assistant":
|
|
172
|
+
entries.extend(self._process_assistant(data))
|
|
173
|
+
elif msg_type == "thinking":
|
|
174
|
+
entries.extend(self._process_thinking(data))
|
|
175
|
+
elif msg_type == "tool_call":
|
|
176
|
+
entries.extend(self._process_tool_call(data))
|
|
177
|
+
elif msg_type == "result":
|
|
178
|
+
entries.extend(self._process_result(data))
|
|
179
|
+
elif msg_type == "stream_event":
|
|
180
|
+
# Claude CLI streaming events (--include-partial-messages)
|
|
181
|
+
entries.extend(self._process_stream_event(data))
|
|
182
|
+
elif msg_type == "rate_limit_event":
|
|
183
|
+
# Skip rate limit events — not useful for display
|
|
184
|
+
pass
|
|
185
|
+
else:
|
|
186
|
+
# Unknown type - log as system message
|
|
187
|
+
entries.append(
|
|
188
|
+
NormalizedEntry(
|
|
189
|
+
entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
|
|
190
|
+
content=f"[{msg_type}] {json.dumps(data)}",
|
|
191
|
+
sequence=self._next_sequence(),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return entries
|
|
196
|
+
|
|
197
|
+
def _process_system(self, data: dict) -> list[NormalizedEntry]:
|
|
198
|
+
"""Process system initialization message."""
|
|
199
|
+
entries = []
|
|
200
|
+
|
|
201
|
+
if not self.model_reported:
|
|
202
|
+
model = data.get("model")
|
|
203
|
+
if model:
|
|
204
|
+
entries.append(
|
|
205
|
+
NormalizedEntry(
|
|
206
|
+
entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
|
|
207
|
+
content=f"🤖 Model: {model}",
|
|
208
|
+
sequence=self._next_sequence(),
|
|
209
|
+
metadata={"model": model},
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
self.model_reported = True
|
|
213
|
+
|
|
214
|
+
return entries
|
|
215
|
+
|
|
216
|
+
def _process_assistant(self, data: dict) -> list[NormalizedEntry]:
|
|
217
|
+
"""Process assistant message (may be streaming chunks)."""
|
|
218
|
+
message = data.get("message", {})
|
|
219
|
+
content_parts = message.get("content", [])
|
|
220
|
+
|
|
221
|
+
# Extract text from content parts
|
|
222
|
+
text = ""
|
|
223
|
+
for part in content_parts:
|
|
224
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
225
|
+
text += part.get("text", "")
|
|
226
|
+
elif isinstance(part, str):
|
|
227
|
+
text += part
|
|
228
|
+
|
|
229
|
+
if not text:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
# Coalesce streaming messages
|
|
233
|
+
self.current_assistant_buffer += text
|
|
234
|
+
|
|
235
|
+
if self.current_assistant_sequence is None:
|
|
236
|
+
self.current_assistant_sequence = self._next_sequence()
|
|
237
|
+
|
|
238
|
+
return [
|
|
239
|
+
NormalizedEntry(
|
|
240
|
+
entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
|
|
241
|
+
content=self.current_assistant_buffer,
|
|
242
|
+
sequence=self.current_assistant_sequence,
|
|
243
|
+
)
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
def _process_thinking(self, data: dict) -> list[NormalizedEntry]:
|
|
247
|
+
"""Process thinking message (streaming deltas)."""
|
|
248
|
+
subtype = data.get("subtype", "")
|
|
249
|
+
|
|
250
|
+
if subtype == "delta":
|
|
251
|
+
text = data.get("text", "")
|
|
252
|
+
if text:
|
|
253
|
+
self.current_thinking_buffer += text
|
|
254
|
+
|
|
255
|
+
if self.current_thinking_sequence is None:
|
|
256
|
+
self.current_thinking_sequence = self._next_sequence()
|
|
257
|
+
|
|
258
|
+
return [
|
|
259
|
+
NormalizedEntry(
|
|
260
|
+
entry_type=NormalizedEntryType.THINKING,
|
|
261
|
+
content=self.current_thinking_buffer,
|
|
262
|
+
sequence=self.current_thinking_sequence,
|
|
263
|
+
metadata={"collapsed": True},
|
|
264
|
+
)
|
|
265
|
+
]
|
|
266
|
+
elif subtype == "completed":
|
|
267
|
+
# Thinking completed - keep current buffer
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
def _process_tool_call(self, data: dict) -> list[NormalizedEntry]:
|
|
273
|
+
"""Process tool call start/complete."""
|
|
274
|
+
subtype = data.get("subtype", "")
|
|
275
|
+
call_id = data.get("call_id", "")
|
|
276
|
+
tool_call = data.get("tool_call", {})
|
|
277
|
+
|
|
278
|
+
# Determine tool name and action type
|
|
279
|
+
tool_name, action_type, content = self._parse_tool_call(tool_call)
|
|
280
|
+
|
|
281
|
+
if subtype == "started":
|
|
282
|
+
seq = self._next_sequence()
|
|
283
|
+
if call_id:
|
|
284
|
+
self.tool_call_sequences[call_id] = seq
|
|
285
|
+
|
|
286
|
+
return [
|
|
287
|
+
NormalizedEntry(
|
|
288
|
+
entry_type=NormalizedEntryType.TOOL_USE,
|
|
289
|
+
content=content,
|
|
290
|
+
sequence=seq,
|
|
291
|
+
tool_name=tool_name,
|
|
292
|
+
action_type=action_type,
|
|
293
|
+
tool_status=ToolStatus.CREATED,
|
|
294
|
+
)
|
|
295
|
+
]
|
|
296
|
+
elif subtype == "completed":
|
|
297
|
+
# Update existing entry with result
|
|
298
|
+
seq = self.tool_call_sequences.get(call_id, self._next_sequence())
|
|
299
|
+
|
|
300
|
+
# Extract result info
|
|
301
|
+
result_content = self._extract_tool_result(tool_call)
|
|
302
|
+
if result_content:
|
|
303
|
+
content = f"{content}\n→ {result_content}"
|
|
304
|
+
|
|
305
|
+
return [
|
|
306
|
+
NormalizedEntry(
|
|
307
|
+
entry_type=NormalizedEntryType.TOOL_USE,
|
|
308
|
+
content=content,
|
|
309
|
+
sequence=seq,
|
|
310
|
+
tool_name=tool_name,
|
|
311
|
+
action_type=action_type,
|
|
312
|
+
tool_status=ToolStatus.COMPLETED,
|
|
313
|
+
)
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
def _parse_tool_call(self, tool_call: dict) -> tuple[str, ToolActionType, str]:
|
|
319
|
+
"""Parse tool call data to extract name, action type, and display content."""
|
|
320
|
+
# Check various tool call formats
|
|
321
|
+
if "readToolCall" in tool_call:
|
|
322
|
+
args = tool_call["readToolCall"].get("args", {})
|
|
323
|
+
path = self._strip_worktree_prefix(args.get("path", "unknown"))
|
|
324
|
+
return "read_file", ToolActionType.READ_FILE, f"📖 Read: {path}"
|
|
325
|
+
|
|
326
|
+
if "editToolCall" in tool_call:
|
|
327
|
+
args = tool_call["editToolCall"].get("args", {})
|
|
328
|
+
path = self._strip_worktree_prefix(args.get("path", "unknown"))
|
|
329
|
+
return "edit_file", ToolActionType.EDIT_FILE, f"✏️ Edit: {path}"
|
|
330
|
+
|
|
331
|
+
if "lsToolCall" in tool_call:
|
|
332
|
+
args = tool_call["lsToolCall"].get("args", {})
|
|
333
|
+
path = self._strip_worktree_prefix(args.get("path", "."))
|
|
334
|
+
return "list_dir", ToolActionType.LIST_DIR, f"📁 List: {path}"
|
|
335
|
+
|
|
336
|
+
if "globToolCall" in tool_call:
|
|
337
|
+
args = tool_call["globToolCall"].get("args", {})
|
|
338
|
+
pattern = args.get("globPattern", "*")
|
|
339
|
+
return "glob", ToolActionType.SEARCH, f"🔍 Glob: {pattern}"
|
|
340
|
+
|
|
341
|
+
if "grepToolCall" in tool_call:
|
|
342
|
+
args = tool_call["grepToolCall"].get("args", {})
|
|
343
|
+
pattern = args.get("pattern", "")
|
|
344
|
+
return "grep", ToolActionType.SEARCH, f"🔍 Grep: {pattern}"
|
|
345
|
+
|
|
346
|
+
if "shellToolCall" in tool_call:
|
|
347
|
+
args = tool_call["shellToolCall"].get("args", {})
|
|
348
|
+
command = args.get("command", "")
|
|
349
|
+
return "shell", ToolActionType.SHELL, f"💻 Shell: {command}"
|
|
350
|
+
|
|
351
|
+
# Generic/unknown tool
|
|
352
|
+
return (
|
|
353
|
+
"unknown",
|
|
354
|
+
ToolActionType.UNKNOWN,
|
|
355
|
+
f"🔧 Tool call: {json.dumps(tool_call)[:100]}",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _extract_tool_result(self, tool_call: dict) -> str:
|
|
359
|
+
"""Extract a summary of tool result for display."""
|
|
360
|
+
for key in [
|
|
361
|
+
"readToolCall",
|
|
362
|
+
"editToolCall",
|
|
363
|
+
"lsToolCall",
|
|
364
|
+
"globToolCall",
|
|
365
|
+
"grepToolCall",
|
|
366
|
+
"shellToolCall",
|
|
367
|
+
]:
|
|
368
|
+
if key in tool_call:
|
|
369
|
+
result = tool_call[key].get("result", {})
|
|
370
|
+
if "success" in result:
|
|
371
|
+
success = result["success"]
|
|
372
|
+
if key == "editToolCall":
|
|
373
|
+
lines_added = success.get("linesAdded", 0)
|
|
374
|
+
lines_removed = success.get("linesRemoved", 0)
|
|
375
|
+
return f"+{lines_added} -{lines_removed} lines"
|
|
376
|
+
elif key == "shellToolCall":
|
|
377
|
+
exit_code = success.get("exitCode", 0)
|
|
378
|
+
return f"exit code: {exit_code}"
|
|
379
|
+
elif key == "globToolCall":
|
|
380
|
+
total = success.get("totalFiles", 0)
|
|
381
|
+
return f"{total} files"
|
|
382
|
+
elif "error" in result:
|
|
383
|
+
return f"❌ {result['error'][:50]}"
|
|
384
|
+
return ""
|
|
385
|
+
|
|
386
|
+
def _process_stream_event(self, data: dict) -> list[NormalizedEntry]:
|
|
387
|
+
"""Process Claude CLI stream events (from --include-partial-messages).
|
|
388
|
+
|
|
389
|
+
These are Anthropic API streaming events wrapped in Claude CLI's format:
|
|
390
|
+
- content_block_delta with text_delta → streaming assistant text
|
|
391
|
+
- content_block_delta with thinking_delta → streaming thinking text
|
|
392
|
+
- message_start, content_block_start/stop, message_delta/stop → lifecycle events (skip)
|
|
393
|
+
"""
|
|
394
|
+
event = data.get("event", {})
|
|
395
|
+
event_type = event.get("type", "")
|
|
396
|
+
|
|
397
|
+
if event_type == "content_block_delta":
|
|
398
|
+
delta = event.get("delta", {})
|
|
399
|
+
delta_type = delta.get("type", "")
|
|
400
|
+
|
|
401
|
+
if delta_type == "text_delta":
|
|
402
|
+
text = delta.get("text", "")
|
|
403
|
+
if text:
|
|
404
|
+
self.current_assistant_buffer += text
|
|
405
|
+
if self.current_assistant_sequence is None:
|
|
406
|
+
self.current_assistant_sequence = self._next_sequence()
|
|
407
|
+
return [
|
|
408
|
+
NormalizedEntry(
|
|
409
|
+
entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
|
|
410
|
+
content=self.current_assistant_buffer,
|
|
411
|
+
sequence=self.current_assistant_sequence,
|
|
412
|
+
)
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
elif delta_type == "thinking_delta":
|
|
416
|
+
text = delta.get("thinking", "")
|
|
417
|
+
if text:
|
|
418
|
+
self.current_thinking_buffer += text
|
|
419
|
+
if self.current_thinking_sequence is None:
|
|
420
|
+
self.current_thinking_sequence = self._next_sequence()
|
|
421
|
+
return [
|
|
422
|
+
NormalizedEntry(
|
|
423
|
+
entry_type=NormalizedEntryType.THINKING,
|
|
424
|
+
content=self.current_thinking_buffer,
|
|
425
|
+
sequence=self.current_thinking_sequence,
|
|
426
|
+
metadata={"collapsed": True},
|
|
427
|
+
)
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
elif event_type == "content_block_start":
|
|
431
|
+
# Check if this is a thinking block start
|
|
432
|
+
block = event.get("content_block", {})
|
|
433
|
+
if block.get("type") == "thinking":
|
|
434
|
+
# Reset thinking buffer for a new thinking block
|
|
435
|
+
self.current_thinking_buffer = ""
|
|
436
|
+
self.current_thinking_sequence = None
|
|
437
|
+
|
|
438
|
+
# Skip other lifecycle events (message_start, content_block_stop, etc.)
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
def _process_result(self, data: dict) -> list[NormalizedEntry]:
|
|
442
|
+
"""Process final result message.
|
|
443
|
+
|
|
444
|
+
Handles both Cursor Agent format (result is dict with 'outcome')
|
|
445
|
+
and Claude CLI format (result is the response text string).
|
|
446
|
+
"""
|
|
447
|
+
result = data.get("result", {})
|
|
448
|
+
subtype = data.get("subtype", "")
|
|
449
|
+
is_error = data.get("is_error", False)
|
|
450
|
+
|
|
451
|
+
if isinstance(result, str):
|
|
452
|
+
# Claude CLI format: result is the text response
|
|
453
|
+
if is_error:
|
|
454
|
+
return [
|
|
455
|
+
NormalizedEntry(
|
|
456
|
+
entry_type=NormalizedEntryType.ERROR_MESSAGE,
|
|
457
|
+
content=f"Agent error: {result[:200]}",
|
|
458
|
+
sequence=self._next_sequence(),
|
|
459
|
+
metadata={"outcome": subtype or "error"},
|
|
460
|
+
)
|
|
461
|
+
]
|
|
462
|
+
else:
|
|
463
|
+
return [
|
|
464
|
+
NormalizedEntry(
|
|
465
|
+
entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
|
|
466
|
+
content=f"✅ Agent finished ({subtype or 'success'})",
|
|
467
|
+
sequence=self._next_sequence(),
|
|
468
|
+
metadata={"outcome": subtype or "success"},
|
|
469
|
+
)
|
|
470
|
+
]
|
|
471
|
+
elif isinstance(result, dict):
|
|
472
|
+
outcome = result.get("outcome", "unknown")
|
|
473
|
+
return [
|
|
474
|
+
NormalizedEntry(
|
|
475
|
+
entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
|
|
476
|
+
content=f"✅ Completed: {outcome}",
|
|
477
|
+
sequence=self._next_sequence(),
|
|
478
|
+
metadata={"outcome": outcome},
|
|
479
|
+
)
|
|
480
|
+
]
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
def finalize(self) -> list[NormalizedEntry]:
|
|
484
|
+
"""Finalize and return any remaining buffered entries."""
|
|
485
|
+
entries = []
|
|
486
|
+
|
|
487
|
+
if self.current_thinking_buffer and self.current_thinking_sequence is not None:
|
|
488
|
+
entries.append(
|
|
489
|
+
NormalizedEntry(
|
|
490
|
+
entry_type=NormalizedEntryType.THINKING,
|
|
491
|
+
content=self.current_thinking_buffer,
|
|
492
|
+
sequence=self.current_thinking_sequence,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if (
|
|
497
|
+
self.current_assistant_buffer
|
|
498
|
+
and self.current_assistant_sequence is not None
|
|
499
|
+
):
|
|
500
|
+
entries.append(
|
|
501
|
+
NormalizedEntry(
|
|
502
|
+
entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
|
|
503
|
+
content=self.current_assistant_buffer,
|
|
504
|
+
sequence=self.current_assistant_sequence,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return entries
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def normalize_cursor_output(
|
|
512
|
+
raw_output: str, worktree_path: str = ""
|
|
513
|
+
) -> list[NormalizedEntry]:
|
|
514
|
+
"""Convenience function to normalize complete cursor output.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
raw_output: Raw stdout from cursor-agent with JSON lines.
|
|
518
|
+
worktree_path: Optional worktree path to strip from file paths.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
List of normalized entries.
|
|
522
|
+
"""
|
|
523
|
+
normalizer = CursorLogNormalizer(worktree_path)
|
|
524
|
+
entries = []
|
|
525
|
+
|
|
526
|
+
for line in raw_output.splitlines():
|
|
527
|
+
entries.extend(normalizer.process_line(line))
|
|
528
|
+
|
|
529
|
+
entries.extend(normalizer.finalize())
|
|
530
|
+
|
|
531
|
+
# Deduplicate by sequence (keep latest version)
|
|
532
|
+
seen_sequences: dict[int, NormalizedEntry] = {}
|
|
533
|
+
for entry in entries:
|
|
534
|
+
seen_sequences[entry.sequence] = entry
|
|
535
|
+
|
|
536
|
+
return sorted(seen_sequences.values(), key=lambda e: e.sequence)
|