From 7e0e3af29f77bc98e0a7b7a754f8ab02eb6d7440 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 11 May 2026 18:14:47 +0300 Subject: [PATCH] Add IBM Bob provider --- CHANGELOG.md | 11 ++ README.md | 7 +- assets/providers/ibm-bob.svg | 6 + docs/architecture.md | 6 +- docs/providers/README.md | 3 +- docs/providers/ibm-bob.md | 55 ++++++ docs/providers/vscode-cline-parser.md | 25 +-- mac/Sources/CodeBurnMenubar/AppStore.swift | 3 + .../CodeBurnMenubar/Views/AgentTabStrip.swift | 1 + package.json | 1 + src/dashboard.tsx | 2 + src/models.ts | 2 + src/providers/ibm-bob.ts | 59 +++++++ src/providers/index.ts | 3 +- src/providers/vscode-cline-parser.ts | 29 +++- tests/provider-registry.test.ts | 2 +- tests/providers/ibm-bob.test.ts | 164 ++++++++++++++++++ 17 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 assets/providers/ibm-bob.svg create mode 100644 docs/providers/ibm-bob.md create mode 100644 src/providers/ibm-bob.ts create mode 100644 tests/providers/ibm-bob.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d..b6d3191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Added (CLI) +- **IBM Bob provider.** CodeBurn now discovers IBM Bob IDE task history from + `User/globalStorage/ibm.bob-code/tasks//` under both the GA + `IBM Bob` application data folder and preview-era `Bob-IDE` folder. The + provider reuses the Cline-family `ui_messages.json` parser for token/cost + records, reads `api_conversation_history.json` for model tags when present, + falls back to `ibm-bob-auto` pricing otherwise, and appears in CLI, + dashboard, JSON, docs, and the macOS provider tabs. Closes #248. + ## 0.9.8 - 2026-05-10 ### Added (CLI) diff --git a/README.md b/README.md index b370022..9db2a1f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Sponsor

