From 1bd5d507c93670b5f3860f323d6992187b2d25ab Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Fri, 20 Mar 2026 22:30:06 +0100 Subject: [PATCH] Initial setup --- src/continue.ts | 282 ++++++++++++++++++++++++++++++++++++++++ src/extension.ts | 102 ++++++++++++++- src/sessionDiscovery.ts | 21 ++- src/usageAnalysis.ts | 43 +++++- src/workspaceHelpers.ts | 5 + 5 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 src/continue.ts diff --git a/src/continue.ts b/src/continue.ts new file mode 100644 index 0000000..6037fbc --- /dev/null +++ b/src/continue.ts @@ -0,0 +1,282 @@ +/** + * Continue extension data access layer. + * Handles reading session data from the Continue VS Code extension's JSON session files. + * Sessions are stored at: ~/.continue/sessions/.json + * Token data is estimated from the full prompt/completion text stored in history[].promptLogs[]. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { ModelUsage } from './types'; + +export class ContinueDataAccess { + + /** + * Get the Continue data directory path (~/.continue). + */ + getContinueDataDir(): string { + return path.join(os.homedir(), '.continue'); + } + + /** + * Get the Continue sessions directory path (~/.continue/sessions). + */ + getContinueSessionsDir(): string { + return path.join(this.getContinueDataDir(), 'sessions'); + } + + /** + * Check if a file path is a Continue session file. + */ + isContinueSessionFile(filePath: string): boolean { + const normalized = filePath.toLowerCase().replace(/\\/g, '/'); + return normalized.includes('/.continue/sessions/') && normalized.endsWith('.json'); + } + + /** + * Get all Continue session file paths. + * Excludes the index file (sessions.json). + */ + getContinueSessionFiles(): string[] { + const sessionsDir = this.getContinueSessionsDir(); + if (!fs.existsSync(sessionsDir)) { return []; } + try { + return fs.readdirSync(sessionsDir) + .filter(f => f.endsWith('.json') && f !== 'sessions.json') + .map(f => path.join(sessionsDir, f)); + } catch { + return []; + } + } + + private readSessionFile(sessionFilePath: string): any | null { + try { + const content = fs.readFileSync(sessionFilePath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } + } + + /** + * Estimate token count from a text string. + * Uses ~4 characters per token (the standard rough estimate for English text). + */ + private estimateTokens(text: string): number { + if (!text) { return 0; } + return Math.ceil(text.length / 4); + } + + /** + * Get token counts from a Continue session. + * Continue stores full prompt and completion text in history[].promptLogs[]: + * log.prompt = full prompt text sent to the model (cumulative context) + * log.completion = full completion text returned by the model + * Token counts are estimated from text length (~4 chars/token). + */ + getTokensFromContinueSession(sessionFilePath: string): { tokens: number; thinkingTokens: number } { + const session = this.readSessionFile(sessionFilePath); + if (!session || !Array.isArray(session.history)) { + return { tokens: 0, thinkingTokens: 0 }; + } + let totalPrompt = 0; + let totalCompletion = 0; + for (const item of session.history) { + if (!Array.isArray(item.promptLogs)) { continue; } + for (const log of item.promptLogs) { + totalPrompt += this.estimateTokens((log.prompt as string) || ''); + totalCompletion += this.estimateTokens((log.completion as string) || ''); + } + } + return { tokens: totalPrompt + totalCompletion, thinkingTokens: 0 }; + } + + /** + * Count user interactions (user messages) in a Continue session. + */ + countContinueInteractions(sessionFilePath: string): number { + const session = this.readSessionFile(sessionFilePath); + if (!session || !Array.isArray(session.history)) { return 0; } + return session.history.filter((item: any) => item.message?.role === 'user').length; + } + + /** + * Get per-model token usage from a Continue session. + * Reads modelTitle from each promptLog entry, falls back to session.chatModelTitle. + */ + getContinueModelUsage(sessionFilePath: string): ModelUsage { + const session = this.readSessionFile(sessionFilePath); + if (!session || !Array.isArray(session.history)) { return {}; } + const modelUsage: ModelUsage = {}; + for (const item of session.history) { + if (!Array.isArray(item.promptLogs)) { continue; } + for (const log of item.promptLogs) { + const model: string = (log.modelTitle as string) || (session.chatModelTitle as string) || 'unknown'; + if (!modelUsage[model]) { + modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; + } + modelUsage[model].inputTokens += this.estimateTokens((log.prompt as string) || ''); + modelUsage[model].outputTokens += this.estimateTokens((log.completion as string) || ''); + } + } + return modelUsage; + } + + /** + * Read session metadata (title, model, workspace) from a Continue session file. + */ + getContinueSessionMeta(sessionFilePath: string): { title?: string; model?: string; workspaceDirectory?: string; mode?: string } | null { + const session = this.readSessionFile(sessionFilePath); + if (!session) { return null; } + return { + title: session.title as string | undefined, + model: session.chatModelTitle as string | undefined, + workspaceDirectory: session.workspaceDirectory as string | undefined, + mode: session.mode as string | undefined + }; + } + + /** + * Read the sessions.json index and return a map of sessionId -> {dateCreated, title, workspaceDirectory}. + * dateCreated is stored as a string of Unix ms in the index. + */ + readSessionsIndex(): Map { + const indexPath = path.join(this.getContinueSessionsDir(), 'sessions.json'); + const result = new Map(); + try { + const content = fs.readFileSync(indexPath, 'utf8'); + const entries: any[] = JSON.parse(content); + if (!Array.isArray(entries)) { return result; } + for (const entry of entries) { + if (!entry.sessionId) { continue; } + result.set(entry.sessionId as string, { + dateCreated: entry.dateCreated ? Number(entry.dateCreated) : undefined, + title: entry.title as string | undefined, + workspaceDirectory: entry.workspaceDirectory as string | undefined + }); + } + } catch { + // Index may not exist or be unreadable + } + return result; + } + + /** + * Get the session ID (UUID) from a Continue session file path. + */ + getContinueSessionId(sessionFilePath: string): string { + return path.basename(sessionFilePath, '.json'); + } + + /** + * Extract user text from a Continue history item's message content. + * Content can be an array of {type, text} objects or a plain string. + */ + extractUserText(messageContent: unknown): string { + if (typeof messageContent === 'string') { return messageContent; } + if (Array.isArray(messageContent)) { + return messageContent + .filter((c: any) => c.type === 'text' && typeof c.text === 'string') + .map((c: any) => c.text as string) + .join('\n'); + } + return ''; + } + + /** + * Build chat turns from a Continue session's history array. + * Returns an array of turn objects for the log viewer. + */ + buildContinueTurns(sessionFilePath: string): Array<{ + userText: string; + assistantText: string; + model: string | null; + toolCalls: Array<{ toolName: string; arguments?: string; result?: string }>; + inputTokens: number; + outputTokens: number; + }> { + const session = this.readSessionFile(sessionFilePath); + if (!session || !Array.isArray(session.history)) { return []; } + + const history: any[] = session.history; + const turns: Array<{ + userText: string; + assistantText: string; + model: string | null; + toolCalls: Array<{ toolName: string; arguments?: string; result?: string }>; + inputTokens: number; + outputTokens: number; + }> = []; + + let i = 0; + while (i < history.length) { + const item = history[i]; + if (item.message?.role !== 'user') { i++; continue; } + + const userText = this.extractUserText(item.message.content); + let assistantText = ''; + const toolCalls: Array<{ toolName: string; arguments?: string; result?: string }> = []; + let model: string | null = session.chatModelTitle || null; + let inputTokens = 0; + let outputTokens = 0; + + // Pending tool calls waiting for their results + const pendingToolCalls: Map = new Map(); + + // Collect all subsequent non-user items until the next user message + let j = i + 1; + while (j < history.length && history[j].message?.role !== 'user') { + const sub = history[j]; + const role = sub.message?.role; + + if (role === 'assistant') { + // Accumulate assistant text + if (typeof sub.message.content === 'string' && sub.message.content) { + assistantText += sub.message.content; + } + // Get model from promptLogs + if (Array.isArray(sub.promptLogs) && sub.promptLogs.length > 0) { + const log = sub.promptLogs[0]; + if (log.modelTitle) { model = log.modelTitle as string; } + for (const plog of sub.promptLogs) { + inputTokens += this.estimateTokens((plog.prompt as string) || ''); + outputTokens += this.estimateTokens((plog.completion as string) || ''); + } + } + // Collect tool calls + if (Array.isArray(sub.message.toolCalls)) { + for (const tc of sub.message.toolCalls) { + const toolName: string = tc.function?.name || tc.name || 'unknown'; + const args: string | undefined = tc.function?.arguments; + const callId: string = tc.id || toolName; + pendingToolCalls.set(callId, { toolName, arguments: args }); + } + } + } else if (role === 'tool') { + // Match tool result back to the pending tool call + const callId: string = sub.message.toolCallId || ''; + const resultText = this.extractUserText(sub.message.content); + const pending = pendingToolCalls.get(callId); + if (pending) { + toolCalls.push({ ...pending, result: resultText }); + pendingToolCalls.delete(callId); + } else { + // Unknown tool call id — just record with null toolName + toolCalls.push({ toolName: 'unknown', result: resultText }); + } + } + j++; + } + + // Flush any unmatched pending tool calls (no result received) + for (const [, pending] of pendingToolCalls) { + toolCalls.push(pending); + } + + turns.push({ userText, assistantText, model, toolCalls, inputTokens, outputTokens }); + i = j; + } + + return turns; + } +} diff --git a/src/extension.ts b/src/extension.ts index bdd2b1f..eee913b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -51,6 +51,7 @@ import type { WorkspaceCustomizationSummary } from './types'; import { OpenCodeDataAccess } from './opencode'; +import { ContinueDataAccess } from './continue'; import { estimateTokensFromText as _estimateTokensFromText, estimateTokensFromJsonlSession as _estimateTokensFromJsonlSession, @@ -107,7 +108,7 @@ import { class CopilotTokenTracker implements vscode.Disposable { // Cache version - increment this when making changes that require cache invalidation - private static readonly CACHE_VERSION = 29; // Use actual token counts from CLI session.shutdown events + private static readonly CACHE_VERSION = 31; // Fix Continue token estimation (use text length, not non-existent promptLength field) // Maximum length for displaying workspace IDs in diagnostics/customization matrix private static readonly WORKSPACE_ID_DISPLAY_LENGTH = 8; @@ -120,10 +121,11 @@ class CopilotTokenTracker implements vscode.Disposable { private lastDiagnosticReport: string = ''; private logViewerPanel?: vscode.WebviewPanel; private openCode: OpenCodeDataAccess; + private continue_: ContinueDataAccess; private cacheManager: CacheManager; private get usageAnalysisDeps(): UsageAnalysisDeps { - return { warn: (m: string) => this.warn(m), openCode: this.openCode, tokenEstimators: this.tokenEstimators, modelPricing: this.modelPricing, toolNameMap: this.toolNameMap }; + return { warn: (m: string) => this.warn(m), openCode: this.openCode, continue_: this.continue_, tokenEstimators: this.tokenEstimators, modelPricing: this.modelPricing, toolNameMap: this.toolNameMap }; } private sessionDiscovery: SessionDiscovery; private statusBarItem: vscode.StatusBarItem; @@ -375,8 +377,9 @@ class CopilotTokenTracker implements vscode.Disposable { constructor(extensionUri: vscode.Uri, context: vscode.ExtensionContext) { this.extensionUri = extensionUri; this.openCode = new OpenCodeDataAccess(extensionUri); + this.continue_ = new ContinueDataAccess(); this.cacheManager = new CacheManager(context, { log: (m: string) => this.log(m), warn: (m: string) => this.warn(m), error: (m: string) => this.error(m) }, CopilotTokenTracker.CACHE_VERSION); - this.sessionDiscovery = new SessionDiscovery({ log: (m) => this.log(m), warn: (m) => this.warn(m), error: (m, e) => this.error(m, e), openCode: this.openCode }); + this.sessionDiscovery = new SessionDiscovery({ log: (m) => this.log(m), warn: (m) => this.warn(m), error: (m, e) => this.error(m, e), openCode: this.openCode, continue_: this.continue_ }); this.context = context; // Create output channel for extension logs this.outputChannel = vscode.window.createOutputChannel('GitHub Copilot Token Tracker'); @@ -1668,6 +1671,11 @@ class CopilotTokenTracker implements vscode.Disposable { return await this.openCode.countOpenCodeInteractions(sessionFile); } + // Handle Continue sessions + if (this.continue_.isContinueSessionFile(sessionFile)) { + return this.continue_.countContinueInteractions(sessionFile); + } + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format) @@ -1846,6 +1854,22 @@ class CopilotTokenTracker implements vscode.Disposable { return { title, firstInteraction, lastInteraction }; } + // Handle Continue sessions + if (this.continue_.isContinueSessionFile(sessionFile)) { + const meta = this.continue_.getContinueSessionMeta(sessionFile); + title = meta?.title; + const sessionId = this.continue_.getContinueSessionId(sessionFile); + const indexEntry = this.continue_.readSessionsIndex().get(sessionId); + let firstInteraction: string | null = null; + let lastInteraction: string | null = null; + if (indexEntry?.dateCreated) { + firstInteraction = new Date(indexEntry.dateCreated).toISOString(); + const fileStat = await fs.promises.stat(sessionFile); + lastInteraction = fileStat.mtime.toISOString(); + } + return { title, firstInteraction, lastInteraction }; + } + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format) @@ -2228,6 +2252,36 @@ class CopilotTokenTracker implements vscode.Disposable { return details; } + // Handle Continue sessions + if (this.continue_.isContinueSessionFile(sessionFile)) { + const meta = this.continue_.getContinueSessionMeta(sessionFile); + if (meta) { + details.title = meta.title; + if (meta.workspaceDirectory) { + // workspaceDirectory is a file URI like file:///c%3A/Users/.../repo-name + try { + const wsPath = decodeURIComponent(meta.workspaceDirectory.replace(/^file:\/\/\//, '').replace(/^file:\/\//, '')); + details.repository = require('path').basename(wsPath); + } catch { /* ignore */ } + } + } + details.interactions = this.continue_.countContinueInteractions(sessionFile); + details.editorRoot = this.continue_.getContinueDataDir(); + details.editorName = 'Continue'; + // Use dateCreated from sessions.json index for accurate timestamps + const sessionId = this.continue_.getContinueSessionId(sessionFile); + const indexEntry = this.continue_.readSessionsIndex().get(sessionId); + if (indexEntry?.dateCreated) { + details.firstInteraction = new Date(indexEntry.dateCreated).toISOString(); + details.lastInteraction = stat.mtime.toISOString(); + } else { + details.firstInteraction = stat.mtime.toISOString(); + details.lastInteraction = stat.mtime.toISOString(); + } + await this.updateCacheWithSessionDetails(sessionFile, stat, details); + return details; + } + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format where the file contains just a session ID) @@ -2554,6 +2608,42 @@ class CopilotTokenTracker implements vscode.Disposable { }; } + // Handle Continue sessions + if (this.continue_.isContinueSessionFile(sessionFile)) { + const continueTurns = this.continue_.buildContinueTurns(sessionFile); + const emptyContextRefs = _createEmptyContextRefs(); + for (const ct of continueTurns) { + turns.push({ + turnNumber: turns.length + 1, + timestamp: null, + mode: 'ask', + userMessage: ct.userText, + assistantResponse: ct.assistantText, + model: ct.model, + toolCalls: ct.toolCalls, + contextReferences: emptyContextRefs, + mcpTools: [], + inputTokensEstimate: ct.inputTokens, + outputTokensEstimate: ct.outputTokens, + thinkingTokensEstimate: 0 + }); + } + return { + file: details.file, + title: details.title || null, + editorSource: details.editorSource, + editorName: details.editorName || 'Continue', + size: details.size, + modified: details.modified, + interactions: details.interactions, + contextReferences: details.contextReferences, + firstInteraction: details.firstInteraction, + lastInteraction: details.lastInteraction, + turns, + usageAnalysis: undefined + }; + } + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format) @@ -2950,6 +3040,12 @@ class CopilotTokenTracker implements vscode.Disposable { return { ...result, actualTokens: result.tokens }; // OpenCode has actual counts } + // Handle Continue sessions - they have actual token counts in promptLogs + if (this.continue_.isContinueSessionFile(sessionFilePath)) { + const result = this.continue_.getTokensFromContinueSession(sessionFilePath); + return { ...result, actualTokens: result.tokens }; // Continue has actual counts + } + const fileContent = await fs.promises.readFile(sessionFilePath, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format) diff --git a/src/sessionDiscovery.ts b/src/sessionDiscovery.ts index e2949f7..51729b0 100644 --- a/src/sessionDiscovery.ts +++ b/src/sessionDiscovery.ts @@ -1,17 +1,19 @@ /** - * Session file discovery - finds and scans for Copilot/OpenCode session files. + * Session file discovery - finds and scans for Copilot/OpenCode/Continue session files. */ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { OpenCodeDataAccess } from './opencode'; +import type { ContinueDataAccess } from './continue'; export interface SessionDiscoveryDeps { log: (message: string) => void; warn: (message: string) => void; error: (message: string, error?: any) => void; openCode: OpenCodeDataAccess; + continue_: ContinueDataAccess; } export class SessionDiscovery { @@ -116,6 +118,12 @@ export class SessionDiscovery { try { openCodeDbExists = fs.existsSync(openCodeDbPath); } catch { /* ignore */ } candidates.push({ path: openCodeDbPath, exists: openCodeDbExists, source: 'OpenCode (DB)' }); + // Continue sessions directory + const continueSessionsDir = this.deps.continue_.getContinueSessionsDir(); + let continueExists = false; + try { continueExists = fs.existsSync(continueSessionsDir); } catch { /* ignore */ } + candidates.push({ path: continueSessionsDir, exists: continueExists, source: 'Continue' }); + return candidates; } @@ -394,6 +402,17 @@ export class SessionDiscovery { this.deps.warn(`Could not read OpenCode database: ${dbError}`); } + // Check for Continue extension session files (~/.continue/sessions/*.json) + try { + const continueFiles = this.deps.continue_.getContinueSessionFiles(); + if (continueFiles.length > 0) { + this.deps.log(`📄 Found ${continueFiles.length} session file(s) in Continue (~/.continue/sessions)`); + sessionFiles.push(...continueFiles); + } + } catch (continueError) { + this.deps.warn(`Could not read Continue session files: ${continueError}`); + } + // Log summary this.deps.log(`✨ Total: ${sessionFiles.length} session file(s) discovered`); if (sessionFiles.length === 0) { diff --git a/src/usageAnalysis.ts b/src/usageAnalysis.ts index 652276d..0f6784f 100644 --- a/src/usageAnalysis.ts +++ b/src/usageAnalysis.ts @@ -36,10 +36,12 @@ import { extractMcpServerName, } from './workspaceHelpers'; import type { OpenCodeDataAccess } from './opencode'; +import type { ContinueDataAccess } from './continue'; export interface UsageAnalysisDeps { warn: (msg: string) => void; openCode: OpenCodeDataAccess; + continue_: ContinueDataAccess; tokenEstimators: { [key: string]: number }; modelPricing: { [key: string]: ModelPricing }; toolNameMap: { [key: string]: string }; @@ -493,7 +495,7 @@ export function analyzeRequestContext(request: any, refs: ContextReferenceUsage) * Calculate model switching statistics for a session file. * This method updates the analysis.modelSwitching field in place. */ -export async function calculateModelSwitching(deps: Pick, sessionFile: string, analysis: SessionUsageAnalysis): Promise { +export async function calculateModelSwitching(deps: Pick, sessionFile: string, analysis: SessionUsageAnalysis): Promise { try { // Use non-cached method to avoid circular dependency // (getSessionFileDataCached -> analyzeSessionUsage -> getModelUsageFromSessionCached -> getSessionFileDataCached) @@ -957,6 +959,38 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile: return analysis; } + // Handle Continue sessions + if (deps.continue_.isContinueSessionFile(sessionFile)) { + const turns = deps.continue_.buildContinueTurns(sessionFile); + const meta = deps.continue_.getContinueSessionMeta(sessionFile); + const models: string[] = []; + for (const turn of turns) { + analysis.modeUsage.ask++; + if (turn.model) { models.push(turn.model); } + for (const tc of turn.toolCalls) { + analysis.toolCalls.total++; + analysis.toolCalls.byTool[tc.toolName] = (analysis.toolCalls.byTool[tc.toolName] || 0) + 1; + } + } + if (meta?.mode === 'agent') { + // Recount interactions as agent mode + for (let k = 0; k < turns.length; k++) { + analysis.modeUsage.ask--; + analysis.modeUsage.agent++; + } + } + const uniqueModels = [...new Set(models)]; + analysis.modelSwitching.uniqueModels = uniqueModels; + analysis.modelSwitching.modelCount = uniqueModels.length; + analysis.modelSwitching.totalRequests = models.length; + let switchCount = 0; + for (let ki = 1; ki < models.length; ki++) { + if (models[ki] !== models[ki - 1]) { switchCount++; } + } + analysis.modelSwitching.switchCount = switchCount; + return analysis; + } + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); // Handle .jsonl files OR .json files with JSONL content (Copilot CLI format and VS Code incremental format) @@ -1315,7 +1349,7 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile: return analysis; } -export async function getModelUsageFromSession(deps: Pick, sessionFile: string): Promise { +export async function getModelUsageFromSession(deps: Pick, sessionFile: string): Promise { const modelUsage: ModelUsage = {}; // Handle OpenCode sessions @@ -1323,6 +1357,11 @@ export async function getModelUsageFromSession(deps: Pick