Skip to content

Commit 14496dc

Browse files
Copilottikazyq
andcommitted
Phase 2: Implement Claude adapter with hierarchy integration
- Created ClaudeAdapter for parsing Claude Desktop JSONL logs - Supports multiple event types: LLM request/response, tool use, file operations - Intelligent event type detection from log structure - Handles various timestamp formats (RFC3339, Unix) - Extracts metrics (token counts) when available - Integrated with hierarchy cache for workspace resolution - Comprehensive test suite with 7 test cases (all passing) - Registered Claude adapter in DefaultRegistry - Format detection based on Claude-specific markers Co-authored-by: tikazyq <[email protected]>
1 parent b0e4512 commit 14496dc

File tree

3 files changed

+691
-1
lines changed

3 files changed

+691
-1
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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+
// ClaudeAdapter parses Claude Desktop logs
18+
type ClaudeAdapter struct {
19+
*BaseAdapter
20+
hierarchy *hierarchy.HierarchyCache
21+
log *logrus.Logger
22+
}
23+
24+
// NewClaudeAdapter creates a new Claude adapter
25+
func NewClaudeAdapter(projectID string, hierarchyCache *hierarchy.HierarchyCache, log *logrus.Logger) *ClaudeAdapter {
26+
if log == nil {
27+
log = logrus.New()
28+
}
29+
return &ClaudeAdapter{
30+
BaseAdapter: NewBaseAdapter("claude", projectID),
31+
hierarchy: hierarchyCache,
32+
log: log,
33+
}
34+
}
35+
36+
// ClaudeLogEntry represents a single log entry from Claude Desktop
37+
// Based on typical Claude/Anthropic log format (JSON lines)
38+
type ClaudeLogEntry struct {
39+
Timestamp interface{} `json:"timestamp"` // Can be string or number
40+
Level string `json:"level"`
41+
Message string `json:"message"`
42+
Type string `json:"type,omitempty"`
43+
ConversationID string `json:"conversation_id,omitempty"`
44+
Model string `json:"model,omitempty"`
45+
Prompt string `json:"prompt,omitempty"`
46+
Response string `json:"response,omitempty"`
47+
TokensUsed int `json:"tokens_used,omitempty"`
48+
PromptTokens int `json:"prompt_tokens,omitempty"`
49+
ResponseTokens int `json:"response_tokens,omitempty"`
50+
ToolName string `json:"tool_name,omitempty"`
51+
ToolInput interface{} `json:"tool_input,omitempty"`
52+
ToolOutput interface{} `json:"tool_output,omitempty"`
53+
FilePath string `json:"file_path,omitempty"`
54+
Action string `json:"action,omitempty"`
55+
Metadata map[string]interface{} `json:"metadata,omitempty"`
56+
}
57+
58+
// ParseLogLine parses a single log line from Claude Desktop
59+
func (a *ClaudeAdapter) ParseLogLine(line string) (*types.AgentEvent, error) {
60+
line = strings.TrimSpace(line)
61+
if line == "" {
62+
return nil, nil
63+
}
64+
65+
var entry ClaudeLogEntry
66+
if err := json.Unmarshal([]byte(line), &entry); err != nil {
67+
// Not JSON, skip
68+
return nil, nil
69+
}
70+
71+
// Detect event type and create appropriate event
72+
eventType := a.detectEventType(&entry)
73+
if eventType == "" {
74+
return nil, nil // Unknown event type, skip
75+
}
76+
77+
timestamp := a.parseTimestamp(entry.Timestamp)
78+
event := &types.AgentEvent{
79+
ID: uuid.New().String(),
80+
Timestamp: timestamp,
81+
Type: eventType,
82+
AgentID: a.name,
83+
SessionID: entry.ConversationID,
84+
LegacyProjectID: a.projectID,
85+
Context: a.extractContext(&entry),
86+
Data: a.extractData(&entry, eventType),
87+
Metrics: a.extractMetrics(&entry),
88+
}
89+
90+
return event, nil
91+
}
92+
93+
// ParseLogFile parses a Claude Desktop log file (JSONL format)
94+
func (a *ClaudeAdapter) ParseLogFile(filePath string) ([]*types.AgentEvent, error) {
95+
file, err := os.Open(filePath)
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to open log file: %w", err)
98+
}
99+
defer file.Close()
100+
101+
// Try to resolve hierarchy context from file path
102+
// Claude logs might be in a project-specific directory
103+
var hierarchyCtx *hierarchy.WorkspaceContext
104+
workspaceID := extractWorkspaceIDFromPath(filePath)
105+
if workspaceID != "" && a.hierarchy != nil {
106+
ctx, err := a.hierarchy.Resolve(workspaceID)
107+
if err != nil {
108+
a.log.Warnf("Failed to resolve workspace %s: %v - continuing without hierarchy", workspaceID, err)
109+
} else {
110+
hierarchyCtx = ctx
111+
a.log.Debugf("Resolved hierarchy for workspace %s: project=%d, machine=%d",
112+
workspaceID, ctx.ProjectID, ctx.MachineID)
113+
}
114+
}
115+
116+
var events []*types.AgentEvent
117+
scanner := bufio.NewScanner(file)
118+
119+
// Increase buffer size for large log lines
120+
buf := make([]byte, 0, 64*1024)
121+
scanner.Buffer(buf, 1024*1024)
122+
123+
lineNum := 0
124+
for scanner.Scan() {
125+
lineNum++
126+
line := scanner.Text()
127+
128+
event, err := a.ParseLogLine(line)
129+
if err != nil {
130+
a.log.Debugf("Failed to parse line %d: %v", lineNum, err)
131+
continue
132+
}
133+
134+
if event != nil {
135+
// Add hierarchy context if available
136+
if hierarchyCtx != nil {
137+
event.ProjectID = hierarchyCtx.ProjectID
138+
event.MachineID = hierarchyCtx.MachineID
139+
event.WorkspaceID = hierarchyCtx.WorkspaceID
140+
event.Context["projectName"] = hierarchyCtx.ProjectName
141+
event.Context["machineName"] = hierarchyCtx.MachineName
142+
}
143+
events = append(events, event)
144+
}
145+
}
146+
147+
if err := scanner.Err(); err != nil {
148+
return nil, fmt.Errorf("error reading log file: %w", err)
149+
}
150+
151+
return events, nil
152+
}
153+
154+
// detectEventType determines the event type from a log entry
155+
func (a *ClaudeAdapter) detectEventType(entry *ClaudeLogEntry) string {
156+
// Check explicit type field first
157+
switch entry.Type {
158+
case "llm_request", "prompt":
159+
return types.EventTypeLLMRequest
160+
case "llm_response", "completion":
161+
return types.EventTypeLLMResponse
162+
case "tool_use", "tool_call":
163+
return types.EventTypeToolUse
164+
case "file_read":
165+
return types.EventTypeFileRead
166+
case "file_write", "file_modify":
167+
return types.EventTypeFileWrite
168+
}
169+
170+
// Infer from message content
171+
msgLower := strings.ToLower(entry.Message)
172+
173+
if entry.Prompt != "" || strings.Contains(msgLower, "prompt") || strings.Contains(msgLower, "request") {
174+
return types.EventTypeLLMRequest
175+
}
176+
177+
if entry.Response != "" || strings.Contains(msgLower, "response") || strings.Contains(msgLower, "completion") {
178+
return types.EventTypeLLMResponse
179+
}
180+
181+
if entry.ToolName != "" || strings.Contains(msgLower, "tool") {
182+
return types.EventTypeToolUse
183+
}
184+
185+
if entry.FilePath != "" {
186+
if entry.Action == "read" || strings.Contains(msgLower, "read") {
187+
return types.EventTypeFileRead
188+
}
189+
if entry.Action == "write" || strings.Contains(msgLower, "write") || strings.Contains(msgLower, "modify") {
190+
return types.EventTypeFileWrite
191+
}
192+
}
193+
194+
return "" // Unknown type
195+
}
196+
197+
// parseTimestamp handles various timestamp formats
198+
func (a *ClaudeAdapter) parseTimestamp(ts interface{}) time.Time {
199+
switch v := ts.(type) {
200+
case string:
201+
// Try RFC3339 format
202+
if t, err := time.Parse(time.RFC3339, v); err == nil {
203+
return t
204+
}
205+
// Try RFC3339Nano
206+
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
207+
return t
208+
}
209+
// Try ISO 8601
210+
if t, err := time.Parse("2006-01-02T15:04:05.000Z", v); err == nil {
211+
return t
212+
}
213+
case float64:
214+
// Unix timestamp in seconds
215+
return time.Unix(int64(v), 0)
216+
case int64:
217+
// Unix timestamp in seconds
218+
return time.Unix(v, 0)
219+
}
220+
// Fallback to now
221+
return time.Now()
222+
}
223+
224+
// extractContext extracts context information from a log entry
225+
func (a *ClaudeAdapter) extractContext(entry *ClaudeLogEntry) map[string]interface{} {
226+
ctx := make(map[string]interface{})
227+
228+
if entry.Level != "" {
229+
ctx["logLevel"] = entry.Level
230+
}
231+
232+
if entry.Model != "" {
233+
ctx["model"] = entry.Model
234+
}
235+
236+
if entry.Metadata != nil {
237+
for k, v := range entry.Metadata {
238+
ctx[k] = v
239+
}
240+
}
241+
242+
return ctx
243+
}
244+
245+
// extractData extracts event-specific data from a log entry
246+
func (a *ClaudeAdapter) extractData(entry *ClaudeLogEntry, eventType string) map[string]interface{} {
247+
data := make(map[string]interface{})
248+
249+
data["message"] = entry.Message
250+
251+
switch eventType {
252+
case types.EventTypeLLMRequest:
253+
if entry.Prompt != "" {
254+
data["prompt"] = entry.Prompt
255+
data["promptLength"] = len(entry.Prompt)
256+
}
257+
case types.EventTypeLLMResponse:
258+
if entry.Response != "" {
259+
data["response"] = entry.Response
260+
data["responseLength"] = len(entry.Response)
261+
}
262+
case types.EventTypeToolUse:
263+
if entry.ToolName != "" {
264+
data["toolName"] = entry.ToolName
265+
}
266+
if entry.ToolInput != nil {
267+
data["toolInput"] = entry.ToolInput
268+
}
269+
if entry.ToolOutput != nil {
270+
data["toolOutput"] = entry.ToolOutput
271+
}
272+
case types.EventTypeFileRead, types.EventTypeFileWrite:
273+
if entry.FilePath != "" {
274+
data["filePath"] = entry.FilePath
275+
}
276+
if entry.Action != "" {
277+
data["action"] = entry.Action
278+
}
279+
}
280+
281+
if entry.ConversationID != "" {
282+
data["conversationId"] = entry.ConversationID
283+
}
284+
285+
return data
286+
}
287+
288+
// extractMetrics extracts metrics from a log entry
289+
func (a *ClaudeAdapter) extractMetrics(entry *ClaudeLogEntry) *types.EventMetrics {
290+
if entry.TokensUsed == 0 && entry.PromptTokens == 0 && entry.ResponseTokens == 0 {
291+
return nil
292+
}
293+
294+
return &types.EventMetrics{
295+
TokenCount: entry.TokensUsed,
296+
PromptTokens: entry.PromptTokens,
297+
ResponseTokens: entry.ResponseTokens,
298+
}
299+
}
300+
301+
// SupportsFormat checks if this adapter can handle the given log format
302+
func (a *ClaudeAdapter) SupportsFormat(sample string) bool {
303+
// Try to parse as JSON
304+
var entry ClaudeLogEntry
305+
if err := json.Unmarshal([]byte(sample), &entry); err != nil {
306+
return false
307+
}
308+
309+
// Check for Claude-specific fields
310+
// Claude logs typically have conversation_id or specific message patterns
311+
return entry.ConversationID != "" ||
312+
entry.Model != "" ||
313+
strings.Contains(strings.ToLower(entry.Message), "claude") ||
314+
strings.Contains(strings.ToLower(entry.Message), "anthropic")
315+
}

0 commit comments

Comments
 (0)