Skip to content

Commit 2cddffb

Browse files
rajbosCopilot
andcommitted
refactor: migrate UsageAnalysisDeps to adapter pattern
Remove 7 individual DataAccess fields from UsageAnalysisDeps. All usage analysis routing now goes through the IAnalyzableEcosystem interface implemented by all 7 ecosystem adapters. Changes: - ecosystemAdapter.ts: add UsageAnalysisAdapterContext, IAnalyzableEcosystem interface and isAnalyzable() type guard - usageAnalysis.ts: slim UsageAnalysisDeps from 13 to 5 fields, replace 7 DA if-blocks in analyzeSessionUsage() with adapter dispatch, remove legacy DA fallback from getModelUsageFromSession(), export readClaudeCodeEventsForAnalysis, applyModelTierClassification, and createEmptySessionUsageAnalysis() factory - All 7 adapters: implement IAnalyzableEcosystem with analyzeUsage() - openCodeAdapter.ts + crushAdapter.ts: add getSyncData() for backend sync - extension.ts: slim usageAnalysisDeps getter to 5 fields - cli/src/helpers.ts: slim calculateUsageAnalysisStats deps to 5 fields - usageAnalysis.test.ts: update makeMockDeps to adapter-based mock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 64936a3 commit 2cddffb

12 files changed

Lines changed: 348 additions & 419 deletions