-CodeBurn tracks token usage, cost, and performance across **18 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes. +CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes. Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm). @@ -104,6 +104,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) | | | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) | | | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) | +| | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) | | | Kiro | Yes | [kiro.md](docs/providers/kiro.md) | | | OpenCode | Yes | [opencode.md](docs/providers/opencode.md) | | | OpenClaw | Yes | [openclaw.md](docs/providers/openclaw.md) | @@ -119,7 +120,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). -Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT) plus official vendor assets, used under nominative fair use for the purpose of identifying supported tools. +Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT), official vendor assets, and simple provider identifiers, used under nominative fair use for the purpose of identifying supported tools. CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them. @@ -378,6 +379,8 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields. +**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders. + **Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn. diff --git a/assets/providers/ibm-bob.svg b/assets/providers/ibm-bob.svg new file mode 100644 index 0000000..ab76047 --- /dev/null +++ b/assets/providers/ibm-bob.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/architecture.md b/docs/architecture.md index 9b1ea14..c3a8c25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,14 +128,14 @@ type Provider = { } ``` -`src/providers/index.ts` registers eighteen providers across two tiers: +`src/providers/index.ts` registers nineteen providers across two tiers: -- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. +- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. - **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed. Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. -`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `kilo-code` and `roo-code`. It is not registered as a provider on its own. +`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own. For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`. diff --git a/docs/providers/README.md b/docs/providers/README.md index 05f43db..600bd60 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -15,6 +15,7 @@ For the architectural picture, see `../architecture.md`. | [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | +| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | | [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` | | [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` | | [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | @@ -38,7 +39,7 @@ For the architectural picture, see `../architecture.md`. | Helper | Used by | Source | |---|---|---| -| [vscode-cline-parser](vscode-cline-parser.md) | `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | +| [vscode-cline-parser](vscode-cline-parser.md) | `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | ## File Format diff --git a/docs/providers/ibm-bob.md b/docs/providers/ibm-bob.md new file mode 100644 index 0000000..c9d4373 --- /dev/null +++ b/docs/providers/ibm-bob.md @@ -0,0 +1,55 @@ +# IBM Bob + +IBM Bob IDE task history. + +- **Source:** `src/providers/ibm-bob.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/ibm-bob.test.ts` + +## Where It Reads From + +IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory. + +Default paths checked: + +| Platform | Paths | +|---|---| +| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback | + +The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory. + +## Storage Format + +Each task is a directory under `tasks//` and must contain `ui_messages.json`. + +CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode: + +- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics. +- `ui_messages.json` user text entries seed the turn's first user message. +- `api_conversation_history.json` is optional and is used to extract the selected model from `...` environment details when present. +- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today. + +If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes. + +## Caching + +None at the provider level. + +## Deduplication + +Per `::` via `vscode-cline-parser.ts`. + +## Quirks + +- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names. +- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture. +- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode. + +## When Fixing A Bug Here + +1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory. +2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`. +3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`. +4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files. diff --git a/docs/providers/vscode-cline-parser.md b/docs/providers/vscode-cline-parser.md index 5b6bdfa..ea68eae 100644 --- a/docs/providers/vscode-cline-parser.md +++ b/docs/providers/vscode-cline-parser.md @@ -1,17 +1,18 @@ # vscode-cline-parser (Shared Helper) -Shared discovery and parsing for VS Code extensions descended from Cline. +Shared discovery and parsing for Cline-family task folders. - **Source:** `src/providers/vscode-cline-parser.ts` -- **Loading:** not a provider; imported by `kilo-code.ts` and `roo-code.ts`. -- **Test:** none directly. Coverage comes from `tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.ts`. +- **Loading:** not a provider; imported by `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. +- **Test:** none directly. Coverage comes from `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`. ## What it does Two responsibilities: -1. `discoverClineTasks(extensionId)` walks VS Code's `globalStorage//tasks/` directories and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). -2. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects. +1. `discoverClineTasks(extensionId)` walks VS Code's `globalStorage//tasks/` directories and returns one source per task that has a `ui_messages.json` file. +2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob. +3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model and token counts, and yields `ParsedProviderCall` objects. ## Storage layout @@ -25,25 +26,25 @@ Per task directory: ## Model resolution -The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag (`vscode-cline-parser.ts:54-72`). Falls back to `cline-auto` if no tag is found. +The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found. ## Token extraction -From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost` (`vscode-cline-parser.ts:119-134`). +From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`. -If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens (`vscode-cline-parser.ts:139`). +If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens. ## Deduplication -Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json` (`vscode-cline-parser.ts:109`). +Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json`. ## Quirks -- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall` (`vscode-cline-parser.ts:157`). Subsequent user turns are accounted but not surfaced. +- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced. - The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex. ## When fixing a bug here -1. A change here ripples to **both** KiloCode and Roo Code. Run both test files (`tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.ts`) before opening a PR. +1. A change here ripples to IBM Bob, KiloCode, and Roo Code. Run all three provider test files before opening a PR. 2. If you find that one of the two extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser. -3. If you add support for a third Cline-derivative extension, register it as a thin wrapper file in the same shape as `kilo-code.ts` and `roo-code.ts`. +3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 00b27e8..bcf336b 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -725,6 +725,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case copilot = "Copilot" case droid = "Droid" case gemini = "Gemini" + case ibmBob = "IBM Bob" case kiro = "Kiro" case kiloCode = "KiloCode" case openclaw = "OpenClaw" @@ -742,6 +743,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .cursor: ["cursor", "cursor agent"] case .rooCode: ["roo-code", "roo code"] case .kiloCode: ["kilo-code", "kilocode"] + case .ibmBob: ["ibm-bob", "ibm bob"] case .openclaw: ["openclaw"] default: [rawValue.lowercased()] } @@ -756,6 +758,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .copilot: "copilot" case .droid: "droid" case .gemini: "gemini" + case .ibmBob: "ibm-bob" case .kiloCode: "kilo-code" case .kiro: "kiro" case .openclaw: "openclaw" diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 6561cc9..df47c46 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -345,6 +345,7 @@ extension ProviderFilter { case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) + case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0) case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) diff --git a/package.json b/package.json index a58098d..b831b30 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "claude-code", "cursor", "codex", + "ibm-bob", "opencode", "pi", "ai-coding", diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b46dbcc..e666b18 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -52,6 +52,7 @@ const PROVIDER_COLORS: Record = { claude: '#FF8C42', codex: '#5BF5A0', cursor: '#00B4D8', + 'ibm-bob': '#0F62FE', opencode: '#A78BFA', pi: '#F472B6', all: '#FF8C42', @@ -513,6 +514,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', + 'ibm-bob': 'IBM Bob', opencode: 'OpenCode', pi: 'Pi', } diff --git a/src/models.ts b/src/models.ts index e4441e0..0d43793 100644 --- a/src/models.ts +++ b/src/models.ts @@ -166,6 +166,7 @@ const BUILTIN_ALIASES: Record = { 'copilot-auto': 'claude-sonnet-4-5', 'copilot-openai-auto': 'gpt-5.3-codex', 'copilot-anthropic-auto': 'claude-sonnet-4-5', + 'ibm-bob-auto': 'claude-sonnet-4-5', 'kiro-auto': 'claude-sonnet-4-5', 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', @@ -351,6 +352,7 @@ const autoModelNames: Record = { 'copilot-auto': 'Copilot (auto)', 'copilot-openai-auto': 'Copilot (OpenAI)', 'copilot-anthropic-auto': 'Copilot (Anthropic)', + 'ibm-bob-auto': 'IBM Bob (auto)', 'kiro-auto': 'Kiro (auto)', 'cline-auto': 'Cline (auto)', 'openclaw-auto': 'OpenClaw (auto)', diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts new file mode 100644 index 0000000..5aec0f6 --- /dev/null +++ b/src/providers/ibm-bob.ts @@ -0,0 +1,59 @@ +import { join } from 'path' +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' + +const PROVIDER_NAME = 'ibm-bob' +const DISPLAY_NAME = 'IBM Bob' +const EXTENSION_ID = 'ibm.bob-code' +const FALLBACK_MODEL = 'ibm-bob-auto' + +export function getIBMBobGlobalStorageDirs(): string[] { + const home = homedir() + if (process.platform === 'darwin') { + return [ + join(home, 'Library', 'Application Support', 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(home, 'Library', 'Application Support', 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + if (process.platform === 'win32') { + const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming') + return [ + join(appData, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(appData, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + const configHome = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config') + return [ + join(configHome, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(configHome, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] +} + +export function createIBMBobProvider(overrideDir?: string): Provider { + return { + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs() + return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL) + }, + } +} + +export const ibmBob = createIBMBobProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 38ed490..551d3a2 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { gemini } from './gemini.js' +import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' import { openclaw } from './openclaw.js' @@ -101,7 +102,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()]) diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts index d1d26c0..7e1a606 100644 --- a/src/providers/vscode-cline-parser.ts +++ b/src/providers/vscode-cline-parser.ts @@ -24,6 +24,23 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string { export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise { const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId) + return discoverClineTasksInBaseDirs([baseDir], providerName, displayName) +} + +export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise { + const sources: SessionSource[] = [] + const seen = new Set() + for (const baseDir of baseDirs) { + for (const source of await discoverClineTasksInBaseDir(baseDir, providerName, displayName)) { + if (seen.has(source.path)) continue + seen.add(source.path) + sources.push(source) + } + } + return sources +} + +async function discoverClineTasksInBaseDir(baseDir: string, providerName: string, displayName: string): Promise { const tasksDir = join(baseDir, 'tasks') const sources: SessionSource[] = [] @@ -51,11 +68,11 @@ export async function discoverClineTasks(extensionId: string, providerName: stri const MODEL_TAG_RE = /([^<]+)<\/model>/ -function extractModelFromHistory(taskDir: string): Promise { +function extractModelFromHistory(taskDir: string, fallbackModel: string): Promise { 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 'cline-auto' + if (!Array.isArray(msgs)) return fallbackModel for (const msg of msgs) { if (msg.role !== 'user' || !Array.isArray(msg.content)) continue for (const block of msg.content) { @@ -66,12 +83,12 @@ function extractModelFromHistory(taskDir: string): Promise { } } } - return 'cline-auto' + return fallbackModel }) - .catch(() => 'cline-auto') + .catch(() => fallbackModel) } -export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string): SessionParser { +export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string, fallbackModel = 'cline-auto'): SessionParser { return { async *parse(): AsyncGenerator { const taskDir = source.path @@ -93,7 +110,7 @@ export function createClineParser(source: SessionSource, seenKeys: Set, if (!Array.isArray(uiMessages)) return - const model = await extractModelFromHistory(taskDir) + const model = await extractModelFromHistory(taskDir, fallbackModel) let userMessage = '' for (const msg of uiMessages) { diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 4497946..2dc1dfc 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/ibm-bob.test.ts b/tests/providers/ibm-bob.test.ts new file mode 100644 index 0000000..d61f92e --- /dev/null +++ b/tests/providers/ibm-bob.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { ibmBob, createIBMBobProvider } from '../../src/providers/ibm-bob.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeUiMessages(opts: { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + userMessage?: string + ts?: number +}): string { + const messages: unknown[] = [] + + if (opts.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1_700_000_000_000 }) + } + + const apiData: Record = { + tokensIn: opts.tokensIn ?? 100, + tokensOut: opts.tokensOut ?? 50, + cacheReads: opts.cacheReads ?? 0, + cacheWrites: opts.cacheWrites ?? 0, + } + if (opts.cost !== undefined) apiData.cost = opts.cost + + messages.push({ + type: 'say', + say: 'api_req_started', + text: JSON.stringify(apiData), + ts: opts.ts ?? 1_700_000_001_000, + }) + + return JSON.stringify(messages) +} + +function makeApiHistory(model?: string): string { + const modelTag = model ? `${model}` : '' + return JSON.stringify([ + { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + ]) +} + +describe('ibm-bob provider - discovery and parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'ibm-bob-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers IBM Bob task directories with ui_messages.json', async () => { + const task1 = join(tmpDir, 'tasks', 'task-a') + const task2 = join(tmpDir, 'tasks', 'task-b') + await mkdir(task1, { recursive: true }) + await mkdir(task2, { recursive: true }) + await writeFile(join(task1, 'ui_messages.json'), '[]') + await writeFile(join(task2, 'ui_messages.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'ibm-bob')).toBe(true) + expect(sessions.every(s => s.project === 'IBM Bob')).toBe(true) + }) + + it('skips tasks without ui_messages.json', async () => { + const task = join(tmpDir, 'tasks', 'task-no-ui') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'api_conversation_history.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) + + it('parses token usage and provider cost from Bob ui messages', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-001') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ + tokensIn: 250, + tokensOut: 125, + cacheReads: 60, + cacheWrites: 30, + cost: 0.08, + userMessage: 'modernize this class', + })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory('anthropic/claude-sonnet-4-6')) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!).toMatchObject({ + provider: 'ibm-bob', + model: 'claude-sonnet-4-6', + inputTokens: 250, + outputTokens: 125, + cacheReadInputTokens: 60, + cacheCreationInputTokens: 30, + costUSD: 0.08, + userMessage: 'modernize this class', + sessionId: 'task-001', + }) + expect(calls[0]!.deduplicationKey).toBe('ibm-bob:task-001:0') + }) + + it('falls back to IBM Bob auto model when history has no model tag', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-002') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('ibm-bob-auto') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('deduplicates across parser runs', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-003') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const seenKeys = new Set() + + const calls1: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) +}) + +describe('ibm-bob provider - metadata', () => { + it('has correct name and displayName', () => { + expect(ibmBob.name).toBe('ibm-bob') + expect(ibmBob.displayName).toBe('IBM Bob') + }) + + it('uses shared short model display names', () => { + expect(ibmBob.modelDisplayName('ibm-bob-auto')).toBe('IBM Bob (auto)') + expect(ibmBob.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + }) +})