Merge pull request #903 from ozymandiashh/feat/899-proberoots-tier1

feat(doctor): probeRoots for 12 more providers (#899 Tier 1)
This commit is contained in:
Resham Joshi 2026-08-10 02:49:47 -07:00 committed by GitHub
commit 9df25fa066
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 198 additions and 10 deletions

View file

@ -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
@ -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()
},

View file

@ -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
@ -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[] = []

View file

@ -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
@ -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())

View file

@ -13,6 +13,7 @@ import type {
SessionSource,
SessionParser,
ParsedProviderCall,
ProbeRoot,
} from './types.js'
type ConversationSummary = {
@ -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 []

View file

@ -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
@ -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 []

View file

@ -11,6 +11,7 @@ import type {
SessionSource,
SessionParser,
ParsedProviderCall,
ProbeRoot,
} from './types.js'
const toolNameMap: Record<string, string> = {
@ -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)
},

View file

@ -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
@ -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()

View file

@ -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 = {
@ -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)

View file

@ -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'
@ -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)

View file

@ -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>
@ -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))
},

View file

@ -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',
@ -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[] = []

View file

@ -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'
@ -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 []

View file

@ -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)
})
})