|
| 1 | +/** |
| 2 | + * Continue extension data access layer. |
| 3 | + * Handles reading session data from the Continue VS Code extension's JSON session files. |
| 4 | + * Sessions are stored at: ~/.continue/sessions/<uuid>.json |
| 5 | + * Token data is estimated from the full prompt/completion text stored in history[].promptLogs[]. |
| 6 | + */ |
| 7 | +import * as fs from 'fs'; |
| 8 | +import * as path from 'path'; |
| 9 | +import * as os from 'os'; |
| 10 | +import type { ModelUsage } from './types'; |
| 11 | + |
| 12 | +export class ContinueDataAccess { |
| 13 | + |
| 14 | + /** |
| 15 | + * Get the Continue data directory path (~/.continue). |
| 16 | + */ |
| 17 | + getContinueDataDir(): string { |
| 18 | + return path.join(os.homedir(), '.continue'); |
| 19 | + } |
| 20 | + |
| 21 | + /** |
| 22 | + * Get the Continue sessions directory path (~/.continue/sessions). |
| 23 | + */ |
| 24 | + getContinueSessionsDir(): string { |
| 25 | + return path.join(this.getContinueDataDir(), 'sessions'); |
| 26 | + } |
| 27 | + |
| 28 | + /** |
| 29 | + * Check if a file path is a Continue session file. |
| 30 | + */ |
| 31 | + isContinueSessionFile(filePath: string): boolean { |
| 32 | + const normalized = filePath.toLowerCase().replace(/\\/g, '/'); |
| 33 | + return normalized.includes('/.continue/sessions/') && normalized.endsWith('.json'); |
| 34 | + } |
| 35 | + |
| 36 | + /** |
| 37 | + * Get all Continue session file paths. |
| 38 | + * Excludes the index file (sessions.json). |
| 39 | + */ |
| 40 | + getContinueSessionFiles(): string[] { |
| 41 | + const sessionsDir = this.getContinueSessionsDir(); |
| 42 | + if (!fs.existsSync(sessionsDir)) { return []; } |
| 43 | + try { |
| 44 | + return fs.readdirSync(sessionsDir) |
| 45 | + .filter(f => f.endsWith('.json') && f !== 'sessions.json') |
| 46 | + .map(f => path.join(sessionsDir, f)); |
| 47 | + } catch { |
| 48 | + return []; |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + private readSessionFile(sessionFilePath: string): any | null { |
| 53 | + try { |
| 54 | + const content = fs.readFileSync(sessionFilePath, 'utf8'); |
| 55 | + return JSON.parse(content); |
| 56 | + } catch { |
| 57 | + return null; |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + /** |
| 62 | + * Estimate token count from a text string. |
| 63 | + * Uses ~4 characters per token (the standard rough estimate for English text). |
| 64 | + */ |
| 65 | + private estimateTokens(text: string): number { |
| 66 | + if (!text) { return 0; } |
| 67 | + return Math.ceil(text.length / 4); |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Get token counts from a Continue session. |
| 72 | + * Continue stores full prompt and completion text in history[].promptLogs[]: |
| 73 | + * log.prompt = full prompt text sent to the model (cumulative context) |
| 74 | + * log.completion = full completion text returned by the model |
| 75 | + * Token counts are estimated from text length (~4 chars/token). |
| 76 | + */ |
| 77 | + getTokensFromContinueSession(sessionFilePath: string): { tokens: number; thinkingTokens: number } { |
| 78 | + const session = this.readSessionFile(sessionFilePath); |
| 79 | + if (!session || !Array.isArray(session.history)) { |
| 80 | + return { tokens: 0, thinkingTokens: 0 }; |
| 81 | + } |
| 82 | + let totalPrompt = 0; |
| 83 | + let totalCompletion = 0; |
| 84 | + for (const item of session.history) { |
| 85 | + if (!Array.isArray(item.promptLogs)) { continue; } |
| 86 | + for (const log of item.promptLogs) { |
| 87 | + totalPrompt += this.estimateTokens((log.prompt as string) || ''); |
| 88 | + totalCompletion += this.estimateTokens((log.completion as string) || ''); |
| 89 | + } |
| 90 | + } |
| 91 | + return { tokens: totalPrompt + totalCompletion, thinkingTokens: 0 }; |
| 92 | + } |
| 93 | + |
| 94 | + /** |
| 95 | + * Count user interactions (user messages) in a Continue session. |
| 96 | + */ |
| 97 | + countContinueInteractions(sessionFilePath: string): number { |
| 98 | + const session = this.readSessionFile(sessionFilePath); |
| 99 | + if (!session || !Array.isArray(session.history)) { return 0; } |
| 100 | + return session.history.filter((item: any) => item.message?.role === 'user').length; |
| 101 | + } |
| 102 | + |
| 103 | + /** |
| 104 | + * Get per-model token usage from a Continue session. |
| 105 | + * Reads modelTitle from each promptLog entry, falls back to session.chatModelTitle. |
| 106 | + */ |
| 107 | + getContinueModelUsage(sessionFilePath: string): ModelUsage { |
| 108 | + const session = this.readSessionFile(sessionFilePath); |
| 109 | + if (!session || !Array.isArray(session.history)) { return {}; } |
| 110 | + const modelUsage: ModelUsage = {}; |
| 111 | + for (const item of session.history) { |
| 112 | + if (!Array.isArray(item.promptLogs)) { continue; } |
| 113 | + for (const log of item.promptLogs) { |
| 114 | + const model: string = (log.modelTitle as string) || (session.chatModelTitle as string) || 'unknown'; |
| 115 | + if (!modelUsage[model]) { |
| 116 | + modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; |
| 117 | + } |
| 118 | + modelUsage[model].inputTokens += this.estimateTokens((log.prompt as string) || ''); |
| 119 | + modelUsage[model].outputTokens += this.estimateTokens((log.completion as string) || ''); |
| 120 | + } |
| 121 | + } |
| 122 | + return modelUsage; |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * Read session metadata (title, model, workspace) from a Continue session file. |
| 127 | + */ |
| 128 | + getContinueSessionMeta(sessionFilePath: string): { title?: string; model?: string; workspaceDirectory?: string; mode?: string } | null { |
| 129 | + const session = this.readSessionFile(sessionFilePath); |
| 130 | + if (!session) { return null; } |
| 131 | + return { |
| 132 | + title: session.title as string | undefined, |
| 133 | + model: session.chatModelTitle as string | undefined, |
| 134 | + workspaceDirectory: session.workspaceDirectory as string | undefined, |
| 135 | + mode: session.mode as string | undefined |
| 136 | + }; |
| 137 | + } |
| 138 | + |
| 139 | + /** |
| 140 | + * Read the sessions.json index and return a map of sessionId -> {dateCreated, title, workspaceDirectory}. |
| 141 | + * dateCreated is stored as a string of Unix ms in the index. |
| 142 | + */ |
| 143 | + readSessionsIndex(): Map<string, { dateCreated?: number; title?: string; workspaceDirectory?: string }> { |
| 144 | + const indexPath = path.join(this.getContinueSessionsDir(), 'sessions.json'); |
| 145 | + const result = new Map<string, { dateCreated?: number; title?: string; workspaceDirectory?: string }>(); |
| 146 | + try { |
| 147 | + const content = fs.readFileSync(indexPath, 'utf8'); |
| 148 | + const entries: any[] = JSON.parse(content); |
| 149 | + if (!Array.isArray(entries)) { return result; } |
| 150 | + for (const entry of entries) { |
| 151 | + if (!entry.sessionId) { continue; } |
| 152 | + result.set(entry.sessionId as string, { |
| 153 | + dateCreated: entry.dateCreated ? Number(entry.dateCreated) : undefined, |
| 154 | + title: entry.title as string | undefined, |
| 155 | + workspaceDirectory: entry.workspaceDirectory as string | undefined |
| 156 | + }); |
| 157 | + } |
| 158 | + } catch { |
| 159 | + // Index may not exist or be unreadable |
| 160 | + } |
| 161 | + return result; |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * Get the session ID (UUID) from a Continue session file path. |
| 166 | + */ |
| 167 | + getContinueSessionId(sessionFilePath: string): string { |
| 168 | + return path.basename(sessionFilePath, '.json'); |
| 169 | + } |
| 170 | + |
| 171 | + /** |
| 172 | + * Extract user text from a Continue history item's message content. |
| 173 | + * Content can be an array of {type, text} objects or a plain string. |
| 174 | + */ |
| 175 | + extractUserText(messageContent: unknown): string { |
| 176 | + if (typeof messageContent === 'string') { return messageContent; } |
| 177 | + if (Array.isArray(messageContent)) { |
| 178 | + return messageContent |
| 179 | + .filter((c: any) => c.type === 'text' && typeof c.text === 'string') |
| 180 | + .map((c: any) => c.text as string) |
| 181 | + .join('\n'); |
| 182 | + } |
| 183 | + return ''; |
| 184 | + } |
| 185 | + |
| 186 | + /** |
| 187 | + * Build chat turns from a Continue session's history array. |
| 188 | + * Returns an array of turn objects for the log viewer. |
| 189 | + */ |
| 190 | + buildContinueTurns(sessionFilePath: string): Array<{ |
| 191 | + userText: string; |
| 192 | + assistantText: string; |
| 193 | + model: string | null; |
| 194 | + toolCalls: Array<{ toolName: string; arguments?: string; result?: string }>; |
| 195 | + inputTokens: number; |
| 196 | + outputTokens: number; |
| 197 | + }> { |
| 198 | + const session = this.readSessionFile(sessionFilePath); |
| 199 | + if (!session || !Array.isArray(session.history)) { return []; } |
| 200 | + |
| 201 | + const history: any[] = session.history; |
| 202 | + const turns: Array<{ |
| 203 | + userText: string; |
| 204 | + assistantText: string; |
| 205 | + model: string | null; |
| 206 | + toolCalls: Array<{ toolName: string; arguments?: string; result?: string }>; |
| 207 | + inputTokens: number; |
| 208 | + outputTokens: number; |
| 209 | + }> = []; |
| 210 | + |
| 211 | + let i = 0; |
| 212 | + while (i < history.length) { |
| 213 | + const item = history[i]; |
| 214 | + if (item.message?.role !== 'user') { i++; continue; } |
| 215 | + |
| 216 | + const userText = this.extractUserText(item.message.content); |
| 217 | + let assistantText = ''; |
| 218 | + const toolCalls: Array<{ toolName: string; arguments?: string; result?: string }> = []; |
| 219 | + let model: string | null = session.chatModelTitle || null; |
| 220 | + let inputTokens = 0; |
| 221 | + let outputTokens = 0; |
| 222 | + |
| 223 | + // Pending tool calls waiting for their results |
| 224 | + const pendingToolCalls: Map<string, { toolName: string; arguments?: string }> = new Map(); |
| 225 | + |
| 226 | + // Collect all subsequent non-user items until the next user message |
| 227 | + let j = i + 1; |
| 228 | + while (j < history.length && history[j].message?.role !== 'user') { |
| 229 | + const sub = history[j]; |
| 230 | + const role = sub.message?.role; |
| 231 | + |
| 232 | + if (role === 'assistant') { |
| 233 | + // Accumulate assistant text |
| 234 | + if (typeof sub.message.content === 'string' && sub.message.content) { |
| 235 | + assistantText += sub.message.content; |
| 236 | + } |
| 237 | + // Get model from promptLogs |
| 238 | + if (Array.isArray(sub.promptLogs) && sub.promptLogs.length > 0) { |
| 239 | + const log = sub.promptLogs[0]; |
| 240 | + if (log.modelTitle) { model = log.modelTitle as string; } |
| 241 | + for (const plog of sub.promptLogs) { |
| 242 | + inputTokens += this.estimateTokens((plog.prompt as string) || ''); |
| 243 | + outputTokens += this.estimateTokens((plog.completion as string) || ''); |
| 244 | + } |
| 245 | + } |
| 246 | + // Collect tool calls |
| 247 | + if (Array.isArray(sub.message.toolCalls)) { |
| 248 | + for (const tc of sub.message.toolCalls) { |
| 249 | + const toolName: string = tc.function?.name || tc.name || 'unknown'; |
| 250 | + const args: string | undefined = tc.function?.arguments; |
| 251 | + const callId: string = tc.id || toolName; |
| 252 | + pendingToolCalls.set(callId, { toolName, arguments: args }); |
| 253 | + } |
| 254 | + } |
| 255 | + } else if (role === 'tool') { |
| 256 | + // Match tool result back to the pending tool call |
| 257 | + const callId: string = sub.message.toolCallId || ''; |
| 258 | + const resultText = this.extractUserText(sub.message.content); |
| 259 | + const pending = pendingToolCalls.get(callId); |
| 260 | + if (pending) { |
| 261 | + toolCalls.push({ ...pending, result: resultText }); |
| 262 | + pendingToolCalls.delete(callId); |
| 263 | + } else { |
| 264 | + // Unknown tool call id — just record with null toolName |
| 265 | + toolCalls.push({ toolName: 'unknown', result: resultText }); |
| 266 | + } |
| 267 | + } |
| 268 | + j++; |
| 269 | + } |
| 270 | + |
| 271 | + // Flush any unmatched pending tool calls (no result received) |
| 272 | + for (const [, pending] of pendingToolCalls) { |
| 273 | + toolCalls.push(pending); |
| 274 | + } |
| 275 | + |
| 276 | + turns.push({ userText, assistantText, model, toolCalls, inputTokens, outputTokens }); |
| 277 | + i = j; |
| 278 | + } |
| 279 | + |
| 280 | + return turns; |
| 281 | + } |
| 282 | +} |
0 commit comments