Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/providers/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import https from 'https'

import { calculateCost } from '../models.js'
import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

type AntigravityConversationRoot = {
dir: string
Expand Down Expand Up @@ -1428,6 +1428,13 @@ export function createAntigravityProvider(): Provider {
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [
...conversationRoots().map(root => ({ path: root.dir, label: 'conversations' })),
{ path: getAntigravityStatusLineEventsPath(), label: 'statusline' },
]
},

async discoverSessions(): Promise<SessionSource[]> {
return discoverAntigravitySessionSources()
},
Expand Down
6 changes: 5 additions & 1 deletion src/providers/codewhale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { extractBashCommands } from '../bash-utils.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ToolCall } from '../types.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'

const METADATA_PREFIX_BYTES = 64 * 1024

Expand Down Expand Up @@ -457,6 +457,10 @@ export function createCodeWhaleProvider(overrideDirs?: string | string[]): Provi
return mapToolName(rawTool)
},

async probeRoots(): Promise<ProbeRoot[]> {
return (configuredDirs ?? defaultSessionDirs()).map(path => ({ path, label: 'sessions' }))
},

async discoverSessions(): Promise<SessionSource[]> {
const seenSessionIds = new Set<string>()
const sources: SessionSource[] = []
Expand Down
6 changes: 5 additions & 1 deletion src/providers/crush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir, platform } from 'os'

import { calculateCost } from '../models.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

/// Crush stores per-project SQLite databases discovered through a JSON registry.
/// We only read both. Schema source: charmbracelet/crush
Expand Down Expand Up @@ -236,6 +236,10 @@ export function createCrushProvider(): Provider {
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: getRegistryPath(), label: 'registry' }]
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
const registry = await loadRegistry(getRegistryPath())
Expand Down
8 changes: 8 additions & 0 deletions src/providers/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
SessionSource,
SessionParser,
ParsedProviderCall,
ProbeRoot,
} from './types.js'

type ConversationSummary = {
Expand Down Expand Up @@ -513,6 +514,13 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [
{ path: projectsDir, label: 'projects' },
{ path: dbPath, label: 'db' },
]
},

async discoverSessions(): Promise<SessionSource[]> {
if (!existsSync(projectsDir)) return []

Expand Down
6 changes: 5 additions & 1 deletion src/providers/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { readCachedResults, writeCachedResults } from '../cursor-cache.js'
import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import { estimateTokensFromChars } from '../token-estimate.js'
import type { DateRange } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

/** Matches cli-date.ts "all" period cap (6 months). */
const CURSOR_MAX_LOOKBACK_MONTHS = 6
Expand Down Expand Up @@ -1045,6 +1045,10 @@ export function createCursorProvider(dbPathOverride?: string): Provider {
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dbPathOverride ?? getCursorDbPath(), label: 'db' }]
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []

Expand Down
5 changes: 5 additions & 0 deletions src/providers/droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
SessionSource,
SessionParser,
ParsedProviderCall,
ProbeRoot,
} from './types.js'

const toolNameMap: Record<string, string> = {
Expand Down Expand Up @@ -391,6 +392,10 @@ export function createDroidProvider(factoryDir?: string): Provider {
return toolNameMap[rawTool] ?? rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: sessionsDir, label: 'sessions' }]
},

async discoverSessions(): Promise<SessionSource[]> {
return discoverSessionsInDir(sessionsDir, base)
},
Expand Down
6 changes: 5 additions & 1 deletion src/providers/goose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import type { ToolCall } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

type SessionRow = {
id: string
Expand Down Expand Up @@ -275,6 +275,10 @@ export function createGooseProvider(): Provider {
return toolNameMap[rawTool] ?? rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: getDbPath(), label: 'db' }]
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
const dbPath = getDbPath()
Expand Down
6 changes: 5 additions & 1 deletion src/providers/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from 'os'

import { calculateCost, getShortModelName } from '../models.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ToolCall } from '../types.js'

