Skip to content

Commit 05107ae

Browse files
authored
Initial setup (#447)
1 parent f73f114 commit 05107ae

5 files changed

Lines changed: 447 additions & 6 deletions

File tree

src/continue.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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

Comments
 (0)