mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-25 14:37:13 +00:00
261 lines
7.6 KiB
TypeScript
261 lines
7.6 KiB
TypeScript
import { readdir, stat } from 'fs/promises'
|
|
import { basename, dirname, join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import { type DiscoverySnapshotEntry, loadDiscoveryCache, saveDiscoveryCache } from '../discovery-cache.js'
|
|
import { readSessionFile } from '../fs-utils.js'
|
|
import { calculateCost } from '../models.js'
|
|
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
|
|
|
const modelDisplayNames: Record<string, string> = {
|
|
'gpt-4.1-nano': 'GPT-4.1 Nano',
|
|
'gpt-4.1-mini': 'GPT-4.1 Mini',
|
|
'gpt-4.1': 'GPT-4.1',
|
|
'gpt-4o-mini': 'GPT-4o Mini',
|
|
'gpt-4o': 'GPT-4o',
|
|
'gpt-5-mini': 'GPT-5 Mini',
|
|
'gpt-5': 'GPT-5',
|
|
'claude-sonnet-4-5': 'Sonnet 4.5',
|
|
'claude-sonnet-4': 'Sonnet 4',
|
|
'claude-3-7-sonnet': 'Sonnet 3.7',
|
|
'claude-3-5-sonnet': 'Sonnet 3.5',
|
|
'o4-mini': 'o4-mini',
|
|
'o3': 'o3',
|
|
}
|
|
|
|
const toolNameMap: Record<string, string> = {
|
|
bash: 'Bash',
|
|
read_file: 'Read',
|
|
write_file: 'Edit',
|
|
edit_file: 'Edit',
|
|
create_file: 'Write',
|
|
delete_file: 'Delete',
|
|
search_files: 'Grep',
|
|
find_files: 'Glob',
|
|
list_directory: 'LS',
|
|
web_search: 'WebSearch',
|
|
fetch_webpage: 'WebFetch',
|
|
github_repo: 'GitHub',
|
|
}
|
|
|
|
// Pre-sorted by key length descending so longer/more-specific keys match first
|
|
const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
|
|
|
|
// Fields marked optional document the on-disk schema; they are not read by the parser
|
|
type ToolRequest = {
|
|
name?: string
|
|
toolCallId?: string
|
|
type?: string
|
|
}
|
|
|
|
type ModelChangeData = {
|
|
newModel: string
|
|
previousModel?: string
|
|
}
|
|
|
|
type UserMessageData = {
|
|
content: string
|
|
interactionId?: string
|
|
}
|
|
|
|
type AssistantMessageData = {
|
|
messageId: string
|
|
outputTokens: number
|
|
interactionId?: string
|
|
toolRequests?: ToolRequest[]
|
|
}
|
|
|
|
type CopilotEvent =
|
|
| { type: 'session.model_change'; timestamp?: string; data: ModelChangeData }
|
|
| { type: 'user.message'; timestamp?: string; data: UserMessageData }
|
|
| { type: 'assistant.message'; timestamp?: string; data: AssistantMessageData }
|
|
|
|
function getCopilotSessionStateDir(override?: string): string {
|
|
return override ?? join(homedir(), '.copilot', 'session-state')
|
|
}
|
|
|
|
function parseCwd(yaml: string): string | null {
|
|
const match = yaml.match(/^cwd:\s*(.+)$/m)
|
|
if (!match?.[1]) return null
|
|
const raw = match[1]
|
|
.replace(/\s*#.*$/, '') // strip trailing comment
|
|
.replace(/^['"]|['"]$/g, '') // strip surrounding quotes
|
|
.trim()
|
|
return raw || null
|
|
}
|
|
|
|
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
|
return {
|
|
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
|
const content = await readSessionFile(source.path)
|
|
if (content === null) return
|
|
const sessionId = basename(dirname(source.path))
|
|
const lines = content.split('\n').filter(l => l.trim())
|
|
let currentModel = ''
|
|
let pendingUserMessage = ''
|
|
|
|
for (const line of lines) {
|
|
let event: CopilotEvent
|
|
try {
|
|
event = JSON.parse(line) as CopilotEvent
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
if (event.type === 'session.model_change') {
|
|
currentModel = event.data.newModel ?? currentModel
|
|
continue
|
|
}
|
|
|
|
if (event.type === 'user.message') {
|
|
pendingUserMessage = event.data.content ?? ''
|
|
continue
|
|
}
|
|
|
|
if (event.type === 'assistant.message') {
|
|
const { messageId, outputTokens, toolRequests = [] } = event.data
|
|
if (outputTokens === 0) continue
|
|
// Skip if no model has been identified yet - avoids silent misattribution
|
|
if (!currentModel) continue
|
|
|
|
const dedupKey = `copilot:${sessionId}:${messageId}`
|
|
if (seenKeys.has(dedupKey)) continue
|
|
seenKeys.add(dedupKey)
|
|
|
|
const tools = toolRequests
|
|
.map(t => t.name ?? '')
|
|
.filter(Boolean)
|
|
.map(n => toolNameMap[n] ?? n)
|
|
|
|
// Copilot only logs outputTokens; inputTokens are not available in session logs.
|
|
// Cost will be lower than actual API cost.
|
|
const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0)
|
|
|
|
yield {
|
|
provider: 'copilot',
|
|
model: currentModel,
|
|
inputTokens: 0,
|
|
outputTokens,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
costUSD,
|
|
tools,
|
|
bashCommands: [],
|
|
timestamp: event.timestamp ?? '',
|
|
speed: 'standard',
|
|
deduplicationKey: dedupKey,
|
|
userMessage: pendingUserMessage,
|
|
sessionId,
|
|
}
|
|
|
|
pendingUserMessage = ''
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
async function collectCopilotDiscoverySnapshot(sessionStateDir: string): Promise<DiscoverySnapshotEntry[]> {
|
|
const snapshot: DiscoverySnapshotEntry[] = []
|
|
|
|
let sessionDirs: string[]
|
|
try {
|
|
sessionDirs = await readdir(sessionStateDir)
|
|
} catch {
|
|
return snapshot
|
|
}
|
|
|
|
for (const sessionId of sessionDirs) {
|
|
const sessionDir = join(sessionStateDir, sessionId)
|
|
const dirStat = await stat(sessionDir).catch(() => null)
|
|
if (!dirStat?.isDirectory()) continue
|
|
|
|
const eventsPath = join(sessionDir, 'events.jsonl')
|
|
const eventsStat = await stat(eventsPath).catch(() => null)
|
|
if (!eventsStat?.isFile()) continue
|
|
|
|
snapshot.push({ path: eventsPath, mtimeMs: eventsStat.mtimeMs })
|
|
|
|
const workspacePath = join(sessionDir, 'workspace.yaml')
|
|
const workspaceStat = await stat(workspacePath).catch(() => null)
|
|
if (workspaceStat?.isFile()) {
|
|
snapshot.push({ path: workspacePath, mtimeMs: workspaceStat.mtimeMs })
|
|
}
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSource[]> {
|
|
const snapshot = await collectCopilotDiscoverySnapshot(sessionStateDir)
|
|
const cached = await loadDiscoveryCache('copilot', sessionStateDir, snapshot)
|
|
if (cached) return cached
|
|
|
|
const sources: SessionSource[] = []
|
|
|
|
let sessionDirs: string[]
|
|
try {
|
|
sessionDirs = await readdir(sessionStateDir)
|
|
} catch {
|
|
return sources
|
|
}
|
|
|
|
for (const sessionId of sessionDirs) {
|
|
const eventsPath = join(sessionStateDir, sessionId, 'events.jsonl')
|
|
const s = await stat(eventsPath).catch(() => null)
|
|
if (!s?.isFile()) continue
|
|
|
|
let project = sessionId
|
|
const yaml = await readSessionFile(join(sessionStateDir, sessionId, 'workspace.yaml'))
|
|
if (yaml !== null) {
|
|
const cwd = parseCwd(yaml)
|
|
if (cwd) project = basename(cwd)
|
|
}
|
|
|
|
sources.push({
|
|
path: eventsPath,
|
|
project,
|
|
provider: 'copilot',
|
|
fingerprintPath: eventsPath,
|
|
cacheStrategy: 'append-jsonl',
|
|
progressLabel: basename(eventsPath),
|
|
parserVersion: 'copilot:v1',
|
|
})
|
|
}
|
|
|
|
await saveDiscoveryCache('copilot', sessionStateDir, snapshot, sources)
|
|
return sources
|
|
}
|
|
|
|
export function createCopilotProvider(sessionStateDir?: string): Provider {
|
|
const dir = getCopilotSessionStateDir(sessionStateDir)
|
|
|
|
return {
|
|
name: 'copilot',
|
|
displayName: 'Copilot',
|
|
|
|
modelDisplayName(model: string): string {
|
|
for (const [key, name] of modelDisplayEntries) {
|
|
if (model === key || model.startsWith(key + '-')) return name
|
|
}
|
|
return model
|
|
},
|
|
|
|
toolDisplayName(rawTool: string): string {
|
|
return toolNameMap[rawTool] ?? rawTool
|
|
},
|
|
|
|
async discoverSessions(): Promise<SessionSource[]> {
|
|
return discoverSessionsInDir(dir)
|
|
},
|
|
|
|
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
|
return createParser(source, seenKeys)
|
|
},
|
|
}
|
|
}
|
|
|
|
export const copilot = createCopilotProvider()
|