diff --git a/README.md b/README.md index b3e312ef..6366bf31 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) | | | Cursor | Yes | [cursor.md](docs/providers/cursor.md) | | | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) | +| | Devin | Yes | [devin.md](docs/providers/devin.md) | | | Forge | Yes | [forge.md](docs/providers/forge.md) | | | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) | | | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) | diff --git a/assets/providers/devin.png b/assets/providers/devin.png new file mode 100644 index 00000000..c9a68730 Binary files /dev/null and b/assets/providers/devin.png differ diff --git a/docs/providers/README.md b/docs/providers/README.md index 109c47a4..ec1e0510 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -14,6 +14,7 @@ For the architectural picture, see `../architecture.md`. | [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | | [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | +| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.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` | diff --git a/docs/providers/devin.md b/docs/providers/devin.md new file mode 100644 index 00000000..04c1cb43 --- /dev/null +++ b/docs/providers/devin.md @@ -0,0 +1,175 @@ +# Devin + +Cognition Devin CLI local usage tracking. + +- **Source:** `src/providers/devin.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/devin.test.ts` (336 lines) + +## Where it reads from + +Devin CLI data lives under: + +```text +~/.local/share/devin/cli/ +``` + +The MVP usage source is transcript JSON: + +```text +~/.local/share/devin/cli/transcripts/*.json +``` + +The provider also reads: + +```text +~/.local/share/devin/cli/sessions.db +``` + +`sessions.db` is enrichment only. It supplies project path/name, model fallback, +timestamp fallback, and hidden-session filtering. It is not the source of usage +or billing. + +## Configuration + +Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`, +so Devin stays disabled until a positive finite ACU-to-USD rate is configured: + +```json +{ + "devin": { + "acuUsdRate": 2.25 + } +} +``` + +The config file is: + +```text +~/.config/codeburn/config.json +``` + +The macOS Settings window writes this value from the Devin tab. There is no +environment-variable override and no default rate. Do not hardcode a universal +ACU price; Devin ACU pricing is account/contract dependent. + +When the rate is missing or invalid, `discoverSessions()` returns `[]` and the +parser yields no calls. Devin remains registered as a provider, but it does not +appear in CLI/UI results until configured. + +## Storage format + +Transcript root is a JSON object following the [ATIF-v1.4 trajectory schema][atif], +with Devin-specific additions such as per-step `metadata`. The parser does not +validate `schema_version`; it only requires a parseable object with `steps[]`. + +Core fields include `session_id`, `agent.model_name`, and `steps[]`. + +Each counted step can provide: + +- `step_id` +- `metadata.committed_acu_cost` +- `metadata.metrics.input_tokens` +- `metadata.metrics.output_tokens` +- `metadata.metrics.cache_creation_tokens` +- `metadata.metrics.cache_read_tokens` +- `metadata.created_at` +- `metadata.generation_model` +- `metadata.request_id` +- `tool_calls[].function_name` + +User-input steps (`metadata.is_user_input === true`) are skipped. Non-user +steps are included only if they have positive ACU usage or positive token usage. + +## Pricing + +`metadata.committed_acu_cost` is per step, not cumulative. The provider converts +each step with: + +```text +costUSD = committed_acu_cost * devin.acuUsdRate +``` + +Token-only steps are still included when they have positive token metrics, but +their `costUSD` is `0` if `committed_acu_cost` is absent. + +`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of +re-pricing it through LiteLLM. + +## sessions.db enrichment + +The provider currently reads these columns from `sessions`: + +| Column | Use | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | +| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` | +| `working_directory` | `projectPath` and derived project name | +| `model` | model fallback | +| `title` | project name fallback | +| `created_at` | timestamp fallback | +| `last_activity_at` | preferred session timestamp fallback | +| `hidden` | skip hidden sessions | + +`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet. + +## Timestamps + +Step timestamps come from `metadata.created_at`, falling back to +`sessions.last_activity_at`, then `sessions.created_at`. + +Transcript step timestamps are passed through as ATIF string timestamps. +Numeric normalization is only applied to `sessions.db` timestamps: + +- less than `10_000_000_000`: seconds +- otherwise: milliseconds + +## Model Resolution + +Model names resolve in this order: + +1. `step.metadata.generation_model` +2. `step.model_name` +3. `transcript.agent.model_name` +4. `sessions.model` +5. `devin` + +## Caching + +No provider-level cache. + +The normal session cache stores parsed provider calls, but Devin is always +reparsed by `src/parser.ts` because `sessions.db` can change without the +transcript JSON fingerprint changing. + +## Deduplication + +`devin::` + +The provider name is part of the key via the `devin:` prefix. + +## Quirks + +- The transcript directory has usage; `sessions.db` is enrichment only. +- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative. +- There is no default ACU-to-USD rate. Missing config intentionally hides Devin. +- Hidden sessions from `sessions.db` are skipped in discovery and parsing. +- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records. +- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment. + +## When fixing a bug here + +1. First check whether `~/.config/codeburn/config.json` contains a valid + `devin.acuUsdRate`. Without it, no Devin sessions should appear. +2. For usage total bugs, compare against: + + ```bash + jq '[.steps[] | select(.metadata.committed_acu_cost != null) | .metadata.committed_acu_cost] | add' ~/.local/share/devin/cli/transcripts/.json + ``` + +3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript. +4. If a hidden session appears, check the `hidden` column. Discovery can only + hide sessions whose transcript filename matches `sessions.id`; parsing uses + the transcript `session_id` when present. +5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, and `sessions.db` enrichment. + +[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md diff --git a/gnome/indicator.js b/gnome/indicator.js index 533f6441..19c13ac5 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -35,6 +35,7 @@ const PROVIDERS = [ { id: 'codex', label: 'Codex' }, { id: 'cursor', label: 'Cursor' }, { id: 'copilot', label: 'Copilot' }, + { id: 'devin', label: 'Devin' }, { id: 'opencode', label: 'OpenCode' }, { id: 'pi', label: 'Pi' }, { id: 'droid', label: 'Droid' }, @@ -70,6 +71,7 @@ const PROVIDER_PATHS = { codex: '.codex/sessions', cursor: '.config/Cursor/User/globalStorage/state.vscdb', copilot: '.copilot/session-state', + devin: '.local/share/devin/cli', kimi: '.kimi/sessions', pi: '.pi/agent/sessions', }; diff --git a/gnome/prefs.js b/gnome/prefs.js index 08d4b824..dafc1a2f 100644 --- a/gnome/prefs.js +++ b/gnome/prefs.js @@ -8,6 +8,7 @@ const PROVIDERS = [ { id: 'codex', label: 'Codex' }, { id: 'copilot', label: 'Copilot' }, { id: 'cursor', label: 'Cursor' }, + { id: 'devin', label: 'Devin' }, { id: 'droid', label: 'Droid' }, { id: 'gemini', label: 'Gemini' }, { id: 'goose', label: 'Goose' }, diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index e6cf9aa2..aab72636 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1025,6 +1025,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case cursor = "Cursor" case cursorAgent = "Cursor Agent" case copilot = "Copilot" + case devin = "Devin" case droid = "Droid" case gemini = "Gemini" case ibmBob = "IBM Bob" @@ -1067,6 +1068,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .cursor: "cursor" case .cursorAgent: "cursor-agent" case .copilot: "copilot" + case .devin: "devin" case .droid: "droid" case .gemini: "gemini" case .ibmBob: "ibm-bob" diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift index c27acc18..5fc907a6 100644 --- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -207,3 +207,74 @@ enum CLICurrencyConfig { } } } + +struct CodeburnCLIConfigStore { + let homeDirectory: String + + init(homeDirectory: String = NSHomeDirectory()) { + self.homeDirectory = homeDirectory + } + + private var configDir: String { + (homeDirectory as NSString).appendingPathComponent(".config/codeburn") + } + private var configPath: String { + (configDir as NSString).appendingPathComponent("config.json") + } + private var lockPath: String { + (configDir as NSString).appendingPathComponent(".config.lock") + } + + func loadDevinAcuUsdRate() -> Double? { + guard + let data = try? SafeFile.read(from: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let devin = json["devin"] as? [String: Any], + let rate = devin["acuUsdRate"] as? Double, + rate.isFinite, + rate > 0 + else { + return nil + } + return rate + } + + func persistDevinAcuUsdRate(_ rate: Double) { + guard rate.isFinite, rate > 0 else { return } + do { + try SafeFile.withExclusiveLock(at: lockPath) { + var existing: [String: Any] = [:] + if let data = try? SafeFile.read(from: configPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + + var devin = existing["devin"] as? [String: Any] ?? [:] + devin["acuUsdRate"] = rate + existing["devin"] = devin + + guard let data = try? JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys] + ) else { + return + } + try SafeFile.write(data, to: configPath, mode: 0o600) + } + } catch { + NSLog("CodeBurn: failed to persist Devin ACU config: \(error)") + } + } +} + +enum CLIDevinConfig { + private static let store = CodeburnCLIConfigStore() + + static func loadAcuUsdRate() -> Double? { + store.loadDevinAcuUsdRate() + } + + static func persistAcuUsdRate(_ rate: Double) { + store.persistDevinAcuUsdRate(rate) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index d86cf36d..0c0a81f7 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -490,6 +490,7 @@ extension ProviderFilter { case .cursor: return Theme.categoricalCursor case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) + case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/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) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 73a16be9..53c1755e 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -18,10 +18,13 @@ struct SettingsView: View { CodexSettingsTab() .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } + DevinSettingsTab() + .tabItem { Label("Devin", systemImage: "flame.fill") } + AboutSettingsTab() .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 520, height: 400) + .frame(width: 520, height: 430) } } @@ -468,6 +471,81 @@ private struct CodexConnectionRow: View { } } +// MARK: - Devin + +private struct DevinSettingsTab: View { + @State private var rateText: String = "" + @State private var statusText: String = "" + + private var parsedRate: Double? { + let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil } + return value + } + + var body: some View { + Form { + Section("ACU Conversion") { + HStack(alignment: .center, spacing: 10) { + Text("USD per ACU") + Spacer() + TextField("", text: $rateText) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(width: 96) + .accessibilityLabel("USD per ACU") + Text("USD") + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .leading) + } + + Button("Save") { + saveRate() + } + .buttonStyle(.borderedProminent) + .disabled(parsedRate == nil) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + Section { + Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + .onAppear { + if let rate = CLIDevinConfig.loadAcuUsdRate() { + rateText = Self.format(rate) + } + } + } + + private func saveRate() { + guard let rate = parsedRate else { return } + CLIDevinConfig.persistAcuUsdRate(rate) + rateText = Self.format(rate) + statusText = "Saved. Refresh CodeBurn to recalculate Devin cost." + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 6 + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} + // MARK: - About private struct AboutSettingsTab: View { diff --git a/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift new file mode 100644 index 00000000..69e9f552 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("CLI Devin config", .serialized) +struct CLIDevinConfigTests { + private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + try body(root, CodeburnCLIConfigStore(homeDirectory: root.path)) + } + + private func configURL(in home: URL) -> URL { + home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codeburn", isDirectory: true) + .appendingPathComponent("config.json") + } + + private func writeConfig(_ object: [String: Any], in home: URL) throws { + let url = configURL(in: home) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url) + } + + private func readConfig(in home: URL) throws -> [String: Any] { + let data = try Data(contentsOf: configURL(in: home)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("missing config has no ACU rate") + func missingConfigHasNoRate() throws { + try withTemporaryStore { _, store in + #expect(store.loadDevinAcuUsdRate() == nil) + } + } + + @Test("persists and loads ACU rate") + func persistsAndLoadsRate() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(2.25) + + #expect(store.loadDevinAcuUsdRate() == 2.25) + } + } + + @Test("preserves existing config while adding Devin rate") + func preservesExistingConfig() throws { + try withTemporaryStore { home, store in + try writeConfig([ + "currency": [ + "code": "EUR", + "symbol": "\u{20AC}" + ] + ], in: home) + + store.persistDevinAcuUsdRate(3.5) + + let json = try readConfig(in: home) + let currency = try #require(json["currency"] as? [String: Any]) + let devin = try #require(json["devin"] as? [String: Any]) + #expect(currency["code"] as? String == "EUR") + #expect(devin["acuUsdRate"] as? Double == 3.5) + } + } + + @Test("ignores invalid rates") + func ignoresInvalidRates() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(1.75) + store.persistDevinAcuUsdRate(0) + store.persistDevinAcuUsdRate(-2) + store.persistDevinAcuUsdRate(.infinity) + + #expect(store.loadDevinAcuUsdRate() == 1.75) + } + } + + @Test("loads only positive finite numeric rates") + func loadsOnlyPositiveFiniteNumericRates() throws { + try withTemporaryStore { home, store in + try writeConfig(["devin": ["acuUsdRate": 0]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + + try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + } + } +} diff --git a/package.json b/package.json index 73224026..f4e7208e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "bundle-litellm": "node scripts/bundle-litellm.mjs", "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"", "dev": "tsx src/cli.ts", + "dev:mac:menubar": "npm run build && cd mac && CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN=\"node $(pwd)/../dist/cli.js\" swift run", + "local:mac:menubar": "scripts/install-local-mac-menubar.sh", "test": "vitest", "prepublishOnly": "npm run build" }, @@ -22,6 +24,7 @@ "cursor", "codex", "kimi", + "devin", "ibm-bob", "opencode", "pi", diff --git a/scripts/install-local-mac-menubar.sh b/scripts/install-local-mac-menubar.sh new file mode 100755 index 00000000..46667a44 --- /dev/null +++ b/scripts/install-local-mac-menubar.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Build and install a local CodeBurn CLI tarball, then launch the menubar app +# built from this checkout. Useful when upstream macOS releases lag behind a +# fork/branch you want to test. + +set -euo pipefail + +REPLACE_APP=0 +SYSTEM_APP=0 +MIN_NODE_VERSION="22.13.0" + +usage() { + cat <<'USAGE' +Usage: + npm run local:mac:menubar -- [--replace-app] [--system-app] + +Options: + --replace-app Replace the installed app with the locally built app before launching. + --system-app Install to /Applications/CodeBurnMenubar.app instead of ~/Applications/CodeBurnMenubar.app. + -h, --help Show this help. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --replace-app) + REPLACE_APP=1 + shift + ;; + --system-app) + SYSTEM_APP=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/.." && pwd) +} + +require_node_version() { + if ! command -v node >/dev/null 2>&1; then + echo "Node ${MIN_NODE_VERSION}+ is required, but node was not found." >&2 + exit 1 + fi + + local node_version + node_version="$(node -p "process.versions.node")" + if ! node -e " +const current = process.versions.node.split('.').map(Number) +const minimum = '${MIN_NODE_VERSION}'.split('.').map(Number) +const ok = current[0] > minimum[0] + || (current[0] === minimum[0] && current[1] > minimum[1]) + || (current[0] === minimum[0] && current[1] === minimum[1] && current[2] >= minimum[2]) +process.exit(ok ? 0 : 1) +"; then + echo "Node ${MIN_NODE_VERSION}+ is required, but found ${node_version}." >&2 + exit 1 + fi +} + +ROOT="$(repo_root)" +PACK_DIR="${TMPDIR:-/tmp}/codeburn-local-pack" +SUPPORT_DIR="${HOME}/Library/Application Support/CodeBurn" +WRAPPER_PATH="${SUPPORT_DIR}/codeburn-menubar-cli" +PERSISTED_CLI_PATH="${SUPPORT_DIR}/codeburn-cli-path.v1" +if [[ "${SYSTEM_APP}" -eq 1 ]]; then + INSTALLED_APP_PATH="/Applications/CodeBurnMenubar.app" +else + INSTALLED_APP_PATH="${HOME}/Applications/CodeBurnMenubar.app" +fi + +cd "${ROOT}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script is for the macOS menubar app." >&2 + exit 1 +fi + +require_node_version + +echo "==> Building CLI" +npm run build +NODE_PATH="$(node -p "process.execPath")" +if [[ ! -x "${NODE_PATH}" ]]; then + echo "Node executable was not found at ${NODE_PATH}." >&2 + exit 1 +fi +NODE_BIN_DIR="$(dirname "${NODE_PATH}")" + +echo "==> Packing CLI" +rm -rf "${PACK_DIR}" +mkdir -p "${PACK_DIR}" +TARBALL_NAME="$(npm pack --silent --pack-destination "${PACK_DIR}")" +TARBALL_PATH="${PACK_DIR}/${TARBALL_NAME}" + +echo "==> Installing global CLI from ${TARBALL_PATH}" +npm install -g "${TARBALL_PATH}" + +CLI_PATH="$(command -v codeburn || true)" +if [[ -z "${CLI_PATH}" ]]; then + echo "Global codeburn command was not found after npm install -g." >&2 + exit 1 +fi + +echo "==> Writing menubar CLI wrapper" +mkdir -p "${SUPPORT_DIR}" +{ + printf '%s\n' '#!/bin/sh' + printf 'export PATH=%s:"${PATH:-}"\n' "$(printf '%q' "${NODE_BIN_DIR}")" + printf 'exec %s "$@"\n' "$(printf '%q' "${CLI_PATH}")" +} > "${WRAPPER_PATH}" +chmod 755 "${WRAPPER_PATH}" + +echo "==> Persisting CLI path: ${WRAPPER_PATH}" +printf '%s\n' "${WRAPPER_PATH}" > "${PERSISTED_CLI_PATH}" +chmod 600 "${PERSISTED_CLI_PATH}" + +VERSION="$(node -p "require('./package.json').version")-local" +echo "==> Building menubar app (${VERSION})" +mac/Scripts/package-app.sh "v${VERSION}" + +APP_PATH="${ROOT}/mac/.build/dist/CodeBurnMenubar.app" +if [[ ! -d "${APP_PATH}" ]]; then + echo "Menubar app was not built at ${APP_PATH}" >&2 + exit 1 +fi + +if [[ "${REPLACE_APP}" -eq 1 ]]; then + echo "==> Replacing ${INSTALLED_APP_PATH}" + pkill -f CodeBurnMenubar 2>/dev/null || true + if [[ "${SYSTEM_APP}" -eq 1 ]]; then + sudo mkdir -p "$(dirname "${INSTALLED_APP_PATH}")" + sudo rm -rf "${INSTALLED_APP_PATH}" + sudo cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}" + sudo chown -R root:wheel "${INSTALLED_APP_PATH}" + else + mkdir -p "$(dirname "${INSTALLED_APP_PATH}")" + rm -rf "${INSTALLED_APP_PATH}" + cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}" + fi + APP_PATH="${INSTALLED_APP_PATH}" +fi + +echo "==> Restarting menubar app" +pkill -f CodeBurnMenubar 2>/dev/null || true +open "${APP_PATH}" + +echo "" +echo "Ready." +echo "CLI: ${CLI_PATH}" +echo "App: ${APP_PATH}" diff --git a/src/config.ts b/src/config.ts index eca65f5a..d94be82b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,9 @@ export type CodeburnConfig = { code: string symbol?: string } + devin?: { + acuUsdRate?: number + } plan?: Plan plans?: PlanConfigMap modelAliases?: Record diff --git a/src/parser.ts b/src/parser.ts index 55eb17d9..9dfe3c9d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1641,7 +1641,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, @@ -1812,6 +1812,11 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string // A 0-turn cache entry may just mean the server was unavailable last run. if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length) + // Devin transcript usage is enriched from sessions.db. The cache fingerprint + // only tracks the transcript JSON, so reparse to pick up DB-side project, + // title, model, and timestamp changes. + if (providerName === 'devin') return true + if (providerName !== 'gemini') return false return cached.turns.some(turn => diff --git a/src/providers/devin.ts b/src/providers/devin.ts new file mode 100644 index 00000000..7fd6bec2 --- /dev/null +++ b/src/providers/devin.ts @@ -0,0 +1,395 @@ +import { readdir, stat } from "fs/promises"; +import { basename, join } from "path"; +import { homedir } from "os"; + +import { getShortModelName } from "../models.js"; +import { booleanValue, openDatabase } from "../sqlite.js"; +import { readConfig } from "../config.js"; +import type { + Provider, + SessionParser, + SessionSource, + ParsedProviderCall, +} from "./types.js"; +import { readSessionFile } from "../fs-utils.js"; + +type AgentTrajectory = { + schema_version: string; + session_id?: string; + agent: Agent; + steps: T[]; +}; + +type Agent = { + name: string; + version: string; + model_name?: string; +}; + +type ToolCall = { + tool_call_id: string; + function_name: string; + arguments: unknown; +}; + +type DevinMetadata = { + created_at?: string; + committed_acu_cost?: number; + generation_model?: string; + is_user_input?: boolean; + num_tokens?: number; + request_id?: string; + finish_reason?: string; + metrics?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_tokens?: number; + cache_read_tokens?: number; + tokens_per_sec?: number; + total_time_ms?: number; + ttft_ms?: number; + tpot_ms?: number; + }; +}; + +type Step = { + step_id: number; + source: string; + model_name?: string; + message: string; + tool_calls?: Array; +}; + +type DevinStep = Step & { metadata?: DevinMetadata }; + +type DevinAgentTrajectory = AgentTrajectory; + +type DevinSessionMetadata = { + id: string; + workingDirectory: string; + model: string; + title?: string; + createdAt: string; + lastActivityAt: string; + hidden: boolean; +}; + +type DevinUsage = { + committedAcuCost: number; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +}; + +const DEFAULT_DEVIN_CLI_DIR = join( + homedir(), + ".local", + "share", + "devin", + "cli", +); + +const DEFAULT_MODEL_NAME = "devin"; +const DEVIN_PROVIDER_NAME = "devin"; +const DEVIN_PROVIDER_DISPLAY_NAME = "Devin"; +const DEVIN_TRASNCRIPTS_SUBDIR = "transcripts"; +const DEVIN_SESSIONS_DB = "sessions.db"; + +function parseTranscript(raw: string): DevinAgentTrajectory | null { + try { + return JSON.parse(raw) as DevinAgentTrajectory; + } catch { + return null; + } +} + +function parseNumericTimestamp(value: number): string { + const millis = value < 10_000_000_000 ? value * 1000 : value; + return new Date(millis).toISOString(); +} + +function getUsage( + metadata: DevinMetadata | undefined | null, +): DevinUsage | null { + if (!metadata) return null; + const metrics = metadata.metrics; + + const hasAnyUsage = [ + metadata.committed_acu_cost, + metrics?.input_tokens, + metrics?.output_tokens, + metrics?.cache_creation_tokens, + metrics?.cache_read_tokens, + ].some((x) => x !== undefined && x !== null && x > 0); + + if (!hasAnyUsage) return null; + + return { + committedAcuCost: metadata.committed_acu_cost ?? 0, + inputTokens: metrics?.input_tokens ?? 0, + outputTokens: metrics?.output_tokens ?? 0, + cacheCreationInputTokens: metrics?.cache_creation_tokens ?? 0, + cacheReadInputTokens: metrics?.cache_read_tokens ?? 0, + }; +} + +function getSessionId( + source: SessionSource, + transcript: DevinAgentTrajectory, +): string { + const fromTranscript = transcript.session_id?.trim(); + return fromTranscript || basename(source.path, ".json"); +} + +function projectNameFromPath(path: string): string { + const normalized = path.trim().replace(/[/\\]+$/, ""); + return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path; +} + +function getProjectName( + source: SessionSource, + session: DevinSessionMetadata | null, +): string { + if (session?.workingDirectory) + return projectNameFromPath(session.workingDirectory); + if (session?.title) return session.title; + return source.project; +} + +function getProjectPath( + session: DevinSessionMetadata | null, +): string | undefined { + return session?.workingDirectory; +} + +function getTimestamp( + step: DevinStep, + session: DevinSessionMetadata | null, +): string | undefined { + return [ + step.metadata?.created_at, + session?.lastActivityAt, + session?.createdAt, + ] + .filter(Boolean) + .shift(); +} + +function getModelName( + transcript: DevinAgentTrajectory, + step: DevinStep, + session: DevinSessionMetadata | null, +): string { + return ( + [ + step.metadata?.generation_model, + step.model_name, + transcript.agent?.model_name, + session?.model, + ] + .filter(Boolean) + .shift() || DEFAULT_MODEL_NAME + ); +} + +function getToolNames(step: DevinStep): string[] { + return (step.tool_calls ?? []).map((call) => call.function_name); +} + +function getFirstUserMessageBeforeStep( + steps: DevinStep[], + index: number, +): string | null { + for (let i = index - 1; i >= 0; i--) { + const step = steps[i]; + if (!step?.metadata?.is_user_input) continue; + const message = step.message?.trim(); + if (message) return message; + } + return null; +} + +function loadSessionMetadata( + dbPath: string, +): Map { + const sessions = new Map(); + let db: ReturnType | null = null; + try { + db = openDatabase(dbPath); + const rows = db.query<{ + id: string; + working_directory: string; + model: string; + title: string | null; + created_at: number; + last_activity_at: number; + hidden: number; + }>( + `SELECT id, working_directory, model, title, created_at, last_activity_at, hidden + FROM sessions`, + ); + for (const row of rows) { + if (!row.id) continue; + sessions.set(row.id, { + id: row.id, + workingDirectory: row.working_directory, + model: row.model, + title: row.title ?? undefined, + createdAt: parseNumericTimestamp(row.created_at), + lastActivityAt: parseNumericTimestamp(row.last_activity_at), + hidden: booleanValue(row.hidden), + }); + } + } catch { + return sessions; + } finally { + db?.close(); + } + return sessions; +} + +function isValidAcuUsdRate(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +async function getCostFactor(): Promise { + const configRate = (await readConfig()).devin?.acuUsdRate; + return isValidAcuUsdRate(configRate) ? configRate : null; +} + +class DevinSessionParser implements SessionParser { + constructor( + private source: SessionSource, + private seenKeys: Set, + private sessionMetadata: Map, + ) {} + + async *parse(): AsyncGenerator { + const raw = await readSessionFile(this.source.path); + if (!raw) return; + + const transcript = parseTranscript(raw); + if (!transcript?.steps) return; + + const sessionId = getSessionId(this.source, transcript); + const session = this.sessionMetadata.get(sessionId) ?? null; + if (session?.hidden) return; + + const project = getProjectName(this.source, session); + const projectPath = getProjectPath(session); + const costFactor = await getCostFactor(); + if (costFactor === null) return; + + for (let index = 0; index < transcript.steps.length; index++) { + const step = transcript.steps[index]; + if (step.metadata?.is_user_input) continue; + + const usage = getUsage(step.metadata); + if (!usage) continue; + + const timestamp = getTimestamp(step, session) ?? ""; + + const deduplicationKey = `devin:${sessionId}:${step.step_id}`; + + if (this.seenKeys.has(deduplicationKey)) continue; + this.seenKeys.add(deduplicationKey); + + const model = getModelName(transcript, step, session); + const tools = getToolNames(step); + const userMessage = + getFirstUserMessageBeforeStep(transcript.steps, index) ?? ""; + + yield { + provider: DEVIN_PROVIDER_NAME, + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: usage.committedAcuCost * costFactor, + tools, + bashCommands: [], + timestamp, + speed: "standard", + deduplicationKey, + userMessage, + sessionId, + project, + projectPath, + }; + } + } +} + +export function createDevinProvider(cliDir: string): Provider { + const sessionsDbPath = join(cliDir, DEVIN_SESSIONS_DB); + let sessionMetadata: Map | null = null; + + const getSessionMetadata = () => { + if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath); + return sessionMetadata; + }; + + return { + name: DEVIN_PROVIDER_NAME, + displayName: DEVIN_PROVIDER_DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model); + }, + + toolDisplayName(rawTool: string): string { + return rawTool; + }, + + async discoverSessions(): Promise { + if ((await getCostFactor()) === null) return []; + + const transcriptsDir = join(cliDir, DEVIN_TRASNCRIPTS_SUBDIR); + const entries = await readdir(transcriptsDir).catch(() => []); + const metadata = getSessionMetadata(); + const sources: SessionSource[] = []; + + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + + const filePath = join(transcriptsDir, entry); + const pathStats = await stat(filePath).catch(() => null); + + if (!pathStats?.isFile()) continue; + + const session = metadata.get(basename(filePath, ".json")) ?? null; + if (session?.hidden) continue; + + const tmpSource: SessionSource = { + path: filePath, + project: DEVIN_PROVIDER_NAME, + provider: DEVIN_PROVIDER_NAME, + }; + + const project = getProjectName(tmpSource, session); + + sources.push({ + path: filePath, + project, + provider: DEVIN_PROVIDER_NAME, + }); + } + + return sources; + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return new DevinSessionParser(source, seenKeys, getSessionMetadata()); + }, + }; +} + +export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR); diff --git a/src/providers/index.ts b/src/providers/index.ts index 98980a62..cadcb086 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -4,6 +4,7 @@ import { codebuff } from './codebuff.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' +import { devin } from './devin.js' import { gemini } from './gemini.js' import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' @@ -136,7 +137,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()]) diff --git a/src/session-cache.ts b/src/session-cache.ts index 53f7a78c..85a5171a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -375,4 +375,3 @@ export async function cleanupOrphanedTempFiles(): Promise { } } catch {} } - diff --git a/src/sqlite.ts b/src/sqlite.ts index 3fb3c6a8..eee5443d 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -137,3 +137,7 @@ export function openDatabase(path: string): SqliteDatabase { }, } } + +export function booleanValue(value: number): boolean { + return value === 1 +} diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index f310a564..c531d6ae 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index. describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/devin.test.ts b/tests/providers/devin.test.ts new file mode 100644 index 00000000..5197140f --- /dev/null +++ b/tests/providers/devin.test.ts @@ -0,0 +1,336 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createDevinProvider } from '../../src/providers/devin.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string +const originalHome = process.env['HOME'] + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-')) + process.env['HOME'] = tmpDir +}) + +afterEach(async () => { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function configureDevinRate(rate = 1): Promise { + await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({ + devin: { acuUsdRate: rate }, + })) +} + +async function writeTranscript(name: string, transcript: unknown): Promise { + const transcriptsDir = join(tmpDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, name) + await writeFile(filePath, JSON.stringify(transcript)) + return filePath +} + +async function parseTranscript(filePath: string, project = 'devin'): Promise { + const provider = createDevinProvider(tmpDir) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +function createSessionsDb(): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(join(tmpDir, 'sessions.db')) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + hidden INTEGER NOT NULL DEFAULT 0 + ) + `) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1) + db.close() +} + +describe('devin provider', () => { + it('discovers Devin CLI transcript json files', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] }) + await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}') + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'devin', provider: 'devin' }, + ]) + }) + + it('stays disabled until the Devin ACU rate is configured', async () => { + await writeTranscript('glimmer-platinum.json', { + session_id: 'session-123', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([]) + }) + + it('parses per-step ACUs, tokens, tools, and model resolution', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { + schema_version: '1', + session_id: 'session-123', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'please inspect the repo', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + model_name: 'step-model', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.02076149918138981, + generation_model: 'claude-opus-4-6', + metrics: { + input_tokens: 100, + output_tokens: 20, + cache_creation_tokens: 10, + cache_read_tokens: 5, + }, + }, + tool_calls: [{ function_name: 'read_file' }], + }, + { + step_id: 3, + model_name: 'claude-sonnet-4-6', + metadata: { + created_at: '2027-01-15T08:00:02.000Z', + committed_acu_cost: 0.005421000067144632, + metrics: { input_tokens: 1 }, + }, + tool_calls: [{ function_name: 'str_replace' }], + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15) + expect(calls[0]).toMatchObject({ + provider: 'devin', + model: 'claude-opus-4-6', + inputTokens: 100, + outputTokens: 20, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cachedInputTokens: 5, + costUSD: 0.02076149918138981, + tools: ['read_file'], + timestamp: '2027-01-15T08:00:01.000Z', + deduplicationKey: 'devin:session-123:2', + userMessage: 'please inspect the repo', + sessionId: 'session-123', + }) + expect(calls[1]).toMatchObject({ + model: 'claude-sonnet-4-6', + timestamp: '2027-01-15T08:00:02.000Z', + tools: ['str_replace'], + deduplicationKey: 'devin:session-123:3', + }) + }) + + it('includes token-only steps and skips user-input or empty steps', async () => { + await configureDevinRate() + const filePath = await writeTranscript('token-only.json', { + session_id: 'token-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 'user-cost', + metadata: { + is_user_input: true, + committed_acu_cost: 99, + metrics: { input_tokens: 99 }, + }, + }, + { step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } }, + { + step_id: 'tokens', + metadata: { + created_at: '2026-06-05T10:00:01.000Z', + metrics: { output_tokens: 42 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('agent-model') + expect(calls[0]!.outputTokens).toBe(42) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('converts ACUs to costUSD using the configured Devin rate', async () => { + await configureDevinRate(2.5) + const filePath = await writeTranscript('configured-rate.json', { + session_id: 'configured-rate', + agent: { model_name: 'agent-model' }, + steps: [ + { step_id: 's1', metadata: { committed_acu_cost: 0.4 } }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(1, 12) + }) + + it('falls back to filename session id and deduplicates by step id', async () => { + await configureDevinRate() + const filePath = await writeTranscript('fallback-session.json', { + steps: [ + { + step_id: 1, + metadata: { + request_id: 'req-1', + committed_acu_cost: 0.1, + }, + }, + { + step_id: 2, + metadata: { + created_at: '2026-06-05T10:00:00.000Z', + committed_acu_cost: 0.2, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session']) + expect(calls.map(c => c.model)).toEqual(['devin', 'devin']) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'devin:fallback-session:1', + 'devin:fallback-session:2', + ]) + }) + + it('ignores array-root and malformed transcripts', async () => { + await configureDevinRate() + const arrayPath = await writeTranscript('array.json', []) + const malformedPath = join(tmpDir, 'transcripts', 'bad.json') + await writeFile(malformedPath, '{') + + expect(await parseTranscript(arrayPath)).toEqual([]) + expect(await parseTranscript(malformedPath)).toEqual([]) + }) + + it('deduplicates calls with a shared seen key set', async () => { + await configureDevinRate() + const filePath = await writeTranscript('dupe.json', { + session_id: 'dupe-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + const provider = createDevinProvider(tmpDir) + const seenKeys = new Set() + const source = { path: filePath, project: 'devin', provider: 'devin' } + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) +}) + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('devin provider sessions.db enrichment', () => { + it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { + session_id: 'db-session', + steps: [ + { + step_id: 's1', + metadata: { + committed_acu_cost: 0.25, + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath, 'fallback-project') + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'claude-sonnet-4-6', + project: 'codeburn', + projectPath: '/Users/example/work/codeburn', + timestamp: '2027-01-15T08:00:10.000Z', + costUSD: 0.25, + }) + }) + + it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] }) + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'codeburn', provider: 'devin' }, + ]) + }) + + it('skips sessions hidden in sessions.db', async () => { + await configureDevinRate() + createSessionsDb() + await writeTranscript('hidden-session.json', { + session_id: 'hidden-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + + const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json')) + expect(calls).toEqual([]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e1536101..3f1cfb9f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "rootDir": "src", "declaration": true, "sourceMap": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"]