cli/src/helpers.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -413,12 +413,6 @@ function aggregateIntoPeriod(period: PeriodStats, data: SessionData): void {
413413
export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promise<UsageAnalysisStats> {
414414
const deps = {
415415
warn,
416-
openCode: _openCodeInstance,
417-
crush: _crushInstance,
418-
continue_: _continueInstance,
419-
visualStudio: _visualStudioInstance,
420-
claudeCode: _claudeCodeInstance,
421-
claudeDesktopCowork: _claudeDesktopCoworkInstance,
422416
tokenEstimators,
423417
modelPricing,
424418
toolNameMap,

vscode-extension/src/adapters/claudeCodeAdapter.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as fs from 'fs';
22
import type { ModelUsage } from '../types';
3-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
5-
import { ClaudeCodeDataAccess } from '../claudecode';
3+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
4+
import { ClaudeCodeDataAccess, normalizeClaudeModelId } from '../claudecode';
5+
import { readClaudeCodeEventsForAnalysis, createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
66

7-
export class ClaudeCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
7+
export class ClaudeCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
88
readonly id = 'claudecode';
99
readonly displayName = 'Claude Code';
1010

@@ -67,4 +67,37 @@ export class ClaudeCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosys
6767
getCandidatePaths(): CandidatePath[] {
6868
return [{ path: this.claudeCode.getClaudeCodeProjectsDir(), source: 'Claude Code' }];
6969
}
70+
71+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
72+
const analysis = createEmptySessionUsageAnalysis();
73+
const events = await readClaudeCodeEventsForAnalysis(sessionFile);
74+
const models: string[] = [];
75+
for (const event of events) {
76+
if (event.type === 'user' && event.message?.role === 'user' && !event.isSidechain) {
77+
analysis.modeUsage.ask++;
78+
} else if (event.type === 'assistant') {
79+
const model = normalizeClaudeModelId(event.message?.model || 'unknown');
80+
models.push(model);
81+
const content: any[] = Array.isArray(event.message?.content) ? event.message.content : [];
82+
for (const c of content) {
83+
if (c?.type === 'tool_use') {
84+
analysis.toolCalls.total++;
85+
const toolName = String(c.name || 'tool');
86+
analysis.toolCalls.byTool[toolName] = (analysis.toolCalls.byTool[toolName] || 0) + 1;
87+
}
88+
}
89+
}
90+
}
91+
const uniqueModels = [...new Set(models)];
92+
analysis.modelSwitching.uniqueModels = uniqueModels;
93+
analysis.modelSwitching.modelCount = uniqueModels.length;
94+
analysis.modelSwitching.totalRequests = models.length;
95+
let switchCount = 0;
96+
for (let i = 1; i < models.length; i++) {
97+
if (models[i] !== models[i - 1]) { switchCount++; }
98+
}
99+
analysis.modelSwitching.switchCount = switchCount;
100+
applyModelTierClassification(ctx.modelPricing, uniqueModels, models, analysis);
101+
return analysis;
102+
}
70103
}

vscode-extension/src/adapters/claudeDesktopAdapter.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn, ActualUsage } from '../types';
3-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
3+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
54
import { ClaudeDesktopCoworkDataAccess } from '../claudedesktop';
65
import { createEmptyContextRefs } from '../tokenEstimation';
6+
import { readClaudeCodeEventsForAnalysis, createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
7+
import { normalizeClaudeModelId } from '../claudecode';
78

8-
export class ClaudeDesktopAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
9+
export class ClaudeDesktopAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
910
readonly id = 'claudedesktop';
1011
readonly displayName = 'Claude Desktop Cowork';
1112

@@ -161,4 +162,37 @@ export class ClaudeDesktopAdapter implements IEcosystemAdapter, IDiscoverableEco
161162

162163
return { turns };
163164
}
165+
166+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
167+
const analysis = createEmptySessionUsageAnalysis();
168+
const events = await readClaudeCodeEventsForAnalysis(sessionFile);
169+
const models: string[] = [];
170+
for (const event of events) {
171+
if (event.type === 'user' && event.message?.role === 'user' && !event.isSidechain) {
172+
analysis.modeUsage.ask++;
173+
} else if (event.type === 'assistant') {
174+
const model = normalizeClaudeModelId(event.message?.model || 'unknown');
175+
models.push(model);
176+
const content: any[] = Array.isArray(event.message?.content) ? event.message.content : [];
177+
for (const c of content) {
178+
if (c?.type === 'tool_use') {
179+
analysis.toolCalls.total++;
180+
const toolName = String(c.name || 'tool');
181+
analysis.toolCalls.byTool[toolName] = (analysis.toolCalls.byTool[toolName] || 0) + 1;
182+
}
183+
}
184+
}
185+
}
186+
const uniqueModels = [...new Set(models)];
187+
analysis.modelSwitching.uniqueModels = uniqueModels;
188+
analysis.modelSwitching.modelCount = uniqueModels.length;
189+
analysis.modelSwitching.totalRequests = models.length;
190+
let switchCount = 0;
191+
for (let i = 1; i < models.length; i++) {
192+
if (models[i] !== models[i - 1]) { switchCount++; }
193+
}
194+
analysis.modelSwitching.switchCount = switchCount;
195+
applyModelTierClassification(ctx.modelPricing, uniqueModels, models, analysis);
196+
return analysis;
197+
}
164198
}

vscode-extension/src/adapters/continueAdapter.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn } from '../types';
3-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
3+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
54
import { ContinueDataAccess } from '../continue';
65
import { createEmptyContextRefs } from '../tokenEstimation';
6+
import { createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
77

8-
export class ContinueAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
8+
export class ContinueAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
99
readonly id = 'continue';
1010
readonly displayName = 'Continue';
1111

@@ -108,4 +108,36 @@ export class ContinueAdapter implements IEcosystemAdapter, IDiscoverableEcosyste
108108
}
109109
return { turns };
110110
}
111+
112+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
113+
const analysis = createEmptySessionUsageAnalysis();
114+
const turns = this.continue_.buildContinueTurns(sessionFile);
115+
const meta = this.continue_.getContinueSessionMeta(sessionFile);
116+
const models: string[] = [];
117+
for (const turn of turns) {
118+
analysis.modeUsage.ask++;
119+
if (turn.model) { models.push(turn.model); }
120+
for (const tc of turn.toolCalls) {
121+
analysis.toolCalls.total++;
122+
analysis.toolCalls.byTool[tc.toolName] = (analysis.toolCalls.byTool[tc.toolName] || 0) + 1;
123+
}
124+
}
125+
if (meta?.mode === 'agent') {
126+
for (let k = 0; k < turns.length; k++) {
127+
analysis.modeUsage.ask--;
128+
analysis.modeUsage.agent++;
129+
}
130+
}
131+
const uniqueModels = [...new Set(models)];
132+
analysis.modelSwitching.uniqueModels = uniqueModels;
133+
analysis.modelSwitching.modelCount = uniqueModels.length;
134+
analysis.modelSwitching.totalRequests = models.length;
135+
let switchCount = 0;
136+
for (let ki = 1; ki < models.length; ki++) {
137+
if (models[ki] !== models[ki - 1]) { switchCount++; }
138+
}
139+
analysis.modelSwitching.switchCount = switchCount;
140+
applyModelTierClassification(ctx.modelPricing, uniqueModels, models, analysis);
141+
return analysis;
142+
}
111143
}

