Add workspace extraction for Cline-family providers

Extract project name from workspace directory in api_conversation_history.json
so sessions show actual folder names instead of the provider display name.
Thread projectPath through ParsedProviderCall to avoid unsanitizePath mangling
hyphenated folder names.
This commit is contained in:
iamtoruk 2026-05-11 20:53:43 -07:00
parent 7e0e3af29f
commit 166424970a
3 changed files with 47 additions and 19 deletions

View file

@ -550,7 +550,7 @@ async function parseProviderSources(
const provider = await getProvider(providerName)
if (!provider) return []
const sessionMap = new Map<string, { project: string; turns: ClassifiedTurn[] }>()
const sessionMap = new Map<string, { project: string; projectPath?: string; turns: ClassifiedTurn[] }>()
try {
for (const source of sources) {
@ -574,13 +574,15 @@ async function parseProviderSources(
const turn = providerCallToTurn(call)
const classified = classifyTurn(turn)
const key = `${providerName}:${call.sessionId}:${source.project}`
const project = call.project ?? source.project
const key = `${providerName}:${call.sessionId}:${project}`
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath
} else {
sessionMap.set(key, { project: source.project, turns: [classified] })
sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] })
}
}
}
@ -592,22 +594,26 @@ async function parseProviderSources(
}
}
const projectMap = new Map<string, SessionSummary[]>()
for (const [key, { project, turns }] of sessionMap) {
const projectMap = new Map<string, { projectPath?: string; sessions: SessionSummary[] }>()
for (const [key, { project, projectPath, turns }] of sessionMap) {
const sessionId = key.split(':')[1] ?? key
const session = buildSessionSummary(sessionId, project, turns)
if (session.apiCalls > 0) {
const existing = projectMap.get(project) ?? []
existing.push(session)
projectMap.set(project, existing)
const existing = projectMap.get(project)
if (existing) {
existing.sessions.push(session)
if (!existing.projectPath && projectPath) existing.projectPath = projectPath
} else {
projectMap.set(project, { projectPath, sessions: [session] })
}
}
}
const projects: ProjectSummary[] = []
for (const [dirName, sessions] of projectMap) {
for (const [dirName, { projectPath, sessions }] of projectMap) {
projects.push({
project: dirName,
projectPath: unsanitizePath(dirName),
projectPath: projectPath ?? unsanitizePath(dirName),
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),

View file

@ -27,6 +27,8 @@ export type ParsedProviderCall = {
deduplicationKey: string
userMessage: string
sessionId: string
project?: string
projectPath?: string
}
export type Provider = {

View file

@ -67,25 +67,40 @@ async function discoverClineTasksInBaseDir(baseDir: string, providerName: string
}
const MODEL_TAG_RE = /<model>([^<]+)<\/model>/
const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/
function extractModelFromHistory(taskDir: string, fallbackModel: string): Promise<string> {
type HistoryMeta = { model: string; workspace: string | null }
function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise<HistoryMeta> {
return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8')
.then(raw => {
const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }>
if (!Array.isArray(msgs)) return fallbackModel
if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null }
let model: string | null = null
let workspace: string | null = null
for (const msg of msgs) {
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text)
if (match) {
const raw = match[1]
return raw.includes('/') ? raw.split('/').pop()! : raw
if (typeof block.text !== 'string') continue
if (!model) {
const mm = MODEL_TAG_RE.exec(block.text)
if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1]
}
if (!workspace) {
const wm = WORKSPACE_DIR_RE.exec(block.text)
if (wm) workspace = wm[1]
}
if (model && workspace) break
}
if (model && workspace) break
}
return fallbackModel
return { model: model ?? fallbackModel, workspace }
})
.catch(() => fallbackModel)
.catch(() => ({ model: fallbackModel, workspace: null }))
}
function workspaceToProject(workspace: string): string {
return basename(workspace) || workspace
}
export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string, fallbackModel = 'cline-auto'): SessionParser {
@ -110,7 +125,10 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
if (!Array.isArray(uiMessages)) return
const model = await extractModelFromHistory(taskDir, fallbackModel)
const meta = await extractHistoryMeta(taskDir, fallbackModel)
const model = meta.model
const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined
const projectPath = meta.workspace ?? undefined
let userMessage = ''
for (const msg of uiMessages) {
@ -173,6 +191,8 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
deduplicationKey: dedupKey,
userMessage: index === 0 ? userMessage : '',
sessionId: taskId,
project,
projectPath,
}
}
},