Skip to content

Commit bc25e42

Browse files
rajbosCopilot
andcommitted
refactor: replace ecosystem if-chains with IEcosystemAdapter registry
- Add IEcosystemAdapter interface (ecosystemAdapter.ts) - Add 7 adapter implementations in src/adapters/ (OpenCode, Crush, Continue, ClaudeDesktop, ClaudeCode, VisualStudio, MistralVibe) - Replace 8 if-chain methods in extension.ts with findEcosystem() dispatch - Replace getModelUsageFromSession() if-chain in usageAnalysis.ts with adapter dispatch + legacy fallback - Replace statSessionFile() + processSessionFile() if-chains in cli/src/helpers.ts - Wire ecosystems registry into calculateUsageAnalysisStats() in CLI - Remove now-unused isCrushSessionFile() + isOpenCodeDbSession() helpers from CLI - Fix duplicate VS check bugs in statSessionFile() and estimateTokensFromSession() - Fix missing MistralVibe in isSpecialSession check and getSessionFileDetails() - Fix missing ClaudeCode in getSessionFileDetails() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bd948af commit bc25e42

12 files changed

Lines changed: 612 additions & 507 deletions

cli/src/helpers.ts

Lines changed: 29 additions & 120 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,6 +89,17 @@ 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 {
92105
return new SessionDiscovery({ log, warn, error, openCode: _openCodeInstance, crush: _crushInstance, continue_: _continueInstance, visualStudio: _visualStudioInstance, claudeCode: _claudeCodeInstance, claudeDesktopCowork: _claudeDesktopCoworkInstance, mistralVibe: _mistralVibeInstance });
@@ -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');
@@ -514,6 +422,7 @@ export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promi
514422
tokenEstimators,
515423
modelPricing,
516424
toolNameMap,
425+
ecosystems: _ecosystems,
517426
};
518427

