diff --git a/Fixtures/OpenCodeMessages/expected-output.txt b/Fixtures/OpenCodeMessages/expected-output.txt new file mode 100644 index 0000000..2a6f038 --- /dev/null +++ b/Fixtures/OpenCodeMessages/expected-output.txt @@ -0,0 +1,6 @@ +assistant_message_count=6 +session_count=2 + +# Verification SQL (for reference): +# sqlite3 sample-opencode.db "SELECT COUNT(*) FROM message WHERE json_extract(data, '$.role') = 'assistant';" +# sqlite3 sample-opencode.db "SELECT COUNT(*) FROM session;" diff --git a/Fixtures/OpenCodeMessages/sample-opencode.db b/Fixtures/OpenCodeMessages/sample-opencode.db new file mode 100644 index 0000000..a567114 Binary files /dev/null and b/Fixtures/OpenCodeMessages/sample-opencode.db differ diff --git a/README.ko.md b/README.ko.md index 087951d..f6b4f64 100644 --- a/README.ko.md +++ b/README.ko.md @@ -4,10 +4,10 @@ 평소처럼 AI 코딩하면, 메뉴 막대에서 Tokenmon이 조용히 모입니다. -Tokenmon은 Claude Code와 Codex 사용을 가볍고 귀여운 수집 경험으로 -바꿔주는 macOS 메뉴 막대 앱입니다. 코딩하는 동안 탐험이 쌓이고, 가끔 -새로운 Tokenmon을 만나며, 포획 결과와 Dex가 계정 없이 Mac 안에 -차곡차곡 쌓입니다. +Tokenmon은 Claude Code, Codex, OpenCode, Gemini, Cursor 사용을 가볍고 +귀여운 수집 경험으로 바꿔주는 macOS 메뉴 막대 앱입니다. 코딩하는 동안 +탐험이 쌓이고, 가끔 새로운 Tokenmon을 만나며, 포획 결과와 Dex가 계정 +없이 Mac 안에 차곡차곡 쌓입니다. ![Tokenmon animated menu hero](assets/screenshots/variants/korean/dark/menu-hero.gif) diff --git a/README.md b/README.md index a9294d3..5b8b023 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Turn everyday AI coding into a quiet creature-collection loop. Tokenmon is a macOS menu bar companion for people who spend their day in Claude -Code and Codex. Keep coding like normal, let exploration build in the -background, and watch new creatures appear, resolve, and fill your Dex without -asking for an account or your prompt history. +Code, Codex, OpenCode, Gemini, and Cursor. Keep coding like normal, let +exploration build in the background, and watch new creatures appear, resolve, +and fill your Dex without asking for an account or your prompt history. ![Tokenmon animated menu hero](assets/screenshots/variants/english/dark/menu-hero.gif) diff --git a/Sources/TokenmonApp/Popover/TokenmonNowTab.swift b/Sources/TokenmonApp/Popover/TokenmonNowTab.swift index 29af1c7..6f027fb 100644 --- a/Sources/TokenmonApp/Popover/TokenmonNowTab.swift +++ b/Sources/TokenmonApp/Popover/TokenmonNowTab.swift @@ -105,7 +105,7 @@ struct TokenmonNowTab: View { } .padding(.horizontal, 14) .padding(.top, 12) - .padding(.bottom, 10) + .padding(.bottom, 20) .frame(width: 300, alignment: .topLeading) } @@ -360,23 +360,33 @@ private struct TokenmonProviderStatusChip: View { } private var presentationModel: Presentation { + let providerTint: Color = { + switch provider { + case .claude: return .orange + case .codex: return .teal + case .gemini: return .indigo + case .cursor: return .green + case .opencode: return .purple + } + }() + if provider != .cursor, let installed = cliInstalled, !installed { return Presentation(tint: .secondary, accessibilityState: TokenmonL10n.string("provider.status.not_installed")) } guard let healthSummary else { - return Presentation(tint: .secondary, accessibilityState: TokenmonL10n.string("provider.status.unavailable")) + return Presentation(tint: providerTint.opacity(0.5), accessibilityState: TokenmonL10n.string("provider.status.unavailable")) } switch healthSummary.healthState { case "active", "connected": - return Presentation(tint: .green, accessibilityState: TokenmonL10n.string("provider.status.connected")) + return Presentation(tint: providerTint, accessibilityState: TokenmonL10n.string("provider.status.connected")) case "missing_configuration": - return Presentation(tint: .orange, accessibilityState: TokenmonL10n.string("provider.status.needs_setup")) + return Presentation(tint: providerTint.opacity(0.6), accessibilityState: TokenmonL10n.string("provider.status.needs_setup")) case "degraded", "unsupported": - return Presentation(tint: .red, accessibilityState: TokenmonL10n.string("provider.status.needs_attention")) + return Presentation(tint: providerTint.opacity(0.4), accessibilityState: TokenmonL10n.string("provider.status.needs_attention")) default: - return Presentation(tint: .secondary, accessibilityState: healthSummary.healthState) + return Presentation(tint: providerTint.opacity(0.5), accessibilityState: healthSummary.healthState) } } @@ -446,6 +456,8 @@ private extension ProviderCode { return "Gemini" case .cursor: return "Cursor" + case .opencode: + return "OpenCode" } } } diff --git a/Sources/TokenmonApp/Popover/TokenmonPopoverContainer.swift b/Sources/TokenmonApp/Popover/TokenmonPopoverContainer.swift index 2605059..264eb2f 100644 --- a/Sources/TokenmonApp/Popover/TokenmonPopoverContainer.swift +++ b/Sources/TokenmonApp/Popover/TokenmonPopoverContainer.swift @@ -11,7 +11,7 @@ struct TokenmonPopoverContainerActions { struct TokenmonPopoverContainer: View { static let width: CGFloat = 360 - static let height: CGFloat = 520 + static let height: CGFloat = 560 static let contentWidth: CGFloat = 300 @ObservedObject var model: TokenmonMenuModel diff --git a/Sources/TokenmonApp/Popover/TokenmonProviderIndicator.swift b/Sources/TokenmonApp/Popover/TokenmonProviderIndicator.swift index 250732e..951229f 100644 --- a/Sources/TokenmonApp/Popover/TokenmonProviderIndicator.swift +++ b/Sources/TokenmonApp/Popover/TokenmonProviderIndicator.swift @@ -59,6 +59,7 @@ struct TokenmonProviderIndicator: View { case .codex: return "Codex" case .gemini: return "Gemini" case .cursor: return "Cursor" + case .opencode: return "OpenCode" } } diff --git a/Sources/TokenmonApp/Popover/TokenmonTokensTab.swift b/Sources/TokenmonApp/Popover/TokenmonTokensTab.swift index 2aa72c9..a1a567b 100644 --- a/Sources/TokenmonApp/Popover/TokenmonTokensTab.swift +++ b/Sources/TokenmonApp/Popover/TokenmonTokensTab.swift @@ -72,7 +72,7 @@ struct TokenmonTokensTab: View { } } - private var providerSplitOrder: [ProviderCode] { [.claude, .codex, .gemini, .cursor] } + private var providerSplitOrder: [ProviderCode] { [.claude, .codex, .gemini, .cursor, .opencode] } private func providerColor(_ provider: ProviderCode) -> Color { switch provider { @@ -80,6 +80,7 @@ struct TokenmonTokensTab: View { case .codex: return .teal case .gemini: return .indigo case .cursor: return .green + case .opencode: return .purple } } @@ -89,6 +90,7 @@ struct TokenmonTokensTab: View { case .codex: return "Codex" case .gemini: return "Gemini" case .cursor: return "Cursor" + case .opencode: return "OpenCode" } } diff --git a/Sources/TokenmonApp/Resources/en.lproj/Localizable.strings b/Sources/TokenmonApp/Resources/en.lproj/Localizable.strings index 75cd8f6..f16ad33 100644 --- a/Sources/TokenmonApp/Resources/en.lproj/Localizable.strings +++ b/Sources/TokenmonApp/Resources/en.lproj/Localizable.strings @@ -312,3 +312,19 @@ "settings.providers.source_mode.cursor_usage_export_api" = "Usage export sync"; "settings.providers.next_step.cursor_missing_configuration" = "Sync Cursor usage from Tokenmon to import the latest managed-only accounting data."; "settings.feedback.cursor_sync_started" = "Syncing Cursor usage into Tokenmon…"; + +"provider.opencode.missing.title" = "OpenCode Not Found"; +"provider.opencode.missing.detail" = "Install OpenCode to start tracking token usage"; +"provider.opencode.missing.custom_path_detail" = "Custom path does not point to a valid OpenCode binary"; +"provider.opencode.ready.title" = "OpenCode Ready"; +"provider.opencode.ready.detail" = "OpenCode is installed and session data is available"; +"provider.opencode.connected.title" = "OpenCode Connected"; +"provider.opencode.connected.detail" = "Tracking OpenCode sessions in the background"; +"provider.opencode.repair.title" = "OpenCode Needs Setup"; +"provider.opencode.repair.detail" = "OpenCode was found but needs configuration"; +"provider.opencode.repair.action" = "Set Up OpenCode"; +"provider.opencode.sync.title" = "OpenCode Sync"; +"provider.opencode.sync.detail" = "OpenCode session data is read from the local database"; +"provider.opencode.sync.action" = "Sync Now"; +"provider.opencode.sync_again.action" = "Sync Again"; +"provider.install.opencode.success" = "OpenCode has been configured for Tokenmon. Session data will be read from the local database."; diff --git a/Sources/TokenmonApp/Resources/ko.lproj/Localizable.strings b/Sources/TokenmonApp/Resources/ko.lproj/Localizable.strings index fb4d221..d0efe99 100644 --- a/Sources/TokenmonApp/Resources/ko.lproj/Localizable.strings +++ b/Sources/TokenmonApp/Resources/ko.lproj/Localizable.strings @@ -658,3 +658,19 @@ "settings.providers.source_mode.cursor_usage_export_api" = "Usage export 동기화"; "settings.providers.next_step.cursor_missing_configuration" = "Tokenmon에서 Cursor usage sync를 실행해 최신 managed-only accounting 데이터를 가져오세요."; "settings.feedback.cursor_sync_started" = "Cursor 사용량을 Tokenmon으로 동기화하는 중…"; + +"provider.opencode.missing.title" = "OpenCode 감지 안 됨"; +"provider.opencode.missing.detail" = "OpenCode를 설치하거나 경로를 지정하세요."; +"provider.opencode.missing.custom_path_detail" = "사용자 지정 경로에서 OpenCode를 찾지 못했습니다. 경로를 수정하거나 자동 감지로 되돌리세요."; +"provider.opencode.ready.title" = "OpenCode 준비됨"; +"provider.opencode.ready.detail" = "OpenCode가 설치되어 있으며 세션 데이터를 사용할 수 있습니다."; +"provider.opencode.connected.title" = "OpenCode 연결됨"; +"provider.opencode.connected.detail" = "백그라운드에서 OpenCode 세션을 추적하고 있습니다."; +"provider.opencode.repair.title" = "OpenCode 설정 필요"; +"provider.opencode.repair.detail" = "OpenCode가 감지되었지만 추가 설정이 필요합니다."; +"provider.opencode.repair.action" = "OpenCode 설정"; +"provider.opencode.sync.title" = "OpenCode 동기화"; +"provider.opencode.sync.detail" = "로컬 데이터베이스에서 OpenCode 세션 데이터를 읽어옵니다."; +"provider.opencode.sync.action" = "지금 동기화"; +"provider.opencode.sync_again.action" = "OpenCode 다시 동기화"; +"provider.install.opencode.success" = "OpenCode가 Tokenmon용으로 설정되었습니다. 로컬 데이터베이스에서 세션 데이터를 읽어옵니다."; diff --git a/Sources/TokenmonApp/TokenmonAutomationCommands.swift b/Sources/TokenmonApp/TokenmonAutomationCommands.swift index 2a593fb..ea10ef8 100644 --- a/Sources/TokenmonApp/TokenmonAutomationCommands.swift +++ b/Sources/TokenmonApp/TokenmonAutomationCommands.swift @@ -601,6 +601,8 @@ enum TokenmonAutomationCommand { throw AutomationError.invalidUsage("gemini transcript backfill is not yet supported") case .cursor: throw AutomationError.invalidUsage("cursor transcript backfill is not yet supported") + case .opencode: + throw AutomationError.invalidUsage("opencode transcript backfill is not yet supported") } } diff --git a/Sources/TokenmonApp/TokenmonMenuModel.swift b/Sources/TokenmonApp/TokenmonMenuModel.swift index c4b13ef..dd39a04 100644 --- a/Sources/TokenmonApp/TokenmonMenuModel.swift +++ b/Sources/TokenmonApp/TokenmonMenuModel.swift @@ -939,6 +939,10 @@ final class TokenmonMenuModel: ObservableObject { await performCursorUsageSync(.background) } + func syncOpenCodeUsageInBackground() async { + await performOpenCodeUsageSync(.background) + } + func runTranscriptBackfill( provider: ProviderCode, transcriptPath: String, @@ -1001,6 +1005,34 @@ final class TokenmonMenuModel: ObservableObject { settingsMessage = TokenmonL10n.string("settings.feedback.gemini_backfill_unsupported") case .cursor: settingsMessage = "Cursor transcript backfill is not supported" + case .opencode: + let dbPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: providerInstallationPreferences) + let result = try OpenCodeBackfillService.run( + databasePath: databasePath, + dbPath: dbPath + ) + if result.backfillRunID > 0 { + analyticsTracker.captureBackfillRunCompleted( + BackfillRunSummary( + backfillRunID: result.backfillRunID, + provider: .opencode, + mode: "opencode_sqlite_backfill", + status: result.status, + startedAt: completedAt, + completedAt: completedAt, + samplesExamined: result.samplesExamined, + samplesCreated: result.samplesCreated, + duplicatesSkipped: result.duplicatesSkipped, + errorsCount: result.errorsCount, + summaryJSON: result.summaryJSON + ) + ) + } + if result.status == "noop" { + settingsMessage = TokenmonL10n.string("settings.feedback.opencode_backfill_noop") + } else { + settingsMessage = TokenmonL10n.format("settings.feedback.opencode_backfill_complete", result.samplesCreated, result.duplicatesSkipped) + } } settingsError = nil refresh(reason: .manual) @@ -1754,6 +1786,71 @@ private extension TokenmonMenuModel { } } +private enum TokenmonOpenCodeSyncPresentation { + case manual + case background +} + +private extension TokenmonMenuModel { + @MainActor + func performOpenCodeUsageSync(_ presentation: TokenmonOpenCodeSyncPresentation) async { + let dbPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: providerInstallationPreferences) + + guard FileManager.default.fileExists(atPath: dbPath) else { + if presentation == .manual { + settingsError = "OpenCode database not found" + settingsMessage = nil + refresh(reason: .manual) + } + return + } + + let databasePath = self.databasePath + + do { + let result = try await Task.detached(priority: .utility) { + try OpenCodeBackfillService.run( + databasePath: databasePath, + dbPath: dbPath + ) + }.value + + if result.samplesCreated > 0 { + recordLiveActivityPulse() + } + + switch presentation { + case .manual: + if result.status == "noop" { + settingsMessage = TokenmonL10n.string("settings.feedback.opencode_backfill_noop") + } else { + settingsMessage = TokenmonL10n.format("settings.feedback.opencode_backfill_complete", result.samplesCreated, result.duplicatesSkipped) + } + settingsError = nil + refresh(reason: .manual) + case .background: + if result.samplesCreated > 0 { + refresh(reason: .manual) + } + } + } catch { + switch presentation { + case .manual: + settingsError = error.localizedDescription + settingsMessage = nil + refresh(reason: .manual) + case .background: + TokenmonAppBehaviorLogger.debug( + category: "providers", + event: "opencode_background_sync_failed", + metadata: ["error": error.localizedDescription], + supportDirectoryPath: supportDirectoryPath + ) + } + } + } +} + enum TokenmonSceneContextResolver { static func popoverContext( displayedSceneContext: TokenmonSceneContext?, @@ -2470,6 +2567,9 @@ final class TokenmonInboxMonitor: @unchecked Sendable { case .cursor: ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath) continue + case .opencode: + ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath) + continue } ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath) diff --git a/Sources/TokenmonApp/TokenmonMenuViews.swift b/Sources/TokenmonApp/TokenmonMenuViews.swift index 9445d22..6107f10 100644 --- a/Sources/TokenmonApp/TokenmonMenuViews.swift +++ b/Sources/TokenmonApp/TokenmonMenuViews.swift @@ -1910,6 +1910,8 @@ private struct TokenmonProviderSettingsCard: View { return "antenna.radiowaves.left.and.right" case .cursor: return "arrow.triangle.branch" + case .opencode: + return "terminal" } } diff --git a/Sources/TokenmonApp/TokenmonProviderDiscovery.swift b/Sources/TokenmonApp/TokenmonProviderDiscovery.swift index 6a1dcfa..5806203 100644 --- a/Sources/TokenmonApp/TokenmonProviderDiscovery.swift +++ b/Sources/TokenmonApp/TokenmonProviderDiscovery.swift @@ -97,6 +97,18 @@ enum TokenmonProviderDiscovery { .path } + static func opencodeDBPath( + preferences: ProviderInstallationPreferences + ) -> String { + opencodeDBPath(configurationRootPath: discover(provider: .opencode, preferences: preferences).configurationRootPath) + } + + static func opencodeDBPath(configurationRootPath: String) -> String { + URL(fileURLWithPath: configurationRootPath, isDirectory: true) + .appendingPathComponent("opencode.db") + .path + } + static func resolvedHomeDirectory() -> URL { if let override = ProcessInfo.processInfo.environment["TOKENMON_HOME_OVERRIDE"], override.isEmpty == false { @@ -175,6 +187,8 @@ enum TokenmonProviderDiscovery { return "gemini" case .cursor: return "cursor" + case .opencode: + return "opencode" } } @@ -190,6 +204,8 @@ enum TokenmonProviderDiscovery { return resolvedHomeDirectory() .appendingPathComponent("Library/Application Support/Cursor/User", isDirectory: true) .path + case .opencode: + return resolvedHomeDirectory().appendingPathComponent(".local/share/opencode", isDirectory: true).path } } diff --git a/Sources/TokenmonApp/TokenmonProviderOnboarding.swift b/Sources/TokenmonApp/TokenmonProviderOnboarding.swift index 9b7ef81..adc15dd 100644 --- a/Sources/TokenmonApp/TokenmonProviderOnboarding.swift +++ b/Sources/TokenmonApp/TokenmonProviderOnboarding.swift @@ -56,6 +56,8 @@ enum TokenmonProviderOnboarding { return inspectGemini(preferences: preferences) case .cursor: return inspectCursor(databasePath: databasePath, preferences: preferences) + case .opencode: + return inspectOpenCode(preferences: preferences) } } } @@ -90,6 +92,8 @@ enum TokenmonProviderOnboarding { provider: .cursor, message: "Cursor sync is managed through scripts/cursor-usage-prototype" ) + case .opencode: + return try installOpenCode(preferences: preferences) } } @@ -811,6 +815,63 @@ enum TokenmonProviderOnboarding { try FileManager.default.copyItem(atPath: path, toPath: backupPath) } } + + private static func inspectOpenCode( + preferences: ProviderInstallationPreferences + ) -> TokenmonProviderOnboardingStatus { + let discovery = TokenmonProviderDiscovery.discover(provider: .opencode, preferences: preferences) + let cliInstalled = discovery.executableExists + guard cliInstalled else { + return TokenmonProviderOnboardingStatus( + provider: .opencode, + cliInstalled: false, + isConnected: false, + isPartial: false, + title: TokenmonL10n.string("provider.opencode.missing.title"), + detail: discovery.usesCustomExecutablePath + ? TokenmonL10n.string("provider.opencode.missing.custom_path_detail") + : TokenmonL10n.string("provider.opencode.missing.detail"), + actionTitle: nil, + executablePath: discovery.executablePath, + executableSource: discovery.executableSource, + configurationPath: discovery.configurationPath, + configurationSource: discovery.configurationSource, + usesCustomExecutablePath: discovery.usesCustomExecutablePath, + usesCustomConfigurationPath: discovery.usesCustomConfigurationPath, + codexMode: nil + ) + } + + let dbPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: preferences) + let dbExists = FileManager.default.fileExists(atPath: dbPath) + + return TokenmonProviderOnboardingStatus( + provider: .opencode, + cliInstalled: true, + isConnected: dbExists, + isPartial: cliInstalled && !dbExists, + title: TokenmonL10n.string(dbExists ? "provider.opencode.connected.title" : "provider.opencode.ready.title"), + detail: TokenmonL10n.string(dbExists ? "provider.opencode.connected.detail" : "provider.opencode.ready.detail"), + actionTitle: nil, + executablePath: discovery.executablePath, + executableSource: discovery.executableSource, + configurationPath: discovery.configurationPath, + configurationSource: discovery.configurationSource, + usesCustomExecutablePath: discovery.usesCustomExecutablePath, + usesCustomConfigurationPath: discovery.usesCustomConfigurationPath, + codexMode: nil + ) + } + + private static func installOpenCode( + preferences: ProviderInstallationPreferences + ) throws -> TokenmonProviderInstallResult { + _ = preferences + return TokenmonProviderInstallResult( + provider: .opencode, + message: TokenmonL10n.string("provider.install.opencode.success") + ) + } } enum TokenmonProviderInstallError: Error, LocalizedError { diff --git a/Sources/TokenmonApp/TokenmonStatusItemController.swift b/Sources/TokenmonApp/TokenmonStatusItemController.swift index ef15609..a184c0d 100644 --- a/Sources/TokenmonApp/TokenmonStatusItemController.swift +++ b/Sources/TokenmonApp/TokenmonStatusItemController.swift @@ -107,6 +107,7 @@ final class TokenmonSceneDebugController: ObservableObject { final class TokenmonAppController { static let shared = TokenmonAppController() private static let cursorSyncIntervalNanoseconds: UInt64 = 20_000_000_000 + private static let openCodeSyncIntervalNanoseconds: UInt64 = 30_000_000_000 let sceneDebugController = TokenmonSceneDebugController.shared let menuModel: TokenmonMenuModel @@ -120,6 +121,7 @@ final class TokenmonAppController { private var startupTask: Task? private var recoveryTask: Task? private var cursorSyncTask: Task? + private var openCodeSyncTask: Task? private lazy var statusItemController: TokenmonStatusItemController = { let controller = TokenmonStatusItemController( @@ -372,6 +374,27 @@ final class TokenmonAppController { ) } + let openCodeDBPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: self.menuModel.providerInstallationPreferences) + if FileManager.default.fileExists(atPath: openCodeDBPath) { + let openCodeSyncStartedAt = Date() + self.openCodeSyncTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.menuModel.syncOpenCodeUsageInBackground() + while Task.isCancelled == false { + do { + try await Task.sleep(nanoseconds: Self.openCodeSyncIntervalNanoseconds) + } catch { return } + guard Task.isCancelled == false else { return } + await self.menuModel.syncOpenCodeUsageInBackground() + } + } + logStartupPhase( + "opencode_background_sync_started", + startedAt: openCodeSyncStartedAt, + metadata: ["interval_seconds": "30"] + ) + } + let geminiSetupStartedAt = Date() let supervisor = GeminiOtelReceiverSupervisor( dataSource: databaseManager, diff --git a/Sources/TokenmonDomain/DomainTypes.swift b/Sources/TokenmonDomain/DomainTypes.swift index 49f8feb..b6f88b5 100644 --- a/Sources/TokenmonDomain/DomainTypes.swift +++ b/Sources/TokenmonDomain/DomainTypes.swift @@ -5,9 +5,10 @@ public enum ProviderCode: String, CaseIterable, Codable, Sendable { case codex case gemini case cursor + case opencode public static var allCases: [ProviderCode] { - [.claude, .codex, .gemini, .cursor] + [.claude, .codex, .gemini, .cursor, .opencode] } public var displayName: String { @@ -20,6 +21,8 @@ public enum ProviderCode: String, CaseIterable, Codable, Sendable { return "Gemini CLI" case .cursor: return "Cursor" + case .opencode: + return "OpenCode" } } @@ -33,6 +36,8 @@ public enum ProviderCode: String, CaseIterable, Codable, Sendable { return "first_class" case .cursor: return "managed_only" + case .opencode: + return "best_effort" } } } diff --git a/Sources/TokenmonPersistence/BackfillRunStore.swift b/Sources/TokenmonPersistence/BackfillRunStore.swift index 736fb2d..a48bc17 100644 --- a/Sources/TokenmonPersistence/BackfillRunStore.swift +++ b/Sources/TokenmonPersistence/BackfillRunStore.swift @@ -343,6 +343,8 @@ public enum BackfillRunStore { message = "Gemini transcript backfill is unsupported for the current configuration" case .cursor: message = "Cursor transcript backfill is unsupported for the current configuration" + case .opencode: + message = "OpenCode transcript backfill is unsupported for the current configuration" } try database.execute( diff --git a/Sources/TokenmonPersistence/OpenCodeBackfillService.swift b/Sources/TokenmonPersistence/OpenCodeBackfillService.swift new file mode 100644 index 0000000..946e10d --- /dev/null +++ b/Sources/TokenmonPersistence/OpenCodeBackfillService.swift @@ -0,0 +1,370 @@ +import Foundation +import TokenmonDomain +import TokenmonProviders + +public struct OpenCodeBackfillResult: Sendable { + public let backfillRunID: Int64 + public let sessionID: String? + public let status: String + public let samplesExamined: Int64 + public let samplesCreated: Int64 + public let duplicatesSkipped: Int64 + public let errorsCount: Int64 + public let summaryJSON: String +} + +private struct OpenCodeBackfillSummary: Encodable { + let provider: String + let mode: String + let dbPath: String + let samplesExamined: Int64 + let samplesCreated: Int64 + let duplicatesSkipped: Int64 + let errorsCount: Int64 + + enum CodingKeys: String, CodingKey { + case provider + case mode + case dbPath = "db_path" + case samplesExamined = "samples_examined" + case samplesCreated = "samples_created" + case duplicatesSkipped = "duplicates_skipped" + case errorsCount = "errors_count" + } +} + +private struct OpenCodeBackfillFailureSummary: Encodable { + let provider: String + let mode: String + let dbPath: String + let reason: String + + enum CodingKeys: String, CodingKey { + case provider + case mode + case dbPath = "db_path" + case reason + } +} + +public enum OpenCodeBackfillService { + public static func run( + databasePath: String, + dbPath: String + ) throws -> OpenCodeBackfillResult { + let database = try TokenmonDatabaseManager(path: databasePath).open() + let key = sourceKey(dbPath: dbPath) + let checkpoint = try IngestSourceCheckpointStore.loadOrCreate( + database: database, + sourceKey: key, + sourceKind: "ndjson_file", + path: dbPath + ) + let sinceValue = checkpoint.lastEventFingerprint + + do { + let rawEvents = try OpenCodeSQLiteAdapter.providerEvents( + from: dbPath, + since: sinceValue + ) + + let events = rawEvents.map { event in + ProviderUsageSampleEvent( + eventType: event.eventType, + provider: event.provider, + sourceMode: event.sourceMode, + providerSessionID: event.providerSessionID, + observedAt: event.observedAt, + workspaceDir: event.workspaceDir, + modelSlug: event.modelSlug, + transcriptPath: event.transcriptPath, + totalInputTokens: event.totalInputTokens, + totalOutputTokens: event.totalOutputTokens, + totalCachedInputTokens: event.totalCachedInputTokens, + normalizedTotalTokens: event.normalizedTotalTokens, + providerEventFingerprint: event.providerEventFingerprint, + rawReference: event.rawReference, + currentInputTokens: event.currentInputTokens, + currentOutputTokens: event.currentOutputTokens, + sessionOriginHint: .startedDuringLiveRuntime + ) + } + + if events.isEmpty { + try upsertBackfillHealth( + database: database, + healthState: "experimental", + message: "OpenCode SQLite backfill found no new events", + lastSuccessAt: ISO8601DateFormatter().string(from: Date()), + lastErrorAt: nil, + lastErrorSummary: nil + ) + return OpenCodeBackfillResult( + backfillRunID: 0, + sessionID: nil, + status: "noop", + samplesExamined: 0, + samplesCreated: 0, + duplicatesSkipped: 0, + errorsCount: 0, + summaryJSON: "{\"provider\":\"opencode\",\"mode\":\"opencode_sqlite_backfill\",\"status\":\"noop\",\"reason\":\"no_new_events\"}" + ) + } + + let resolvedSessionID = events.first?.providerSessionID + + let ingestService = UsageSampleIngestionService(databasePath: databasePath) + let ingestResult = try ingestService.ingestProviderEvents( + database: database, + events: events, + sourceKey: key, + sourcePath: dbPath, + sourceKind: "ndjson_file", + manageSourceCheckpoint: false + ) + + if let lastEvent = events.last { + try advanceCheckpoint( + database: database, + checkpoint: checkpoint, + dbPath: dbPath, + lastEvent: lastEvent + ) + } + + let samplesExamined = Int64(events.count) + let samplesCreated = Int64(ingestResult.usageSamplesCreated) + let duplicatesSkipped = Int64(ingestResult.duplicateEvents) + let errorsCount = Int64(ingestResult.rejectedEvents) + + if samplesCreated == 0, duplicatesSkipped > 0, errorsCount == 0 { + try upsertBackfillHealth( + database: database, + healthState: "experimental", + message: "OpenCode SQLite backfill advanced checkpoint; all appended samples were duplicates", + lastSuccessAt: ISO8601DateFormatter().string(from: Date()), + lastErrorAt: nil, + lastErrorSummary: nil + ) + return OpenCodeBackfillResult( + backfillRunID: 0, + sessionID: resolvedSessionID, + status: "noop", + samplesExamined: samplesExamined, + samplesCreated: 0, + duplicatesSkipped: duplicatesSkipped, + errorsCount: 0, + summaryJSON: "{\"provider\":\"opencode\",\"mode\":\"opencode_sqlite_backfill\",\"status\":\"noop\",\"reason\":\"duplicates_only\",\"duplicates_skipped\":\(duplicatesSkipped)}" + ) + } + + let backfillRunID = try BackfillRunStore.startBackfillRun( + database: database, + provider: .opencode, + providerSessionID: resolvedSessionID, + mode: "opencode_sqlite_backfill" + ) + + try DomainEventStore.persist( + database: database, + envelope: TokenmonDomainEventRegistry.backfillStarted( + runID: backfillRunID, + provider: .opencode, + sessionID: resolvedSessionID, + reason: "Recover OpenCode token usage samples from SQLite database" + ) + ) + + let summaryJSON = try encodeSummary( + OpenCodeBackfillSummary( + provider: ProviderCode.opencode.rawValue, + mode: "opencode_sqlite_backfill", + dbPath: dbPath, + samplesExamined: samplesExamined, + samplesCreated: samplesCreated, + duplicatesSkipped: duplicatesSkipped, + errorsCount: errorsCount + ) + ) + + _ = try BackfillRunStore.completeBackfillRun( + database: database, + backfillRunID: backfillRunID, + provider: .opencode, + mode: "opencode_sqlite_backfill", + status: "completed", + samplesExamined: samplesExamined, + samplesCreated: samplesCreated, + duplicatesSkipped: duplicatesSkipped, + errorsCount: errorsCount, + summaryJSON: summaryJSON + ) + + try DomainEventStore.persist( + database: database, + envelope: TokenmonDomainEventRegistry.backfillCompleted( + runID: backfillRunID, + provider: .opencode, + sessionID: resolvedSessionID, + samplesExamined: samplesExamined, + samplesCreated: samplesCreated, + duplicatesSkipped: duplicatesSkipped, + errorsCount: errorsCount + ) + ) + + try upsertBackfillHealth( + database: database, + healthState: "experimental", + message: "OpenCode SQLite backfill completed successfully", + lastSuccessAt: ISO8601DateFormatter().string(from: Date()), + lastErrorAt: nil, + lastErrorSummary: nil + ) + + return OpenCodeBackfillResult( + backfillRunID: backfillRunID, + sessionID: resolvedSessionID, + status: "completed", + samplesExamined: samplesExamined, + samplesCreated: samplesCreated, + duplicatesSkipped: duplicatesSkipped, + errorsCount: errorsCount, + summaryJSON: summaryJSON + ) + } catch { + let summaryJSON = try encodeSummary( + OpenCodeBackfillFailureSummary( + provider: ProviderCode.opencode.rawValue, + mode: "opencode_sqlite_backfill", + dbPath: dbPath, + reason: error.localizedDescription + ) + ) + + let backfillRunID = try BackfillRunStore.startBackfillRun( + database: database, + provider: .opencode, + providerSessionID: nil, + mode: "opencode_sqlite_backfill" + ) + + try DomainEventStore.persist( + database: database, + envelope: TokenmonDomainEventRegistry.backfillStarted( + runID: backfillRunID, + provider: .opencode, + sessionID: nil, + reason: "Recover OpenCode token usage samples from SQLite database" + ) + ) + + try upsertBackfillHealth( + database: database, + healthState: "degraded", + message: "OpenCode SQLite backfill failed", + lastSuccessAt: nil, + lastErrorAt: ISO8601DateFormatter().string(from: Date()), + lastErrorSummary: error.localizedDescription + ) + + _ = try BackfillRunStore.completeBackfillRun( + database: database, + backfillRunID: backfillRunID, + provider: .opencode, + mode: "opencode_sqlite_backfill", + status: "failed", + samplesExamined: 0, + samplesCreated: 0, + duplicatesSkipped: 0, + errorsCount: 1, + summaryJSON: summaryJSON + ) + + try DomainEventStore.persist( + database: database, + envelope: TokenmonDomainEventRegistry.backfillCompleted( + runID: backfillRunID, + provider: .opencode, + sessionID: nil, + samplesExamined: 0, + samplesCreated: 0, + duplicatesSkipped: 0, + errorsCount: 1 + ) + ) + + throw error + } + } + + private static func sourceKey(dbPath: String) -> String { + "opencode:sqlite:\(dbPath)" + } + + private static func advanceCheckpoint( + database: SQLiteDatabase, + checkpoint: IngestSourceCheckpoint, + dbPath: String, + lastEvent: ProviderUsageSampleEvent + ) throws { + try IngestSourceCheckpointStore.advance( + database: database, + sourceID: checkpoint.ingestSourceID, + path: dbPath, + offset: 0, + lineNumber: 0, + fingerprint: lastEvent.rawReference.offset + ) + } + + private static func upsertBackfillHealth( + database: SQLiteDatabase, + healthState: String, + message: String, + lastSuccessAt: String?, + lastErrorAt: String?, + lastErrorSummary: String? + ) throws { + let updatedAt = ISO8601DateFormatter().string(from: Date()) + try database.execute( + """ + INSERT INTO provider_health ( + provider_code, + source_mode, + health_state, + message, + last_success_at, + last_error_at, + last_error_code, + last_error_summary, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) + ON CONFLICT(provider_code, source_mode) DO UPDATE SET + health_state = excluded.health_state, + message = excluded.message, + last_success_at = COALESCE(excluded.last_success_at, provider_health.last_success_at), + last_error_at = excluded.last_error_at, + last_error_code = NULL, + last_error_summary = excluded.last_error_summary, + updated_at = excluded.updated_at; + """, + bindings: [ + .text(ProviderCode.opencode.rawValue), + .text("opencode_sqlite_backfill"), + .text(healthState), + .text(message), + lastSuccessAt.map(SQLiteValue.text) ?? .null, + lastErrorAt.map(SQLiteValue.text) ?? .null, + lastErrorSummary.map(SQLiteValue.text) ?? .null, + .text(updatedAt), + ] + ) + } + + private static func encodeSummary(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return String(decoding: try encoder.encode(value), as: UTF8.self) + } +} diff --git a/Sources/TokenmonPersistence/ProviderInstallationPreferencesStore.swift b/Sources/TokenmonPersistence/ProviderInstallationPreferencesStore.swift index 3b22979..f7e6d83 100644 --- a/Sources/TokenmonPersistence/ProviderInstallationPreferencesStore.swift +++ b/Sources/TokenmonPersistence/ProviderInstallationPreferencesStore.swift @@ -53,6 +53,8 @@ public struct ProviderInstallationPreferences: Equatable, Codable, Sendable { return ProviderInstallationPathOverride() case .cursor: return ProviderInstallationPathOverride() + case .opencode: + return ProviderInstallationPathOverride() } } @@ -66,6 +68,8 @@ public struct ProviderInstallationPreferences: Equatable, Codable, Sendable { break case .cursor: break + case .opencode: + break } } @@ -79,6 +83,8 @@ public struct ProviderInstallationPreferences: Equatable, Codable, Sendable { break case .cursor: break + case .opencode: + break } } @@ -92,6 +98,8 @@ public struct ProviderInstallationPreferences: Equatable, Codable, Sendable { break case .cursor: break + case .opencode: + break } } } diff --git a/Sources/TokenmonPersistence/TokenmonDatabase.swift b/Sources/TokenmonPersistence/TokenmonDatabase.swift index 31eb108..5870902 100644 --- a/Sources/TokenmonPersistence/TokenmonDatabase.swift +++ b/Sources/TokenmonPersistence/TokenmonDatabase.swift @@ -1767,6 +1767,30 @@ public final class TokenmonDatabaseManager { """, "CREATE UNIQUE INDEX IF NOT EXISTS idx_party_members_slot ON party_members(slot_order);", ]), + SQLiteMigration(version: 10, statements: [ + """ + INSERT INTO providers ( + provider_code, + display_name, + default_support_level, + is_enabled, + created_at, + updated_at + ) VALUES ( + 'opencode', + 'OpenCode', + 'best_effort', + 1, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ) + ON CONFLICT(provider_code) DO UPDATE SET + display_name = excluded.display_name, + default_support_level = excluded.default_support_level, + is_enabled = 1, + updated_at = excluded.updated_at; + """, + ]), ] } } diff --git a/Sources/TokenmonPersistence/TokenmonDiagnosticsReadModels.swift b/Sources/TokenmonPersistence/TokenmonDiagnosticsReadModels.swift index f9c1f39..4fee966 100644 --- a/Sources/TokenmonPersistence/TokenmonDiagnosticsReadModels.swift +++ b/Sources/TokenmonPersistence/TokenmonDiagnosticsReadModels.swift @@ -319,6 +319,12 @@ public extension TokenmonDatabaseManager { "missing_configuration", "Cursor usage export has not been imported yet" ) + case .opencode: + return ( + sourceMode ?? "opencode_sqlite_live", + "missing_configuration", + "OpenCode has not been detected yet" + ) } } @@ -332,6 +338,8 @@ public extension TokenmonDatabaseManager { return "unavailable" case .cursor: return "api_sync_supported" + case .opencode: + return "unavailable" } } @@ -356,6 +364,8 @@ public extension TokenmonDatabaseManager { return healthState != "missing_configuration" && healthState != "unsupported" case .cursor: return healthState != "missing_configuration" && healthState != "unsupported" + case .opencode: + return healthState != "missing_configuration" && healthState != "unsupported" } } } diff --git a/Sources/TokenmonProviders/OpenCodeSQLiteAdapter.swift b/Sources/TokenmonProviders/OpenCodeSQLiteAdapter.swift new file mode 100644 index 0000000..24fd576 --- /dev/null +++ b/Sources/TokenmonProviders/OpenCodeSQLiteAdapter.swift @@ -0,0 +1,257 @@ +import CryptoKit +import Foundation +import SQLite3 +import TokenmonDomain + +public enum OpenCodeSQLiteAdapterError: Error, LocalizedError { + case databaseNotFound(String) + case databaseOpenFailed(String) + case queryFailed(String) + + public var errorDescription: String? { + switch self { + case .databaseNotFound(let path): + return "opencode database not found at \(path)" + case .databaseOpenFailed(let detail): + return "failed to open opencode database: \(detail)" + case .queryFailed(let detail): + return "opencode database query failed: \(detail)" + } + } +} + +private struct OpenCodeMessageData: Decodable { + let role: String? + let tokens: OpenCodeTokens? + let cost: Double? + let providerID: String? + let modelID: String? + let path: OpenCodePath? + let time: OpenCodeTime? +} + +private struct OpenCodeTokens: Decodable { + let input: Int64? + let output: Int64? + let reasoning: Int64? + let cache: OpenCodeCache? + let total: Int64? +} + +private struct OpenCodeCache: Decodable { + let read: Int64? + let write: Int64? +} + +private struct OpenCodePath: Decodable { + let cwd: String? +} + +private struct OpenCodeTime: Decodable { + let created: Int64? +} + +private struct OpenCodeMessageRow { + let id: String + let sessionID: String + let timeCreated: Int64 + let dataString: String +} + +public enum OpenCodeSQLiteAdapter { + public static func providerEvents(from dbPath: String) throws -> [ProviderUsageSampleEvent] { + try providerEvents(from: dbPath, since: nil) + } + + public static func providerEvents( + from dbPath: String, + since messageID: String? + ) throws -> [ProviderUsageSampleEvent] { + let db = try openDatabase(at: dbPath) + defer { sqlite3_close(db) } + + let messages = try fetchMessages(from: db, since: messageID) + return buildEvents(from: messages) + } + + private static func openDatabase(at path: String) throws -> OpaquePointer { + guard FileManager.default.fileExists(atPath: path) else { + throw OpenCodeSQLiteAdapterError.databaseNotFound(path) + } + var db: OpaquePointer? + let result = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) + guard result == SQLITE_OK, let db else { + sqlite3_close(db) + throw OpenCodeSQLiteAdapterError.databaseOpenFailed( + "sqlite3_open_v2 returned \(result)" + ) + } + return db + } + + private static func fetchMessages( + from db: OpaquePointer, + since messageID: String? + ) throws -> [OpenCodeMessageRow] { + let sql = """ + SELECT m.id, m.session_id, m.time_created, m.data + FROM message m + WHERE m.id > ? + ORDER BY m.time_created ASC + """ + + var statement: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, sql, -1, &statement, nil) + guard prepareResult == SQLITE_OK, let statement else { + let message = String(cString: sqlite3_errmsg(db)) + throw OpenCodeSQLiteAdapterError.queryFailed("prepare: \(message)") + } + defer { sqlite3_finalize(statement) } + + let sinceValue = messageID ?? "" + let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let bindResult = sqlite3_bind_text( + statement, 1, sinceValue, -1, transientDestructor + ) + guard bindResult == SQLITE_OK else { + throw OpenCodeSQLiteAdapterError.queryFailed("bind: code \(bindResult)") + } + + var rows: [OpenCodeMessageRow] = [] + while true { + let stepResult = sqlite3_step(statement) + if stepResult == SQLITE_ROW { + guard let idPtr = sqlite3_column_text(statement, 0), + let sessionIDPtr = sqlite3_column_text(statement, 1) else { + continue + } + + let id = String(cString: idPtr) + let sessionID = String(cString: sessionIDPtr) + let timeCreated = sqlite3_column_int64(statement, 2) + + guard let dataPtr = sqlite3_column_text(statement, 3) else { + continue + } + let dataString = String(cString: dataPtr) + + rows.append(OpenCodeMessageRow( + id: id, + sessionID: sessionID, + timeCreated: timeCreated, + dataString: dataString + )) + } else if stepResult == SQLITE_DONE { + break + } else { + let message = String(cString: sqlite3_errmsg(db)) + throw OpenCodeSQLiteAdapterError.queryFailed("step: \(message)") + } + } + + return rows + } + + private static func buildEvents( + from rows: [OpenCodeMessageRow] + ) -> [ProviderUsageSampleEvent] { + var cumulativeTotalsBySession: [String: Int64] = [:] + var events: [ProviderUsageSampleEvent] = [] + + for row in rows { + let data: OpenCodeMessageData + do { + data = try JSONDecoder().decode( + OpenCodeMessageData.self, + from: Data(row.dataString.utf8) + ) + } catch { + continue + } + + guard data.role == "assistant" else { + continue + } + + let inputTokens = data.tokens?.input ?? 0 + let outputTokens = data.tokens?.output ?? 0 + let cacheReadTokens = data.tokens?.cache?.read ?? 0 + let cacheWriteTokens = data.tokens?.cache?.write ?? 0 + let messageTotal = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + + let cumulativeTotal = cumulativeTotalsBySession[row.sessionID, default: 0] + messageTotal + cumulativeTotalsBySession[row.sessionID] = cumulativeTotal + + let observedAt: String + if let created = data.time?.created, created > 0 { + observedAt = iso8601FromMilliseconds(created) + } else { + observedAt = iso8601FromMilliseconds(row.timeCreated) + } + + let fingerprint = providerFingerprint( + sessionID: row.sessionID, + messageID: row.id, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheWriteTokens: cacheWriteTokens + ) + + events.append( + ProviderUsageSampleEvent( + eventType: "provider_usage_sample", + provider: .opencode, + sourceMode: "opencode_sqlite_backfill", + providerSessionID: row.sessionID, + observedAt: observedAt, + workspaceDir: data.path?.cwd, + modelSlug: data.modelID, + transcriptPath: nil, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + totalCachedInputTokens: cacheReadTokens + cacheWriteTokens, + normalizedTotalTokens: cumulativeTotal, + providerEventFingerprint: fingerprint, + rawReference: ProviderRawReference( + kind: "opencode_sqlite_message", + offset: row.id, + eventName: "message" + ), + currentInputTokens: inputTokens, + currentOutputTokens: outputTokens, + sessionOriginHint: .unknown + ) + ) + } + + return events + } + + private static func iso8601FromMilliseconds(_ ms: Int64) -> String { + let date = Date(timeIntervalSince1970: Double(ms) / 1000.0) + return ISO8601DateFormatter().string(from: date) + } + + private static func providerFingerprint( + sessionID: String, + messageID: String, + inputTokens: Int64, + outputTokens: Int64, + cacheReadTokens: Int64, + cacheWriteTokens: Int64 + ) -> String { + let payload = [ + sessionID, + messageID, + "\(inputTokens)", + "\(outputTokens)", + "\(cacheReadTokens)", + "\(cacheWriteTokens)", + ].joined(separator: "|") + + let digest = SHA256.hash(data: Data(payload.utf8)) + let digestText = digest.map { String(format: "%02x", $0) }.joined() + return "opencode:\(sessionID):\(digestText)" + } +} diff --git a/Tests/TokenmonAppTests/OpenCodeAdapterTests.swift b/Tests/TokenmonAppTests/OpenCodeAdapterTests.swift new file mode 100644 index 0000000..e53cbdd --- /dev/null +++ b/Tests/TokenmonAppTests/OpenCodeAdapterTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing +import TokenmonDomain +import TokenmonProviders + +struct OpenCodeAdapterTests { + private func fixtureDBPath() -> String { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot + .appendingPathComponent("Fixtures/OpenCodeMessages/sample-opencode.db") + .path + } + + @Test + func readsAllAssistantMessagesFromFixtureDB() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + + // 8 messages total, 2 are user-role → 6 assistant events + #expect(events.count == 6) + + let session1Events = events.filter { $0.providerSessionID == "sess-001" } + let session2Events = events.filter { $0.providerSessionID == "sess-002" } + #expect(session1Events.count == 4) + #expect(session2Events.count == 2) + } + + @Test + func computesCumulativeTotalsPerSession() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + + let session1Events = events.filter { $0.providerSessionID == "sess-001" } + + // msg-001: 962+142+5184+0 = 6288 + #expect(session1Events[0].normalizedTotalTokens == 6288) + #expect(session1Events[0].currentInputTokens == 962) + #expect(session1Events[0].currentOutputTokens == 142) + + // msg-003: 2400+876+12000+2000 = 17276, cumulative = 6288+17276 = 23564 + #expect(session1Events[1].normalizedTotalTokens == 23564) + + // msg-004: 500+80+3000+0 = 3580, cumulative = 23564+3580 = 27144 + #expect(session1Events[2].normalizedTotalTokens == 27144) + + // msg-005: 1500+450+8000+500 = 10450, cumulative = 27144+10450 = 37594 + #expect(session1Events[3].normalizedTotalTokens == 37594) + + let session2Events = events.filter { $0.providerSessionID == "sess-002" } + + // msg-006: 1800+320+9000+1000 = 12120 + #expect(session2Events[0].normalizedTotalTokens == 12120) + + // msg-008: 3200+1100+15000+3000 = 22300, cumulative = 12120+22300 = 34420 + #expect(session2Events[1].normalizedTotalTokens == 34420) + } + + @Test + func includesCacheTokensInCachedInputTotal() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + + // msg-001: cacheRead=5184, cacheWrite=0 → cachedInput = 5184 + let first = events.first! + #expect(first.totalCachedInputTokens == 5184) + + // msg-003: cacheRead=12000, cacheWrite=2000 → cachedInput = 14000 + let second = events.filter { $0.providerSessionID == "sess-001" }[1] + #expect(second.totalCachedInputTokens == 14000) + } + + @Test + func producesCorrectFingerprintFormat() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + let first = events.first! + + #expect(first.providerEventFingerprint.hasPrefix("opencode:sess-001:")) + // SHA-256 produces 64 hex characters after the prefix + let hexPart = first.providerEventFingerprint + .replacingOccurrences(of: "opencode:sess-001:", with: "") + #expect(hexPart.count == 64) + #expect(hexPart.allSatisfy { $0.isHexDigit }) + } + + @Test + func setsProviderAndSourceModeFields() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + let first = events.first! + + #expect(first.provider == ProviderCode.opencode) + #expect(first.sourceMode == "opencode_sqlite_backfill") + #expect(first.rawReference.kind == "opencode_sqlite_message") + #expect(first.rawReference.offset == "msg-001") + #expect(first.rawReference.eventName == "message") + } + + @Test + func observesWorkspaceDirAndModelSlug() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + let first = events.first! + + #expect(first.workspaceDir != nil) + #expect(first.modelSlug != nil) + } + + @Test + func filtersBySinceMessageID() throws { + let events = try OpenCodeSQLiteAdapter.providerEvents( + from: fixtureDBPath(), + since: "msg-003" + ) + + // Messages with id > "msg-003": msg-004, msg-005, msg-006, msg-007, msg-008 + // Assistant only: msg-004, msg-005, msg-006, msg-008 = 4 + #expect(events.count == 4) + + // Cumulative totals are computed from the fetched subset, not the full session + let session1Events = events.filter { $0.providerSessionID == "sess-001" } + + // msg-004: 500+80+3000+0 = 3580, cumulative = 3580 + #expect(session1Events[0].normalizedTotalTokens == 3580) + + // msg-005: 1500+450+8000+500 = 10450, cumulative = 3580+10450 = 14030 + #expect(session1Events[1].normalizedTotalTokens == 14030) + } + + @Test + func throwsDatabaseNotFoundForMissingPath() { + #expect(throws: OpenCodeSQLiteAdapterError.self) { + try OpenCodeSQLiteAdapter.providerEvents(from: "/nonexistent/opencode.db") + } + } + + @Test + func returnsEmptyArrayForEmptySince() throws { + // Passing nil (via the convenience overload) should return all events + let events = try OpenCodeSQLiteAdapter.providerEvents(from: fixtureDBPath()) + #expect(events.count == 6) + } +} diff --git a/Tests/TokenmonAppTests/TokenmonDataContractTests.swift b/Tests/TokenmonAppTests/TokenmonDataContractTests.swift index 7b58825..77e7380 100644 --- a/Tests/TokenmonAppTests/TokenmonDataContractTests.swift +++ b/Tests/TokenmonAppTests/TokenmonDataContractTests.swift @@ -1135,6 +1135,14 @@ struct TokenmonDataContractTests { #expect(ProviderCode.cursor.defaultSupportLevel == "managed_only") } + @Test + func providerCodeIncludesOpenCodeWithExpectedMetadata() { + #expect(ProviderCode.allCases.contains(.opencode)) + #expect(ProviderCode(rawValue: "opencode") == .opencode) + #expect(ProviderCode.opencode.displayName == "OpenCode") + #expect(ProviderCode.opencode.defaultSupportLevel == "best_effort") + } + @Test func migrationVersionSixSeedsGeminiProviderRow() throws { let tempDirectory = FileManager.default.temporaryDirectory @@ -1173,6 +1181,40 @@ struct TokenmonDataContractTests { #expect(displayName == "Gemini CLI") } + @Test + func migrationVersionTenSeedsOpenCodeProviderRow() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("tokenmon-mig-v10-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let dbPath = tempDirectory.appendingPathComponent("tokenmon.sqlite").path + let manager = TokenmonDatabaseManager(path: dbPath) + try manager.bootstrap() + + let database = try manager.open() + + try database.execute("PRAGMA user_version = 9;") + try database.execute("DELETE FROM providers WHERE provider_code = 'opencode';") + + _ = try manager.open() + + let count = try database.fetchOne( + "SELECT COUNT(*) FROM providers WHERE provider_code = 'opencode';" + ) { statement in + SQLiteDatabase.columnInt64(statement, index: 0) + } ?? 0 + + #expect(count == 1) + + let displayName = try database.fetchOne( + "SELECT display_name FROM providers WHERE provider_code = 'opencode';" + ) { statement in + SQLiteDatabase.columnText(statement, index: 0) + } + #expect(displayName == "OpenCode") + } + @Test func migrationVersionSevenRebuildsUsageSamplesOutsideTransactionWhenEncountersReferenceThem() throws { let manager = try makeManager(prefix: "tokenmon-mig-v7") diff --git a/scripts/run-ai-verify b/scripts/run-ai-verify index 7a4ca65..15ced52 100755 --- a/scripts/run-ai-verify +++ b/scripts/run-ai-verify @@ -1021,7 +1021,7 @@ require_contains "$app_smoke_output" "seen_species: 0" require_contains "$app_smoke_output" "captured_species: 0" require_contains "$app_smoke_output" "notifications_enabled: false" require_contains "$app_smoke_output" "provider_status_visibility: true" -require_contains "$app_smoke_output" "provider_status_entries_visible: 4" +require_contains "$app_smoke_output" "provider_status_entries_visible: 5" require_contains "$app_smoke_output" "launch_at_login_supported: false" require_contains "$app_smoke_output" "latest_encounter: none" printf 'VERIFY PASS: menubar app empty-db smoke\n' @@ -1035,7 +1035,7 @@ require_contains "$seed_rerun_output" "seeded species: total=151 inserted=0" printf 'VERIFY PASS: species seed rerun idempotency\n' summary_output="$(tokenmon_app_cmd --tokenmon-summary --db "$db" 2>&1)" -require_contains "$summary_output" "providers: 4" +require_contains "$summary_output" "providers: 5" require_contains "$summary_output" "species: 151" printf 'VERIFY PASS: summary after bootstrap/seed\n'