Skip to content

Commit 998731a

Browse files
Copilottikazyq
andcommitted
Phase 3: Implement Cursor adapter with hierarchy integration
- Created CursorAdapter supporting both JSON and plain text log formats - Handles multiple event types: LLM request/response, tool use, file operations - Intelligent event detection from log structure and message content - Plain text log parsing for Cursor-specific patterns - Session ID extraction with multiple fallbacks - Integrated with hierarchy cache for workspace resolution - Comprehensive test suite with 7 test cases (all passing) - Registered Cursor adapter in DefaultRegistry - Format detection for both JSON and plain text Cursor logs Co-authored-by: tikazyq <[email protected]>
1 parent 14496dc commit 998731a

File tree

3 files changed

+705
-1
lines changed

3 files changed

+705
-1
lines changed
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
package adapters
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"strings"
9+
"time"
10+
11+
"github.com/codervisor/devlog/collector/internal/hierarchy"
12+
"github.com/codervisor/devlog/collector/pkg/types"
13+
"github.com/google/uuid"
14+
"github.com/sirupsen/logrus"
15+
)
16+
17+
// CursorAdapter parses Cursor AI logs
18+
// Cursor is based on VS Code, so it may use similar formats to Copilot
19+
type CursorAdapter struct {
20+
*BaseAdapter
21+
hierarchy *hierarchy.HierarchyCache
22+
log *logrus.Logger
23+
}
24+
25+
// NewCursorAdapter creates a new Cursor adapter
26+
func NewCursorAdapter(projectID string, hierarchyCache *hierarchy.HierarchyCache, log *logrus.Logger) *CursorAdapter {
27+
if log == nil {
28+
log = logrus.New()
29+
}
30+
return &CursorAdapter{
31+
BaseAdapter: NewBaseAdapter("cursor", projectID),
32+
hierarchy: hierarchyCache,
33+
log: log,
34+
}
35+
}
36+
37+
// CursorLogEntry represents a log entry from Cursor
38+
// Cursor may use structured JSON logs or plain text
39+
type CursorLogEntry struct {
40+
Timestamp interface{} `json:"timestamp,omitempty"`
41+
Level string `json:"level,omitempty"`
42+
Message string `json:"message,omitempty"`
43+
Type string `json:"type,omitempty"`
44+
SessionID string `json:"session_id,omitempty"`
45+
ConversationID string `json:"conversation_id,omitempty"`
46+
Model string `json:"model,omitempty"`
47+
Prompt string `json:"prompt,omitempty"`
48+
Response string `json:"response,omitempty"`
49+
Tokens int `json:"tokens,omitempty"`
50+
PromptTokens int `json:"prompt_tokens,omitempty"`
51+
CompletionTokens int `json:"completion_tokens,omitempty"`
52+
Tool string `json:"tool,omitempty"`
53+
ToolArgs interface{} `json:"tool_args,omitempty"`
54+
File string `json:"file,omitempty"`
55+
Operation string `json:"operation,omitempty"`
56+
Metadata map[string]interface{} `json:"metadata,omitempty"`
57+
}
58+
59+
// ParseLogLine parses a single log line
60+
func (a *CursorAdapter) ParseLogLine(line string) (*types.AgentEvent, error) {
61+
line = strings.TrimSpace(line)
62+
if line == "" {
63+
return nil, nil
64+
}
65+
66+
// Try to parse as JSON first
67+
var entry CursorLogEntry
68+
if err := json.Unmarshal([]byte(line), &entry); err != nil {
69+
// Not JSON, try plain text parsing
70+
return a.parsePlainTextLine(line)
71+
}
72+
73+
// Detect event type from JSON structure
74+
eventType := a.detectEventType(&entry)
75+
if eventType == "" {
76+
return nil, nil // Unknown event type
77+
}
78+
79+
timestamp := a.parseTimestamp(entry.Timestamp)
80+
event := &types.AgentEvent{
81+
ID: uuid.New().String(),
82+
Timestamp: timestamp,
83+
Type: eventType,
84+
AgentID: a.name,
85+
SessionID: a.getSessionID(&entry),
86+
LegacyProjectID: a.projectID,
87+
Context: a.extractContext(&entry),
88+
Data: a.extractData(&entry, eventType),
89+
Metrics: a.extractMetrics(&entry),
90+
}
91+
92+
return event, nil
93+
}
94+
95+
// ParseLogFile parses a Cursor log file
96+
func (a *CursorAdapter) ParseLogFile(filePath string) ([]*types.AgentEvent, error) {
97+
file, err := os.Open(filePath)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to open log file: %w", err)
100+
}
101+
defer file.Close()
102+
103+
// Try to resolve hierarchy context
104+
var hierarchyCtx *hierarchy.WorkspaceContext
105+
workspaceID := extractWorkspaceIDFromPath(filePath)
106+
if workspaceID != "" && a.hierarchy != nil {
107+
ctx, err := a.hierarchy.Resolve(workspaceID)
108+
if err != nil {
109+
a.log.Warnf("Failed to resolve workspace %s: %v - continuing without hierarchy", workspaceID, err)
110+
} else {
111+
hierarchyCtx = ctx
112+
a.log.Debugf("Resolved hierarchy for workspace %s: project=%d, machine=%d",
113+
workspaceID, ctx.ProjectID, ctx.MachineID)
114+
}
115+
}
116+
117+
var events []*types.AgentEvent
118+
scanner := bufio.NewScanner(file)
119+
120+
// Increase buffer for large lines
121+
buf := make([]byte, 0, 64*1024)
122+
scanner.Buffer(buf, 1024*1024)
123+
124+
lineNum := 0
125+
for scanner.Scan() {
126+
lineNum++
127+
line := scanner.Text()
128+
129+
event, err := a.ParseLogLine(line)
130+
if err != nil {
131+
a.log.Debugf("Failed to parse line %d: %v", lineNum, err)
132+
continue
133+
}
134+
135+
if event != nil {
136+
// Add hierarchy context if available
137+
if hierarchyCtx != nil {
138+
event.ProjectID = hierarchyCtx.ProjectID
139+
event.MachineID = hierarchyCtx.MachineID
140+
event.WorkspaceID = hierarchyCtx.WorkspaceID
141+
event.Context["projectName"] = hierarchyCtx.ProjectName
142+
event.Context["machineName"] = hierarchyCtx.MachineName
143+
}
144+
events = append(events, event)
145+
}
146+
}
147+
148+
if err := scanner.Err(); err != nil {
149+
return nil, fmt.Errorf("error reading log file: %w", err)
150+
}
151+
152+
return events, nil
153+
}
154+
155+
// parsePlainTextLine attempts to parse plain text log lines
156+
func (a *CursorAdapter) parsePlainTextLine(line string) (*types.AgentEvent, error) {
157+
// Basic pattern matching for common log patterns
158+
// Format: [timestamp] [level] message
159+
160+
// Skip debug/info logs that aren't AI-related
161+
lower := strings.ToLower(line)
162+
if !strings.Contains(lower, "ai") &&
163+
!strings.Contains(lower, "completion") &&
164+
!strings.Contains(lower, "prompt") &&
165+
!strings.Contains(lower, "tool") {
166+
return nil, nil
167+
}
168+
169+
// Create a basic event from plain text
170+
event := &types.AgentEvent{
171+
ID: uuid.New().String(),
172+
Timestamp: time.Now(),
173+
Type: types.EventTypeUserInteraction, // Default type
174+
AgentID: a.name,
175+
SessionID: uuid.New().String(),
176+
LegacyProjectID: a.projectID,
177+
Data: map[string]interface{}{
178+
"rawLog": line,
179+
},
180+
}
181+
182+
return event, nil
183+
}
184+
185+
// detectEventType determines event type from log entry
186+
func (a *CursorAdapter) detectEventType(entry *CursorLogEntry) string {
187+
// Check explicit type field
188+
switch entry.Type {
189+
case "llm_request", "prompt", "completion_request":
190+
return types.EventTypeLLMRequest
191+
case "llm_response", "completion", "completion_response":
192+
return types.EventTypeLLMResponse
193+
case "tool_use", "tool_call":
194+
return types.EventTypeToolUse
195+
case "file_read":
196+
return types.EventTypeFileRead
197+
case "file_write", "file_modify":
198+
return types.EventTypeFileWrite
199+
}
200+
201+
// Infer from content
202+
msgLower := strings.ToLower(entry.Message)
203+
204+
if entry.Prompt != "" || strings.Contains(msgLower, "prompt") || strings.Contains(msgLower, "request") {
205+
return types.EventTypeLLMRequest
206+
}
207+
208+
if entry.Response != "" || strings.Contains(msgLower, "response") || strings.Contains(msgLower, "completion") {
209+
return types.EventTypeLLMResponse
210+
}
211+
212+
if entry.Tool != "" || strings.Contains(msgLower, "tool") {
213+
return types.EventTypeToolUse
214+
}
215+
216+
if entry.File != "" {
217+
if entry.Operation == "read" || strings.Contains(msgLower, "read") {
218+
return types.EventTypeFileRead
219+
}
220+
if entry.Operation == "write" || strings.Contains(msgLower, "write") {
221+
return types.EventTypeFileWrite
222+
}
223+
}
224+
225+
return ""
226+
}
227+
228+
// getSessionID extracts session ID with fallback
229+
func (a *CursorAdapter) getSessionID(entry *CursorLogEntry) string {
230+
if entry.SessionID != "" {
231+
return entry.SessionID
232+
}
233+
if entry.ConversationID != "" {
234+
return entry.ConversationID
235+
}
236+
return uuid.New().String()
237+
}
238+
239+
// parseTimestamp handles various timestamp formats
240+
func (a *CursorAdapter) parseTimestamp(ts interface{}) time.Time {
241+
if ts == nil {
242+
return time.Now()
243+
}
244+
245+
switch v := ts.(type) {
246+
case string:
247+
// Try common formats
248+
formats := []string{
249+
time.RFC3339,
250+
time.RFC3339Nano,
251+
"2006-01-02T15:04:05.000Z",
252+
"2006-01-02 15:04:05",
253+
}
254+
for _, format := range formats {
255+
if t, err := time.Parse(format, v); err == nil {
256+
return t
257+
}
258+
}
259+
case float64:
260+
return time.Unix(int64(v), 0)
261+
case int64:
262+
return time.Unix(v, 0)
263+
}
264+
265+
return time.Now()
266+
}
267+
268+
// extractContext extracts context information
269+
func (a *CursorAdapter) extractContext(entry *CursorLogEntry) map[string]interface{} {
270+
ctx := make(map[string]interface{})
271+
272+
if entry.Level != "" {
273+
ctx["logLevel"] = entry.Level
274+
}
275+
276+
if entry.Model != "" {
277+
ctx["model"] = entry.Model
278+
}
279+
280+
if entry.Metadata != nil {
281+
for k, v := range entry.Metadata {
282+
ctx[k] = v
283+
}
284+
}
285+
286+
return ctx
287+
}
288+
289+
// extractData extracts event-specific data
290+
func (a *CursorAdapter) extractData(entry *CursorLogEntry, eventType string) map[string]interface{} {
291+
data := make(map[string]interface{})
292+
293+
if entry.Message != "" {
294+
data["message"] = entry.Message
295+
}
296+
297+
switch eventType {
298+
case types.EventTypeLLMRequest:
299+
if entry.Prompt != "" {
300+
data["prompt"] = entry.Prompt
301+
data["promptLength"] = len(entry.Prompt)
302+
}
303+
case types.EventTypeLLMResponse:
304+
if entry.Response != "" {
305+
data["response"] = entry.Response
306+
data["responseLength"] = len(entry.Response)
307+
}
308+
case types.EventTypeToolUse:
309+
if entry.Tool != "" {
310+
data["toolName"] = entry.Tool
311+
}
312+
if entry.ToolArgs != nil {
313+
data["toolArgs"] = entry.ToolArgs
314+
}
315+
case types.EventTypeFileRead, types.EventTypeFileWrite:
316+
if entry.File != "" {
317+
data["filePath"] = entry.File
318+
}
319+
if entry.Operation != "" {
320+
data["operation"] = entry.Operation
321+
}
322+
}
323+
324+
return data
325+
}
326+
327+
// extractMetrics extracts metrics
328+
func (a *CursorAdapter) extractMetrics(entry *CursorLogEntry) *types.EventMetrics {
329+
if entry.Tokens == 0 && entry.PromptTokens == 0 && entry.CompletionTokens == 0 {
330+
return nil
331+
}
332+
333+
return &types.EventMetrics{
334+
TokenCount: entry.Tokens,
335+
PromptTokens: entry.PromptTokens,
336+
ResponseTokens: entry.CompletionTokens,
337+
}
338+
}
339+
340+
// SupportsFormat checks if this adapter can handle the given log format
341+
func (a *CursorAdapter) SupportsFormat(sample string) bool {
342+
// Try JSON parse
343+
var entry CursorLogEntry
344+
if err := json.Unmarshal([]byte(sample), &entry); err == nil {
345+
// Check for Cursor-specific markers
346+
return entry.SessionID != "" ||
347+
entry.ConversationID != "" ||
348+
strings.Contains(strings.ToLower(entry.Message), "cursor") ||
349+
entry.Model != ""
350+
}
351+
352+
// Check plain text for Cursor markers
353+
lower := strings.ToLower(sample)
354+
return strings.Contains(lower, "cursor") &&
355+
(strings.Contains(lower, "ai") || strings.Contains(lower, "completion"))
356+
}

0 commit comments

Comments
 (0)