519428
const now = new Date();
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as fs from 'fs';
2+
import type { ModelUsage } from '../types';
3+
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import { ClaudeCodeDataAccess } from '../claudecode';
5+
6+
export class ClaudeCodeAdapter implements IEcosystemAdapter {
7+
readonly id = 'claudecode';
8+
readonly displayName = 'Claude Code';
9+
10+
constructor(private readonly claudeCode: ClaudeCodeDataAccess) {}
11+
12+
handles(sessionFile: string): boolean {
13+
return this.claudeCode.isClaudeCodeSessionFile(sessionFile);
14+
}
15+
16+
getBackingPath(sessionFile: string): string {
17+
return sessionFile;
18+
}
19+
20+
async stat(sessionFile: string): Promise<fs.Stats> {
21+
return fs.promises.stat(sessionFile);
22+
}
23+
24+
async getTokens(sessionFile: string): Promise<{ tokens: number; thinkingTokens: number; actualTokens: number }> {
25+
const result = this.claudeCode.getTokensFromClaudeCodeSession(sessionFile);
26+
return { ...result, actualTokens: result.tokens };
27+
}
28+
29+
async countInteractions(sessionFile: string): Promise<number> {
30+
return Promise.resolve(this.claudeCode.countClaudeCodeInteractions(sessionFile));
31+
}
32+
33+
async getModelUsage(sessionFile: string): Promise<ModelUsage> {
34+
return Promise.resolve(this.claudeCode.getClaudeCodeModelUsage(sessionFile));
35+
}
36+
37+
async getMeta(sessionFile: string): Promise<{ title: string | undefined; firstInteraction: string | null; lastInteraction: string | null; workspacePath?: string }> {
38+
const meta = this.claudeCode.getClaudeCodeSessionMeta(sessionFile);
39+
return {
40+
title: meta?.title,
41+
firstInteraction: meta?.firstInteraction || null,
42+
lastInteraction: meta?.lastInteraction || null,
43+
workspacePath: meta?.cwd,
44+
};
45+
}
46+
47+
getEditorRoot(_sessionFile: string): string {
48+
return this.claudeCode.getClaudeCodeProjectsDir();
49+
}
50+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as fs from 'fs';
2+
import type { ModelUsage } from '../types';
3+
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import { ClaudeDesktopCoworkDataAccess } from '../claudedesktop';
5+
6+
export class ClaudeDesktopAdapter implements IEcosystemAdapter {
7+
readonly id = 'claudedesktop';
8+
readonly displayName = 'Claude Desktop Cowork';
9+
10+
constructor(private readonly claudeDesktopCowork: ClaudeDesktopCoworkDataAccess) {}
11+
12+
handles(sessionFile: string): boolean {
13+
return this.claudeDesktopCowork.isCoworkSessionFile(sessionFile);
14+
}
15+
16+
getBackingPath(sessionFile: string): string {
17+
return sessionFile;
18+
}
19+
20+
async stat(sessionFile: string): Promise<fs.Stats> {
21+
return fs.promises.stat(sessionFile);
22+
}
23+
24+
async getTokens(sessionFile: string): Promise<{ tokens: number; thinkingTokens: number; actualTokens: number }> {
25+
const result = this.claudeDesktopCowork.getTokensFromCoworkSession(sessionFile);
26+
return { ...result, actualTokens: result.tokens };
27+
}
28+
29+
async countInteractions(sessionFile: string): Promise<number> {
30+
return Promise.resolve(this.claudeDesktopCowork.countCoworkInteractions(sessionFile));
31+
}
32+
33+
async getModelUsage(sessionFile: string): Promise<ModelUsage> {
34+
return Promise.resolve(this.claudeDesktopCowork.getCoworkModelUsage(sessionFile));
35+
}
36+
37+
async getMeta(sessionFile: string): Promise<{ title: string | undefined; firstInteraction: string | null; lastInteraction: string | null; workspacePath?: string }> {
38+
const meta = this.claudeDesktopCowork.getCoworkSessionMeta(sessionFile);
39+
return {
40+
title: meta?.title,
41+
firstInteraction: meta?.firstInteraction || null,
42+
lastInteraction: meta?.lastInteraction || null,
43+
workspacePath: meta?.cwd,
44+
};
45+
}
46+
47+
getEditorRoot(_sessionFile: string): string {
48+
return this.claudeDesktopCowork.getCoworkBaseDir();
49+
}
50+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as fs from 'fs';
2+
import type { ModelUsage } from '../types';
3+
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import { ContinueDataAccess } from '../continue';
5+
6+
export class ContinueAdapter implements IEcosystemAdapter {
7+
readonly id = 'continue';
8+
readonly displayName = 'Continue';
9+
10+
constructor(private readonly continue_: ContinueDataAccess) {}
11+
12+
handles(sessionFile: string): boolean {
13+
return this.continue_.isContinueSessionFile(sessionFile);
14+
}
15+
16+
getBackingPath(sessionFile: string): string {
17+
return sessionFile;
18+
}
19+
20+
async stat(sessionFile: string): Promise<fs.Stats> {
21+
return fs.promises.stat(sessionFile);
22+
}
23+
24+
async getTokens(sessionFile: string): Promise<{ tokens: number; thinkingTokens: number; actualTokens: number }> {
25+
const result = this.continue_.getTokensFromContinueSession(sessionFile);
26+
return { ...result, actualTokens: result.tokens };
27+
}
28+
29+
async countInteractions(sessionFile: string): Promise<number> {
30+
return Promise.resolve(this.continue_.countContinueInteractions(sessionFile));
31+
}
32+
33+
async getModelUsage(sessionFile: string): Promise<ModelUsage> {
34+
return Promise.resolve(this.continue_.getContinueModelUsage(sessionFile));
35+
}
36+
37+
async getMeta(sessionFile: string): Promise<{ title: string | undefined; firstInteraction: string | null; lastInteraction: string | null; workspacePath?: string }> {
38+
const meta = this.continue_.getContinueSessionMeta(sessionFile);
39+
const sessionId = this.continue_.getContinueSessionId(sessionFile);
40+
const indexEntry = this.continue_.readSessionsIndex().get(sessionId);
41+
let firstInteraction: string | null = null;
42+
let lastInteraction: string | null = null;
43+
if (indexEntry?.dateCreated) {
44+
firstInteraction = new Date(indexEntry.dateCreated).toISOString();
45+
try {
46+
const fileStat = await fs.promises.stat(sessionFile);
47+
lastInteraction = fileStat.mtime.toISOString();
48+
} catch { /* ignore */ }
49+
}
50+
let workspacePath: string | undefined;
51+
if (meta?.workspaceDirectory) {
52+
try {
53+
workspacePath = decodeURIComponent(meta.workspaceDirectory.replace(/^file:\/\/\//, '').replace(/^file:\/\//, ''));
54+
} catch { /* ignore */ }
55+
}
56+
return {
57+
title: meta?.title,
58+
firstInteraction,
59+
lastInteraction,
60+
workspacePath,
61+
};
62+
}
63+
64+
getEditorRoot(_sessionFile: string): string {
65+
return this.continue_.getContinueDataDir();
66+
}
67+
}

0 commit comments

Comments
 (0)