type HermesSessionRow = {
Expand Down Expand Up @@ -462,6 +462,10 @@ export function createHermesProvider(hermesHomeOverride?: string): Provider {
return mapToolName(rawTool)
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: hermesHome, label: 'home' }]
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
const dbs = await findStateDbs(hermesHome)
Expand Down
6 changes: 5 additions & 1 deletion src/providers/ibm-bob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from 'os'

import { getShortModelName } from '../models.js'
import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'

const PROVIDER_NAME = 'ibm-bob'
const DISPLAY_NAME = 'IBM Bob'
Expand Down Expand Up @@ -45,6 +45,10 @@ export function createIBMBobProvider(overrideDir?: string): Provider {
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return (overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()).map(path => ({ path, label: 'storage' }))
},

async discoverSessions(): Promise<SessionSource[]> {
const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()
return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME)
Expand Down
20 changes: 19 additions & 1 deletion src/providers/lingtai-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from 'os'

import { readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'

type JsonObject = Record<string, unknown>

Expand Down Expand Up @@ -418,6 +418,24 @@ export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptio
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
// Deliberately pre-existence-filter: doctor's job is to show where
// discovery looks, so a missing default home reads as "not installed
// here" instead of vanishing (getLingTaiHomes drops non-existent
// candidates, which is right for discovery and wrong for doctor).
const explicit = splitPathList(providerOptions.lingtaiHomeOverride ?? process.env['LINGTAI_HOME'] ?? process.env['LINGTAI_TUI_HOME'])
const roots: ProbeRoot[] = explicit.length
? explicit.map(path => ({ path, label: 'sessions' }))
: [
{ path: getDefaultLingTaiHome(providerOptions), label: 'sessions' },
{ path: getLingTaiGlobalDir(providerOptions), label: 'registry' },
]
for (const home of await getLingTaiHomes(providerOptions)) {
if (!roots.some(root => root.path === home.path)) roots.push({ path: home.path, label: 'sessions' })
}
return roots
},

async discoverSessions(): Promise<SessionSource[]> {
return discoverLedgers(await getLingTaiHomes(providerOptions))
},
Expand Down
6 changes: 5 additions & 1 deletion src/providers/qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

const toolNameMap: Record<string, string> = {
read_file: 'Read',
Expand Down Expand Up @@ -162,6 +162,10 @@ export function createQwenProvider(overrideDir?: string): Provider {
return toolNameMap[rawTool] ?? rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: projectsDir, label: 'projects' }]
},

