Skip to content

Commit 1f136ff

Browse files
authored
Merge pull request #641 from rajbos/rajbos/refactor-ecosystem-interface
refactor: replace ecosystem if-chains with IEcosystemAdapter registry
2 parents fcc94f8 + bc2aab8 commit 1f136ff

15 files changed

Lines changed: 1862 additions & 1571 deletions

cli/src/helpers.ts

Lines changed: 30 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { VisualStudioDataAccess } from '../../vscode-extension/src/visualstudio'
1414
import { ClaudeCodeDataAccess } from '../../vscode-extension/src/claudecode';
1515
import { ClaudeDesktopCoworkDataAccess } from '../../vscode-extension/src/claudedesktop';
1616
import { MistralVibeDataAccess } from '../../vscode-extension/src/mistralvibe';
17+
import type { IEcosystemAdapter } from '../../vscode-extension/src/ecosystemAdapter';
18+
import { OpenCodeAdapter, CrushAdapter, ContinueAdapter, ClaudeDesktopAdapter, ClaudeCodeAdapter, VisualStudioAdapter, MistralVibeAdapter } from '../../vscode-extension/src/adapters';
1719
import { parseSessionFileContent } from '../../vscode-extension/src/sessionParser';
1820
import { estimateTokensFromText, getModelFromRequest, isJsonlContent, estimateTokensFromJsonlSession, calculateEstimatedCost, getModelTier } from '../../vscode-extension/src/tokenEstimation';
1921
import type { DetailedStats, PeriodStats, ModelUsage, EditorUsage, SessionFileCache, UsageAnalysisStats, UsageAnalysisPeriod } from '../../vscode-extension/src/types';
@@ -87,9 +89,20 @@ const _claudeCodeInstance = createClaudeCode();
8789
const _claudeDesktopCoworkInstance = createClaudeDesktopCowork();
8890
const _mistralVibeInstance = createMistralVibe();
8991

92+
/** Ordered registry of ecosystem adapters — first match wins. */
93+
const _ecosystems: IEcosystemAdapter[] = [
94+
new OpenCodeAdapter(_openCodeInstance),
95+
new CrushAdapter(_crushInstance),
96+
new VisualStudioAdapter(_visualStudioInstance, (t, m) => estimateTokensFromText(t, m ?? 'gpt-4', tokenEstimators)),
97+
new ContinueAdapter(_continueInstance),
98+
new ClaudeDesktopAdapter(_claudeDesktopCoworkInstance),
99+
new ClaudeCodeAdapter(_claudeCodeInstance),
100+
new MistralVibeAdapter(_mistralVibeInstance),
101+
];
102+
90103
/** Create session discovery instance for CLI */
91104
function createSessionDiscovery(): SessionDiscovery {
92-
return new SessionDiscovery({ log, warn, error, openCode: _openCodeInstance, crush: _crushInstance, continue_: _continueInstance, visualStudio: _visualStudioInstance, claudeCode: _claudeCodeInstance, claudeDesktopCowork: _claudeDesktopCoworkInstance, mistralVibe: _mistralVibeInstance });
105+
return new SessionDiscovery({ log, warn, error, ecosystems: _ecosystems });
93106
}
94107

95108
/** Discover all session files on this machine */
@@ -118,32 +131,13 @@ function resolveModel(request: any): string {
118131
return getModelFromRequest(request, modelPricing);
119132
}
120133

121-
/**
122-
* Check if a session file path is an OpenCode DB virtual path.
123-
*/
124-
function isOpenCodeDbSession(filePath: string): boolean {
125-
return filePath.includes('opencode.db#ses_');
126-
}
127-
128-
/**
129-
* Check if a session file path is a Crush DB virtual path.
130-
*/
131-
function isCrushSessionFile(filePath: string): boolean {
132-
return _crushInstance.isCrushSessionFile(filePath);
133-
}
134-
135134
/**
136135
* Stat a session file, handling DB virtual paths (OpenCode and Crush).
137136
* Virtual DB paths are resolved to the actual DB file.
138137
*/
139138
async function statSessionFile(filePath: string): Promise<fs.Stats> {
140-
if (isCrushSessionFile(filePath)) {
141-
return _crushInstance.statSessionFile(filePath);
142-
}
143-
if (isOpenCodeDbSession(filePath)) {
144-
const dbPath = filePath.split('#')[0];
145-
return fs.promises.stat(dbPath);
146-
}
139+
const eco = _ecosystems.find(e => e.handles(filePath));
140+
if (eco) { return eco.stat(filePath); }
147141
return fs.promises.stat(filePath);
148142
}
149143

@@ -210,111 +204,25 @@ export async function processSessionFile(filePath: string): Promise<SessionData
210204
return cached;
211205
}
212206

