|
1 | 1 | import * as fs from 'fs'; |
2 | | -import type { ModelUsage } from '../types'; |
| 2 | +import type { ModelUsage, ChatTurn, ActualUsage } from '../types'; |
3 | 3 | import type { IEcosystemAdapter } from '../ecosystemAdapter'; |
4 | 4 | import { ClaudeDesktopCoworkDataAccess } from '../claudedesktop'; |
| 5 | +import { createEmptyContextRefs } from '../tokenEstimation'; |
5 | 6 |
|
6 | 7 | export class ClaudeDesktopAdapter implements IEcosystemAdapter { |
7 | 8 | readonly id = 'claudedesktop'; |
8 | 9 | readonly displayName = 'Claude Desktop Cowork'; |
9 | 10 |
|
10 | | - constructor(private readonly claudeDesktopCowork: ClaudeDesktopCoworkDataAccess) {} |
| 11 | + constructor( |
| 12 | + private readonly claudeDesktopCowork: ClaudeDesktopCoworkDataAccess, |
| 13 | + private readonly isMcpToolFn: (toolName: string) => boolean, |
| 14 | + private readonly extractMcpServerNameFn: (toolName: string) => string, |
| 15 | + private readonly estimateTokensFn: (text: string, model?: string) => number |
| 16 | + ) {} |
11 | 17 |
|
12 | 18 | handles(sessionFile: string): boolean { |
13 | 19 | return this.claudeDesktopCowork.isCoworkSessionFile(sessionFile); |
@@ -47,4 +53,91 @@ export class ClaudeDesktopAdapter implements IEcosystemAdapter { |
47 | 53 | getEditorRoot(_sessionFile: string): string { |
48 | 54 | return this.claudeDesktopCowork.getCoworkBaseDir(); |
49 | 55 | } |
| 56 | + |
| 57 | + async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> { |
| 58 | + const turns: ChatTurn[] = []; |
| 59 | + const events = this.claudeDesktopCowork.readCoworkEvents(sessionFile); |
| 60 | + let currentUserEvent: any = null; |
| 61 | + const pendingAssistantEvents: any[] = []; |
| 62 | + |
| 63 | + const emitTurn = () => { |
| 64 | + if (!currentUserEvent) { return; } |
| 65 | + const content = currentUserEvent.message?.content; |
| 66 | + const userMessage = typeof content === 'string' ? content |
| 67 | + : Array.isArray(content) ? content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('\n') |
| 68 | + : ''; |
| 69 | + let assistantText = ''; |
| 70 | + let actualInputTokens = 0; |
| 71 | + let actualOutputTokens = 0; |
| 72 | + let model: string | null = null; |
| 73 | + const toolCalls: { toolName: string; arguments?: string }[] = []; |
| 74 | + const mcpTools: { server: string; tool: string }[] = []; |
| 75 | + |
| 76 | + for (const ae of pendingAssistantEvents) { |
| 77 | + const msg = ae.message; |
| 78 | + if (!model && msg?.model) { model = msg.model; } |
| 79 | + const usage = msg?.usage; |
| 80 | + if (usage) { |
| 81 | + actualInputTokens += (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0); |
| 82 | + actualOutputTokens += usage.output_tokens || 0; |
| 83 | + } |
| 84 | + const contentArr: any[] = Array.isArray(msg?.content) ? msg.content : []; |
| 85 | + for (const block of contentArr) { |
| 86 | + if (block.type === 'text') { assistantText += block.text || ''; } |
| 87 | + else if (block.type === 'tool_use') { |
| 88 | + const toolName: string = block.name || 'unknown'; |
| 89 | + if (this.isMcpToolFn(toolName)) { |
| 90 | + mcpTools.push({ server: this.extractMcpServerNameFn(toolName), tool: toolName }); |
| 91 | + } else { |
| 92 | + toolCalls.push({ toolName, arguments: block.input ? JSON.stringify(block.input) : undefined }); |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + const usedModel = model || 'claude-sonnet-4-6'; |
| 99 | + const actualUsage: ActualUsage | undefined = (actualInputTokens > 0 || actualOutputTokens > 0) ? { |
| 100 | + promptTokens: actualInputTokens, |
| 101 | + completionTokens: actualOutputTokens |
| 102 | + } : undefined; |
| 103 | + |
| 104 | + turns.push({ |
| 105 | + turnNumber: turns.length + 1, |
| 106 | + timestamp: currentUserEvent.timestamp ? new Date(currentUserEvent.timestamp).toISOString() : null, |
| 107 | + mode: 'agent', |
| 108 | + userMessage, |
| 109 | + assistantResponse: assistantText, |
| 110 | + model: usedModel, |
| 111 | + toolCalls, |
| 112 | + contextReferences: createEmptyContextRefs(), |
| 113 | + mcpTools, |
| 114 | + inputTokensEstimate: actualInputTokens || this.estimateTokensFn(userMessage, usedModel), |
| 115 | + outputTokensEstimate: actualOutputTokens || this.estimateTokensFn(assistantText, usedModel), |
| 116 | + thinkingTokensEstimate: 0, |
| 117 | + actualUsage |
| 118 | + }); |
| 119 | + }; |
| 120 | + |
| 121 | + const isRealUserMessage = (event: any): boolean => { |
| 122 | + const content = event.message?.content; |
| 123 | + if (typeof content === 'string') { return !!content.trim(); } |
| 124 | + if (!Array.isArray(content)) { return false; } |
| 125 | + const hasText = content.some((c: any) => c.type === 'text'); |
| 126 | + const hasToolResult = content.some((c: any) => c.type === 'tool_result'); |
| 127 | + return hasText && !hasToolResult; |
| 128 | + }; |
| 129 | + |
| 130 | + for (const event of events) { |
| 131 | + if (event.type === 'user' && !event.isSidechain && event.message?.role === 'user' && isRealUserMessage(event)) { |
| 132 | + emitTurn(); |
| 133 | + currentUserEvent = event; |
| 134 | + pendingAssistantEvents.length = 0; |
| 135 | + } else if (event.type === 'assistant' && event.message?.stop_reason && event.message?.role === 'assistant') { |
| 136 | + pendingAssistantEvents.push(event); |
| 137 | + } |
| 138 | + } |
| 139 | + emitTurn(); |
| 140 | + |
| 141 | + return { turns }; |
| 142 | + } |
50 | 143 | } |
0 commit comments