async discoverSessions(): Promise<SessionSource[]> {
const sources: SessionSource[] = []

Expand Down
6 changes: 5 additions & 1 deletion src/providers/warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { extractBashCommands } from '../bash-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
import { estimateTokensFromChars } from '../token-estimate.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import { safeNumber } from '../parser.js'

const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp'
Expand Down Expand Up @@ -479,6 +479,10 @@ export function createWarpProvider(dbPathOverride?: string): Provider {
return rawTool === 'run_command' ? 'Bash' : rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return getDbCandidates(dbPathOverride).map(path => ({ path, label: 'db' }))
},

async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []

Expand Down
118 changes: 118 additions & 0 deletions tests/provider-probe-roots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { isAbsolute, join } from 'path'

import { createCodeWhaleProvider } from '../src/providers/codewhale.js'
import { createHermesProvider } from '../src/providers/hermes.js'
import { createLingTaiTuiProvider } from '../src/providers/lingtai-tui.js'
import { createDroidProvider } from '../src/providers/droid.js'
import { createCursorProvider } from '../src/providers/cursor.js'
import { createCursorAgentProvider } from '../src/providers/cursor-agent.js'
import { createGooseProvider } from '../src/providers/goose.js'
import { createCrushProvider } from '../src/providers/crush.js'
import { createWarpProvider } from '../src/providers/warp.js'
import { createAntigravityProvider } from '../src/providers/antigravity.js'
import { createQwenProvider } from '../src/providers/qwen.js'
import { createIBMBobProvider } from '../src/providers/ibm-bob.js'

// probeRoots must mirror the exact resolution each provider's discovery uses
// (#899 Tier 1). Where a factory takes an override, the assertion is exact:
// the same override must come back through probeRoots, proving the two paths
// share one resolution. Providers without an override factory get structural
// assertions: non-empty, absolute, correctly labeled.

describe('probeRoots mirrors discovery resolution', () => {
it('codewhale reports the configured dirs, or both defaults', async () => {
expect(await createCodeWhaleProvider('/tmp/cw-root').probeRoots!()).toEqual([
{ path: '/tmp/cw-root', label: 'sessions' },
])
const defaults = await createCodeWhaleProvider().probeRoots!()
expect(defaults).toHaveLength(2)
for (const root of defaults) expect(isAbsolute(root.path)).toBe(true)
})

it('hermes reports its resolved home', async () => {
expect(await createHermesProvider('/tmp/hermes-home').probeRoots!()).toEqual([
{ path: '/tmp/hermes-home', label: 'home' },
])
})

it('droid reports the sessions dir under the factory root', async () => {
expect(await createDroidProvider('/tmp/factory').probeRoots!()).toEqual([
{ path: join('/tmp/factory', 'sessions'), label: 'sessions' },
])
})

it('cursor reports the state db path', async () => {
expect(await createCursorProvider('/tmp/cursor/state.vscdb').probeRoots!()).toEqual([
{ path: '/tmp/cursor/state.vscdb', label: 'db' },
])
})

it('cursor-agent reports the projects dir and the attribution db', async () => {
expect(await createCursorAgentProvider('/tmp/ca').probeRoots!()).toEqual([
{ path: join('/tmp/ca', 'projects'), label: 'projects' },
{ path: join('/tmp/ca', 'ai-tracking', 'ai-code-tracking.db'), label: 'db' },
])
})

it('warp reports the override db, or both bundle candidates', async () => {
expect(await createWarpProvider('/tmp/warp.db').probeRoots!()).toEqual([
{ path: '/tmp/warp.db', label: 'db' },
])
const defaults = await createWarpProvider().probeRoots!()
expect(defaults).toHaveLength(2)
for (const root of defaults) {
expect(isAbsolute(root.path)).toBe(true)
expect(root.label).toBe('db')
}
})

it('qwen reports the projects dir', async () => {
expect(await createQwenProvider('/tmp/qwen-projects').probeRoots!()).toEqual([
{ path: '/tmp/qwen-projects', label: 'projects' },
])
})

it('ibm-bob reports the storage dirs', async () => {
expect(await createIBMBobProvider('/tmp/bob').probeRoots!()).toEqual([
{ path: '/tmp/bob', label: 'storage' },
])
const defaults = await createIBMBobProvider().probeRoots!()
expect(defaults.length).toBeGreaterThan(0)
for (const root of defaults) expect(root.label).toBe('storage')
})

it('lingtai-tui reports its candidates even when none exist yet', async () => {
// getLingTaiHomes drops non-existent candidates (right for discovery);
// probeRoots must keep them visible so doctor can show where it looked.
const roots = await createLingTaiTuiProvider().probeRoots!()
expect(roots.length).toBeGreaterThanOrEqual(2)
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
const labels = new Set(roots.map(r => r.label))
expect(labels.has('sessions')).toBe(true)
expect(labels.has('registry')).toBe(true)
})

it('goose reports its sessions db', async () => {
const roots = await createGooseProvider().probeRoots!()
expect(roots).toHaveLength(1)
expect(isAbsolute(roots[0]!.path)).toBe(true)
expect(roots[0]!.label).toBe('db')
})

it('crush reports its registry file', async () => {
const roots = await createCrushProvider().probeRoots!()
expect(roots).toHaveLength(1)
expect(isAbsolute(roots[0]!.path)).toBe(true)
expect(roots[0]!.label).toBe('registry')
})

it('antigravity reports its conversation roots and the statusline file', async () => {
const roots = await createAntigravityProvider().probeRoots!()
expect(roots.length).toBeGreaterThanOrEqual(2)
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
const labels = new Set(roots.map(r => r.label))
expect(labels.has('conversations')).toBe(true)
expect(labels.has('statusline')).toBe(true)
})
})