213-
// Handle Crush DB virtual paths directly via the crush module
214-
if (isCrushSessionFile(filePath)) {
215-
const result = await _crushInstance.getTokensFromCrushSession(filePath);
216-
const interactions = await _crushInstance.countCrushInteractions(filePath);
217-
const modelUsage = await _crushInstance.getCrushModelUsage(filePath);
218-
const crushResult: SessionData = {
219-
file: filePath,
220-
tokens: result.tokens,
221-
thinkingTokens: result.thinkingTokens,
222-
interactions,
223-
modelUsage,
224-
lastModified: stats.mtime,
225-
editorSource: getEditorSourceFromPath(filePath),
226-
};
227-
setCached(filePath, stats.mtimeMs, stats.size, crushResult);
228-
return crushResult;
229-
}
230-
231-
// Handle OpenCode DB virtual paths directly via the opencode module
232-
if (isOpenCodeDbSession(filePath)) {
233-
const result = await _openCodeInstance.getTokensFromOpenCodeSession(filePath);
234-
const interactions = await _openCodeInstance.countOpenCodeInteractions(filePath);
235-
const modelUsage = await _openCodeInstance.getOpenCodeModelUsage(filePath);
236-
const openCodeResult: SessionData = {
207+
// Dispatch to ecosystem adapters (OpenCode, Crush, VS, Continue, ClaudeDesktop, ClaudeCode, MistralVibe)
208+
const eco = _ecosystems.find(e => e.handles(filePath));
209+
if (eco) {
210+
const [tokenResult, interactions, modelUsage] = await Promise.all([
211+
eco.getTokens(filePath),
212+
eco.countInteractions(filePath),
213+
eco.getModelUsage(filePath),
214+
]);
215+
const ecoResult: SessionData = {
237216
file: filePath,
238-
tokens: result.tokens,
239-
thinkingTokens: result.thinkingTokens,
217+
tokens: tokenResult.tokens,
218+
thinkingTokens: tokenResult.thinkingTokens,
240219
interactions,
241220
modelUsage,
242221
lastModified: stats.mtime,
243222
editorSource: getEditorSourceFromPath(filePath),
244223
};
245-
setCached(filePath, stats.mtimeMs, stats.size, openCodeResult);
246-
return openCodeResult;
247-
}
248-
249-
// Handle Visual Studio session files (binary MessagePack)
250-
if (_visualStudioInstance.isVSSessionFile(filePath)) {
251-
const result = _visualStudioInstance.getTokenEstimates(filePath, estimateTokens);
252-
const objects = _visualStudioInstance.decodeSessionFile(filePath);
253-
const interactions = _visualStudioInstance.countInteractions(objects);
254-
const modelUsage = _visualStudioInstance.getModelUsage(filePath, estimateTokens);
255-
const vsResult: SessionData = {
256-
file: filePath,
257-
tokens: result.tokens,
258-
thinkingTokens: result.thinkingTokens,
259-
interactions,
260-
modelUsage,
261-
lastModified: stats.mtime,
262-
editorSource: getEditorSourceFromPath(filePath),
263-
};
264-
setCached(filePath, stats.mtimeMs, stats.size, vsResult);
265-
return vsResult;
266-
}
267-
268-
// Handle Claude Desktop Cowork sessions (JSONL with actual Anthropic API token counts)
269-
if (_claudeDesktopCoworkInstance.isCoworkSessionFile(filePath)) {
270-
const result = _claudeDesktopCoworkInstance.getTokensFromCoworkSession(filePath);
271-
const interactions = _claudeDesktopCoworkInstance.countCoworkInteractions(filePath);
272-
const modelUsage = _claudeDesktopCoworkInstance.getCoworkModelUsage(filePath);
273-
const coworkResult: SessionData = {
274-
file: filePath,
275-
tokens: result.tokens,
276-
thinkingTokens: result.thinkingTokens,
277-
interactions,
278-
modelUsage,
279-
lastModified: stats.mtime,
280-
editorSource: getEditorSourceFromPath(filePath),
281-
};
282-
setCached(filePath, stats.mtimeMs, stats.size, coworkResult);
283-
return coworkResult;
284-
}
285-
286-
// Handle Claude Code sessions (JSONL with actual Anthropic API token counts)
287-
if (_claudeCodeInstance.isClaudeCodeSessionFile(filePath)) {
288-
const result = _claudeCodeInstance.getTokensFromClaudeCodeSession(filePath);
289-
const interactions = _claudeCodeInstance.countClaudeCodeInteractions(filePath);
290-
const modelUsage = _claudeCodeInstance.getClaudeCodeModelUsage(filePath);
291-
const claudeResult: SessionData = {
292-
file: filePath,
293-
tokens: result.tokens,
294-
thinkingTokens: result.thinkingTokens,
295-
interactions,
296-
modelUsage,
297-
lastModified: stats.mtime,
298-
editorSource: getEditorSourceFromPath(filePath),
299-
};
300-
setCached(filePath, stats.mtimeMs, stats.size, claudeResult);
301-
return claudeResult;
302-
}
303-
304-
// Handle Mistral Vibe sessions (JSON meta.json with actual token counts in stats)
305-
if (_mistralVibeInstance.isVibeSessionFile(filePath)) {
306-
const sessionData = _mistralVibeInstance.getSessionData(filePath);
307-
const vibeResult: SessionData = {
308-
file: filePath,
309-
tokens: sessionData.tokens,
310-
thinkingTokens: 0,
311-
interactions: sessionData.interactions,
312-
modelUsage: sessionData.modelUsage,
313-
lastModified: stats.mtime,
314-
editorSource: getEditorSourceFromPath(filePath),
315-
};
316-
setCached(filePath, stats.mtimeMs, stats.size, vibeResult);
317-
return vibeResult;
224+
setCached(filePath, stats.mtimeMs, stats.size, ecoResult);
225+
return ecoResult;
318226
}
319227

320228
const content = await fs.promises.readFile(filePath, 'utf-8');
@@ -505,15 +413,10 @@ function aggregateIntoPeriod(period: PeriodStats, data: SessionData): void {
505413
export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promise<UsageAnalysisStats> {
506414
const deps = {
507415
warn,
508-
openCode: _openCodeInstance,
509-
crush: _crushInstance,
510-
continue_: _continueInstance,
511-
visualStudio: _visualStudioInstance,
512-
claudeCode: _claudeCodeInstance,
513-
claudeDesktopCowork: _claudeDesktopCoworkInstance,
514416
tokenEstimators,
515417
modelPricing,
516418
toolNameMap,
419+
ecosystems: _ecosystems,
517420
};
518421

519422
const now = new Date();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as fs from 'fs';
2+
import type { ModelUsage } from '../types';
3+
import type { IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem, DiscoveryResult, CandidatePath, UsageAnalysisAdapterContext } from '../ecosystemAdapter';
4+
import { ClaudeCodeDataAccess, normalizeClaudeModelId } from '../claudecode';
5+
import { readClaudeCodeEventsForAnalysis, createEmptySessionUsageAnalysis, applyModelTierClassification } from '../usageAnalysis';
6+
7+
export class ClaudeCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem, IAnalyzableEcosystem {
8+
readonly id = 'claudecode';
9+
readonly displayName = 'Claude Code';
10+
11+
constructor(private readonly claudeCode: ClaudeCodeDataAccess) {}
12+
13+
handles(sessionFile: string): boolean {
14+
return this.claudeCode.isClaudeCodeSessionFile(sessionFile);
15+
}
16+
17+
getBackingPath(sessionFile: string): string {
18+
return sessionFile;
19+
}
20+
21+
async stat(sessionFile: string): Promise<fs.Stats> {
22+
return fs.promises.stat(sessionFile);
23+
}
24+
25+
async getTokens(sessionFile: string): Promise<{ tokens: number; thinkingTokens: number; actualTokens: number }> {
26+
const result = this.claudeCode.getTokensFromClaudeCodeSession(sessionFile);
27+
return { ...result, actualTokens: result.tokens };
28+
}
29+
30+
async countInteractions(sessionFile: string): Promise<number> {
31+
return Promise.resolve(this.claudeCode.countClaudeCodeInteractions(sessionFile));
32+
}
33+
34+
async getModelUsage(sessionFile: string): Promise<ModelUsage> {
35+
return Promise.resolve(this.claudeCode.getClaudeCodeModelUsage(sessionFile));
36+
}
37+
38+
async getMeta(sessionFile: string): Promise<{ title: string | undefined; firstInteraction: string | null; lastInteraction: string | null; workspacePath?: string }> {
39+
const meta = this.claudeCode.getClaudeCodeSessionMeta(sessionFile);
40+
return {
41+
title: meta?.title,
42+
firstInteraction: meta?.firstInteraction || null,
43+
lastInteraction: meta?.lastInteraction || null,
44+
workspacePath: meta?.cwd,
45+
};
46+
}
47+
48+
getEditorRoot(_sessionFile: string): string {
49+
return this.claudeCode.getClaudeCodeProjectsDir();
50+
}
51+
52+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
53+
const candidatePaths = this.getCandidatePaths();
54+
const sessionFiles: string[] = [];
55+
try {
56+
const files = this.claudeCode.getClaudeCodeSessionFiles();
57+
if (files.length > 0) {
58+
log(`📄 Found ${files.length} session file(s) in Claude Code (~/.claude/projects)`);
59+
sessionFiles.push(...files);
60+
}
61+
} catch (e) {
62+
log(`Could not read Claude Code session files: ${e}`);
63+
}
64+
return { sessionFiles, candidatePaths };
65+
}
66+
67+
getCandidatePaths(): CandidatePath[] {
68+
return [{ path: this.claudeCode.getClaudeCodeProjectsDir(), source: 'Claude Code' }];
69+
}
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+
}
103+
}

0 commit comments

Comments
 (0)