vscode-extension/src/adapters/crushAdapter.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
33
import type { ModelUsage, ChatTurn } from '../types';
4-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
5-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
4+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
65
import { CrushDataAccess } from '../crush';
76
import { createEmptyContextRefs } from '../tokenEstimation';
7+
import { createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
88

9-
export class CrushAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
9+
export class CrushAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
1010
readonly id = 'crush';
1111
readonly displayName = 'Crush';
1212

@@ -168,4 +168,40 @@ export class CrushAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
168168
}
169169
return { turns };
170170
}
171+
172+
async getSyncData(sessionFile: string): Promise<{ tokens: number; interactions: number; modelUsage: ModelUsage; timestamp: number }> {
173+
return this.crush.getCrushSessionData(sessionFile);
174+
}
175+
176+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
177+
const analysis = createEmptySessionUsageAnalysis();
178+
const messages = await this.crush.getCrushMessages(sessionFile);
179+
const models: string[] = [];
180+
for (const msg of messages) {
181+
if (msg.role === 'user') { analysis.modeUsage.agent++; }
182+
if (msg.role === 'assistant') {
183+
const model = msg.model || 'unknown';
184+
models.push(model);
185+
const parts: any[] = Array.isArray(msg.parts) ? msg.parts : [];
186+
for (const part of parts) {
187+
if (part?.type === 'tool_call' && part?.data?.name) {
188+
analysis.toolCalls.total++;
189+
const toolName = part.data.name as string;
190+
analysis.toolCalls.byTool[toolName] = (analysis.toolCalls.byTool[toolName] || 0) + 1;
191+
}
192+
}
193+
}
194+
}
195+
const uniqueModels = [...new Set(models)];
196+
analysis.modelSwitching.uniqueModels = uniqueModels;
197+
analysis.modelSwitching.modelCount = uniqueModels.length;
198+
analysis.modelSwitching.totalRequests = models.length;
199+
let switchCount = 0;
200+
for (let i = 1; i < models.length; i++) {
201+
if (models[i] !== models[i - 1]) { switchCount++; }
202+
}
203+
analysis.modelSwitching.switchCount = switchCount;
204+
applyModelTierClassification(ctx.modelPricing, uniqueModels, models, analysis);
205+
return analysis;
206+
}
171207
}

