diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index a9d72bad..31449567 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -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 { + return [ + ...conversationRoots().map(root => ({ path: root.dir, label: 'conversations' })), + { path: getAntigravityStatusLineEventsPath(), label: 'statusline' }, + ] + }, + async discoverSessions(): Promise { return discoverAntigravitySessionSources() }, diff --git a/src/providers/codewhale.ts b/src/providers/codewhale.ts index 51354090..b48574fe 100644 --- a/src/providers/codewhale.ts +++ b/src/providers/codewhale.ts @@ -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 { + return (configuredDirs ?? defaultSessionDirs()).map(path => ({ path, label: 'sessions' })) + }, + async discoverSessions(): Promise { const seenSessionIds = new Set() const sources: SessionSource[] = [] diff --git a/src/providers/crush.ts b/src/providers/crush.ts index 5661d823..0223dfdc 100644 --- a/src/providers/crush.ts +++ b/src/providers/crush.ts @@ -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 { + return [{ path: getRegistryPath(), label: 'registry' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const registry = await loadRegistry(getRegistryPath()) diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 924956a1..b57265a7 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -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 { + return [ + { path: projectsDir, label: 'projects' }, + { path: dbPath, label: 'db' }, + ] + }, + async discoverSessions(): Promise { if (!existsSync(projectsDir)) return [] diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 250e3279..290f615a 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -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 { + return [{ path: dbPathOverride ?? getCursorDbPath(), label: 'db' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] diff --git a/src/providers/droid.ts b/src/providers/droid.ts index 4ec8cf6c..5da2239e 100644 --- a/src/providers/droid.ts +++ b/src/providers/droid.ts @@ -11,6 +11,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall, + ProbeRoot, } from './types.js' const toolNameMap: Record = { @@ -391,6 +392,10 @@ export function createDroidProvider(factoryDir?: string): Provider { return toolNameMap[rawTool] ?? rawTool }, + async probeRoots(): Promise { + return [{ path: sessionsDir, label: 'sessions' }] + }, + async discoverSessions(): Promise { return discoverSessionsInDir(sessionsDir, base) }, diff --git a/src/providers/goose.ts b/src/providers/goose.ts index 71097097..8cdec502 100644 --- a/src/providers/goose.ts +++ b/src/providers/goose.ts @@ -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 { + return [{ path: getDbPath(), label: 'db' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const dbPath = getDbPath() diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 309b29e9..980703b4 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -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 { + return [{ path: hermesHome, label: 'home' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const dbs = await findStateDbs(hermesHome) diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts index 5aec0f65..a9c0d12d 100644 --- a/src/providers/ibm-bob.ts +++ b/src/providers/ibm-bob.ts @@ -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 { + return (overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()).map(path => ({ path, label: 'storage' })) + }, + async discoverSessions(): Promise { const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs() return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) diff --git a/src/providers/lingtai-tui.ts b/src/providers/lingtai-tui.ts index bec5d931..ee2c25d9 100644 --- a/src/providers/lingtai-tui.ts +++ b/src/providers/lingtai-tui.ts @@ -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 @@ -418,6 +418,24 @@ export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptio return rawTool }, + async probeRoots(): Promise { + // 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 { return discoverLedgers(await getLingTaiHomes(providerOptions)) }, diff --git a/src/providers/qwen.ts b/src/providers/qwen.ts index 427b5fdf..55dbf4d1 100644 --- a/src/providers/qwen.ts +++ b/src/providers/qwen.ts @@ -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 = { read_file: 'Read', @@ -162,6 +162,10 @@ export function createQwenProvider(overrideDir?: string): Provider { return toolNameMap[rawTool] ?? rawTool }, + async probeRoots(): Promise { + return [{ path: projectsDir, label: 'projects' }] + }, + async discoverSessions(): Promise { const sources: SessionSource[] = [] diff --git a/src/providers/warp.ts b/src/providers/warp.ts index 999fc3c0..2bb89938 100644 --- a/src/providers/warp.ts +++ b/src/providers/warp.ts @@ -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 { + return getDbCandidates(dbPathOverride).map(path => ({ path, label: 'db' })) + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] diff --git a/tests/provider-probe-roots.test.ts b/tests/provider-probe-roots.test.ts new file mode 100644 index 00000000..4de8a87d --- /dev/null +++ b/tests/provider-probe-roots.test.ts @@ -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) + }) +})