vscode-extension/src/adapters/mistralVibeAdapter.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn } from '../types';
3-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
3+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
54
import { MistralVibeDataAccess } from '../mistralvibe';
65
import { createEmptyContextRefs } from '../tokenEstimation';
6+
import { createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
77

8-
export class MistralVibeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
8+
export class MistralVibeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
99
readonly id = 'mistralvibe';
1010
readonly displayName = 'Mistral Vibe';
1111

@@ -126,4 +126,33 @@ export class MistralVibeAdapter implements IEcosystemAdapter, IDiscoverableEcosy
126126

127127
return { turns, actualTokens: tokenData.tokens };
128128
}
129+
130+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
131+
const analysis = createEmptySessionUsageAnalysis();
132+
const messages = this.mistralVibe.readSessionMessages(sessionFile);
133+
const meta = this.mistralVibe.getSessionMeta(sessionFile);
134+
const model = meta.model || 'devstral';
135+
const models: string[] = [];
136+
for (const msg of messages) {
137+
if (msg.role === 'user' && msg.injected !== true) {
138+
analysis.modeUsage.agent++;
139+
} else if (msg.role === 'assistant') {
140+
models.push(model);
141+
if (Array.isArray(msg.tool_calls)) {
142+
for (const tc of msg.tool_calls) {
143+
analysis.toolCalls.total++;
144+
const toolName = String(tc.function?.name || tc.name || 'tool');
145+
analysis.toolCalls.byTool[toolName] = (analysis.toolCalls.byTool[toolName] || 0) + 1;
146+
}
147+
}
148+
}
149+
}
150+
const uniqueModels = [...new Set(models)];
151+
analysis.modelSwitching.uniqueModels = uniqueModels;
152+
analysis.modelSwitching.modelCount = uniqueModels.length;
153+
analysis.modelSwitching.totalRequests = models.length;
154+
analysis.modelSwitching.switchCount = 0;
155+
applyModelTierClassification(ctx.modelPricing, uniqueModels, models, analysis);
156+
return analysis;
157+
}
129158
}

vscode-extension/src/adapters/openCodeAdapter.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
33
import type { ModelUsage, ChatTurn } from '../types';
4-
import type { IEcosystemAdapter } from '../ecosystemAdapter';
5-
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
4+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
65
import { OpenCodeDataAccess } from '../opencode';
76
import { createEmptyContextRefs } from '../tokenEstimation';
7+
import { createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
88

9-
export class OpenCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
9+
export class OpenCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
1010
readonly id = 'opencode';
1111
readonly displayName = 'OpenCode';
1212

@@ -212,4 +212,47 @@ export class OpenCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosyste
212212
}
213213
return { turns };
214214
}
215+
216+
async getSyncData(sessionFile: string): Promise<{ tokens: number; interactions: number; modelUsage: ModelUsage; timestamp: number }> {
217+
return this.openCode.getOpenCodeSessionData(sessionFile);
218+
}
219+
220+
async analyzeUsage(sessionFile: string, ctx: UsageAnalysisAdapterContext): Promise<import('../types').SessionUsageAnalysis> {
221+
const analysis = createEmptySessionUsageAnalysis();
222+
const messages = await this.openCode.getOpenCodeMessagesForSession(sessionFile);
223+
if (messages.length > 0) {
224+
const models: string[] = [];
225+
for (const msg of messages) {
226+
if (msg.role === 'user') {
227+
const mode = msg.agent || 'agent';
228+
if (mode === 'build' || mode === 'agent') { analysis.modeUsage.agent++; }
229+
else if (mode === 'ask') { analysis.modeUsage.ask++; }
230+
else if (mode === 'edit') { analysis.modeUsage.edit++; }
231+
else { analysis.modeUsage.agent++; }
232+
}
233+
if (msg.role === 'assistant') {
234+
const model = msg.modelID || 'unknown';
235+
models.push(model);
236+
const parts = await this.openCode.getOpenCodePartsForMessage(msg.id);
237+
for (const part of parts) {
238+
if (part.type === 'tool' && part.tool) {
239+
analysis.toolCalls.total++;
240+
const toolName = part.tool;
241+
analysis.toolCalls.byTool[toolName] = (analysis.toolCalls.byTool[toolName] || 0) + 1;
242+
}
243+
}
244+
}
245+
}
246+
const uniqueModels = [...new Set(models)];
247+
analysis.modelSwitching.uniqueModels = uniqueModels;
248+
analysis.modelSwitching.modelCount = uniqueModels.length;
249+
analysis.modelSwitching.totalRequests = models.length;
250+
let switchCount = 0;
251+
for (let i = 1; i < models.length; i++) {
252+
if (models[i] !== models[i - 1]) { switchCount++; }
253+
}
254+
analysis.modelSwitching.switchCount = switchCount;
255+
}
256+
return analysis;
257+
}
215258
}

0 commit comments

Comments
 (0)