mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 06:59:37 +00:00
Add new providers, fix menubar tabs, accent color picker (#167)
* Add Kiro provider and transparent auto-model naming - Add Kiro IDE provider: parses .chat JSON files, estimates tokens, normalizes dot-versioned model IDs for cost lookup - Show "Cursor (auto)", "Copilot (auto)", "Kiro (auto)" in CLI dashboard instead of pretending to know which model was used - Route auto model names through BUILTIN_ALIASES for cost estimation * Fix menubar tabs: add missing providers, show period-scoped costs - Add Kiro, OMP to ProviderFilter enum so installed providers appear as tabs - Merge Cursor + Cursor Agent into single Cursor tab - Tab costs now reflect the selected period (7d/30d/month/all) instead of always showing today - Tab visibility still uses today's provider list so tabs don't disappear when switching to periods with no data * Add accent color picker to menubar with Apple system presets - 9 presets using Apple's exact macOS dark-mode accent colors (Ember, Blue, Purple, Pink, Red, Orange, Yellow, Green, Graphite) - Color picker in header, persisted via UserDefaults - "Burn" text stays fixed ember regardless of accent - ThemeState is MainActor-isolated for thread safety - Picker state lifted to AppStore so it survives .id() tree rebuild - Accessibility labels on all color swatches - Renamed brandAccentDark/brandEmberDeep/brandEmberGlow to match their actual light/deep/glow semantics * Fix review findings: case-sensitive cost lookup, Kiro timestamp guard, cache versioning - Normalize provider dictionary keys to lowercase in tab cost lookup so "Cursor Agent" (title-case from CLI) matches providerKeys - Guard against missing/invalid/epoch startTime in Kiro parser to prevent RangeError crash or 1970-01-01 ghost entries - Bump DAILY_CACHE_VERSION to 4 so upgraded users get a clean recompute with the new auto-model naming (cursor-auto vs default) - Add version field to cursor-results.json cache to invalidate stale entries that still use the old 'default' model name
This commit is contained in:
parent
5d1b335c0a
commit
f7f64a01ab
19 changed files with 853 additions and 57 deletions
|
|
@ -20,6 +20,10 @@ final class AppStore {
|
|||
var selectedProvider: ProviderFilter = .all
|
||||
var selectedPeriod: Period = .today
|
||||
var selectedInsight: InsightMode = .trend
|
||||
var accentPreset: AccentPreset = ThemeState.shared.preset {
|
||||
didSet { ThemeState.shared.preset = accentPreset }
|
||||
}
|
||||
var showingAccentPicker: Bool = false
|
||||
var currency: String = "USD"
|
||||
var isLoading: Bool = false
|
||||
var lastError: String?
|
||||
|
|
@ -44,6 +48,12 @@ final class AppStore {
|
|||
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
|
||||
}
|
||||
|
||||
/// All-provider payload for the selected period. Used by the tab strip to show
|
||||
/// per-provider costs that match the active period, not just today.
|
||||
var periodAllPayload: MenubarPayload? {
|
||||
cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload
|
||||
}
|
||||
|
||||
var hasCachedData: Bool {
|
||||
cache[currentKey] != nil
|
||||
}
|
||||
|
|
@ -86,6 +96,11 @@ final class AppStore {
|
|||
lastError = String(describing: error)
|
||||
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
||||
}
|
||||
|
||||
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
||||
if key != allKey, cache[allKey]?.isFresh != true {
|
||||
await refreshQuietly(period: selectedPeriod)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
||||
|
|
@ -211,12 +226,20 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case codex = "Codex"
|
||||
case cursor = "Cursor"
|
||||
case copilot = "Copilot"
|
||||
case kiro = "Kiro"
|
||||
case opencode = "OpenCode"
|
||||
case pi = "Pi"
|
||||
case omp = "OMP"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Maps to the CLI's `--provider` argument values.
|
||||
var providerKeys: [String] {
|
||||
switch self {
|
||||
case .cursor: ["cursor", "cursor agent"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
||||
var cliArg: String {
|
||||
switch self {
|
||||
case .all: "all"
|
||||
|
|
@ -224,8 +247,10 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .codex: "codex"
|
||||
case .cursor: "cursor"
|
||||
case .copilot: "copilot"
|
||||
case .kiro: "kiro"
|
||||
case .opencode: "opencode"
|
||||
case .pi: "pi"
|
||||
case .omp: "omp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Design tokens. Warm terracotta-ember palette, not generic orange.
|
||||
/// Design tokens. Accent colors are driven by ThemeState so the user can switch palettes.
|
||||
@MainActor
|
||||
enum Theme {
|
||||
static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
|
||||
static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
|
||||
static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
|
||||
static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
|
||||
static var brandAccent: Color { ThemeState.shared.preset.base }
|
||||
static var brandAccentLight: Color { ThemeState.shared.preset.light }
|
||||
static var brandAccentDeep: Color { ThemeState.shared.preset.deep }
|
||||
static var brandAccentGlow: Color { ThemeState.shared.preset.glow }
|
||||
|
||||
static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
|
||||
static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
|
||||
|
|
|
|||
86
mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift
Normal file
86
mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import SwiftUI
|
||||
|
||||
enum AccentPreset: String, CaseIterable, Identifiable {
|
||||
case ember = "Ember"
|
||||
case blue = "Blue"
|
||||
case purple = "Purple"
|
||||
case pink = "Pink"
|
||||
case red = "Red"
|
||||
case orange = "Orange"
|
||||
case yellow = "Yellow"
|
||||
case green = "Green"
|
||||
case graphite = "Graphite"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Apple macOS dark-mode system accent colors (NSColor.system*).
|
||||
var base: Color {
|
||||
switch self {
|
||||
case .ember: Color(red: 0xC9/255, green: 0x52/255, blue: 0x1D/255)
|
||||
case .blue: Color(red: 0x0A/255, green: 0x84/255, blue: 0xFF/255)
|
||||
case .purple: Color(red: 0xBF/255, green: 0x5A/255, blue: 0xF2/255)
|
||||
case .pink: Color(red: 0xFF/255, green: 0x37/255, blue: 0x5F/255)
|
||||
case .red: Color(red: 0xFF/255, green: 0x45/255, blue: 0x3A/255)
|
||||
case .orange: Color(red: 0xFF/255, green: 0x9F/255, blue: 0x0A/255)
|
||||
case .yellow: Color(red: 0xFF/255, green: 0xD6/255, blue: 0x0A/255)
|
||||
case .green: Color(red: 0x30/255, green: 0xD1/255, blue: 0x58/255)
|
||||
case .graphite: Color(red: 0x98/255, green: 0x98/255, blue: 0x9D/255)
|
||||
}
|
||||
}
|
||||
|
||||
var light: Color {
|
||||
switch self {
|
||||
case .ember: Color(red: 0xE8/255, green: 0x77/255, blue: 0x4A/255)
|
||||
case .blue: Color(red: 0x40/255, green: 0x9C/255, blue: 0xFF/255)
|
||||
case .purple: Color(red: 0xDA/255, green: 0x8F/255, blue: 0xF7/255)
|
||||
case .pink: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x8C/255)
|
||||
case .red: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x63/255)
|
||||
case .orange: Color(red: 0xFF/255, green: 0xBD/255, blue: 0x4A/255)
|
||||
case .yellow: Color(red: 0xFF/255, green: 0xE0/255, blue: 0x4A/255)
|
||||
case .green: Color(red: 0x5A/255, green: 0xE0/255, blue: 0x78/255)
|
||||
case .graphite: Color(red: 0xAE/255, green: 0xAE/255, blue: 0xB2/255)
|
||||
}
|
||||
}
|
||||
|
||||
var deep: Color {
|
||||
switch self {
|
||||
case .ember: Color(red: 0x8B/255, green: 0x3E/255, blue: 0x13/255)
|
||||
case .blue: Color(red: 0x06/255, green: 0x52/255, blue: 0xB3/255)
|
||||
case .purple: Color(red: 0x7C/255, green: 0x38/255, blue: 0xA8/255)
|
||||
case .pink: Color(red: 0xB3/255, green: 0x26/255, blue: 0x42/255)
|
||||
case .red: Color(red: 0xB3/255, green: 0x30/255, blue: 0x28/255)
|
||||
case .orange: Color(red: 0xB3/255, green: 0x6F/255, blue: 0x06/255)
|
||||
case .yellow: Color(red: 0xB3/255, green: 0x96/255, blue: 0x06/255)
|
||||
case .green: Color(red: 0x20/255, green: 0x92/255, blue: 0x3D/255)
|
||||
case .graphite: Color(red: 0x5E/255, green: 0x5E/255, blue: 0x62/255)
|
||||
}
|
||||
}
|
||||
|
||||
var glow: Color {
|
||||
switch self {
|
||||
case .ember: Color(red: 0xF0/255, green: 0xA0/255, blue: 0x70/255)
|
||||
case .blue: Color(red: 0x80/255, green: 0xC0/255, blue: 0xFF/255)
|
||||
case .purple: Color(red: 0xE0/255, green: 0xB8/255, blue: 0xFA/255)
|
||||
case .pink: Color(red: 0xFF/255, green: 0x99/255, blue: 0xB0/255)
|
||||
case .red: Color(red: 0xFF/255, green: 0x99/255, blue: 0x90/255)
|
||||
case .orange: Color(red: 0xFF/255, green: 0xD0/255, blue: 0x80/255)
|
||||
case .yellow: Color(red: 0xFF/255, green: 0xEA/255, blue: 0x80/255)
|
||||
case .green: Color(red: 0x80/255, green: 0xF0/255, blue: 0x98/255)
|
||||
case .graphite: Color(red: 0xC8/255, green: 0xC8/255, blue: 0xCC/255)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ThemeState {
|
||||
static let shared = ThemeState()
|
||||
|
||||
var preset: AccentPreset {
|
||||
didSet { UserDefaults.standard.set(preset.rawValue, forKey: "CodeBurnAccentPreset") }
|
||||
}
|
||||
|
||||
private init() {
|
||||
let saved = UserDefaults.standard.string(forKey: "CodeBurnAccentPreset") ?? ""
|
||||
self.preset = AccentPreset(rawValue: saved) ?? .ember
|
||||
}
|
||||
}
|
||||
|
|
@ -25,34 +25,33 @@ struct AgentTabStrip: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today),
|
||||
/// not the currently selected provider's payload. Without this, switching to Codex (which
|
||||
/// has no data) would hide every other tab including Claude.
|
||||
private var allProvidersToday: MenubarPayload {
|
||||
private var todayAll: MenubarPayload {
|
||||
store.todayPayload ?? store.payload
|
||||
}
|
||||
|
||||
private var periodAll: MenubarPayload {
|
||||
store.periodAllPayload ?? store.payload
|
||||
}
|
||||
|
||||
private var visibleFilters: [ProviderFilter] {
|
||||
// Show a tab for every provider detected on this machine. The CLI decides what
|
||||
// to include in the providers map based on session dirs / credential files it
|
||||
// finds, so zero-cost-today is still "installed" and the user expects to see
|
||||
// it. Only providers that aren't installed at all are absent from the map.
|
||||
let detectedKeys = Set(
|
||||
allProvidersToday.current.providers.keys.map { $0.lowercased() }
|
||||
todayAll.current.providers.keys.map { $0.lowercased() }
|
||||
)
|
||||
return ProviderFilter.allCases.filter { filter in
|
||||
if filter == .all { return true }
|
||||
return detectedKeys.contains(filter.rawValue.lowercased())
|
||||
return filter.providerKeys.contains(where: detectedKeys.contains)
|
||||
}
|
||||
}
|
||||
|
||||
private func cost(for filter: ProviderFilter) -> Double? {
|
||||
switch filter {
|
||||
case .all:
|
||||
return allProvidersToday.current.cost
|
||||
default:
|
||||
let key = filter.rawValue.lowercased()
|
||||
return allProvidersToday.current.providers[key]
|
||||
let data = periodAll
|
||||
if filter == .all { return data.current.cost }
|
||||
let providers = Dictionary(
|
||||
data.current.providers.map { ($0.key.lowercased(), $0.value) },
|
||||
uniquingKeysWith: +
|
||||
)
|
||||
return filter.providerKeys.reduce(0.0) { sum, key in
|
||||
sum + (providers[key] ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,15 +85,17 @@ private struct AgentTab: View {
|
|||
}
|
||||
|
||||
extension ProviderFilter {
|
||||
var color: Color {
|
||||
@MainActor var color: Color {
|
||||
switch self {
|
||||
case .all: return Theme.brandAccent
|
||||
case .claude: return Theme.categoricalClaude
|
||||
case .codex: return Theme.categoricalCodex
|
||||
case .cursor: return Theme.categoricalCursor
|
||||
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
||||
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import SwiftUI
|
||||
|
||||
private let winColor = Theme.brandAccent
|
||||
private let riskColor = Theme.brandAccent
|
||||
private let improveColor = Theme.brandAccent
|
||||
|
||||
/// Three-category insights panel: wins, improvements, risks.
|
||||
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
|
||||
|
|
@ -133,7 +130,7 @@ private struct TipItem: Identifiable {
|
|||
let trailing: String?
|
||||
}
|
||||
|
||||
private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
|
||||
@MainActor private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
|
||||
let stats = computeHistoryStats(history: payload.history.daily)
|
||||
|
||||
// What's working
|
||||
|
|
@ -201,9 +198,9 @@ private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
|
|||
}
|
||||
|
||||
return [
|
||||
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
|
||||
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
|
||||
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
|
||||
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins),
|
||||
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements),
|
||||
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct HeroSection: View {
|
|||
.tracking(-1)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccent, Theme.brandEmberDeep],
|
||||
colors: [Theme.brandAccent, Theme.brandAccentDeep],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ struct MenuBarContent: View {
|
|||
|
||||
StarBanner()
|
||||
}
|
||||
.id(store.accentPreset)
|
||||
}
|
||||
|
||||
/// True when a specific provider tab is selected and that provider has no spend in the
|
||||
|
|
@ -147,7 +148,7 @@ private struct BurnFlame: View {
|
|||
// Soft outer glow that pulses, matching the brand terracotta palette.
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(Theme.brandEmberGlow.opacity(glowing ? 0.55 : 0.20))
|
||||
.foregroundStyle(Theme.brandAccentGlow.opacity(glowing ? 0.55 : 0.20))
|
||||
.blur(radius: glowing ? 14 : 6)
|
||||
|
||||
// Empty (cool) flame as base
|
||||
|
|
@ -161,10 +162,10 @@ private struct BurnFlame: View {
|
|||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Theme.brandEmberGlow,
|
||||
Theme.brandAccentDark,
|
||||
Theme.brandAccentGlow,
|
||||
Theme.brandAccentLight,
|
||||
Theme.brandAccent,
|
||||
Theme.brandEmberDeep
|
||||
Theme.brandAccentDeep
|
||||
],
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
|
|
@ -184,13 +185,12 @@ private struct BurnFlame: View {
|
|||
|
||||
private struct Header: View {
|
||||
@Environment(UpdateChecker.self) private var updateChecker
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandAccent)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandEmber)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
|
|
@ -202,6 +202,7 @@ private struct Header: View {
|
|||
if updateChecker.updateAvailable {
|
||||
UpdateBadge()
|
||||
}
|
||||
AccentPicker()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
|
|
@ -209,6 +210,60 @@ private struct Header: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AccentPicker: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
if store.showingAccentPicker {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(AccentPreset.allCases) { preset in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
store.accentPreset = preset
|
||||
}
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(preset.base)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white.opacity(store.accentPreset == preset ? 0.9 : 0), lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(preset.rawValue)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.08))
|
||||
)
|
||||
.transition(.opacity.combined(with: .move(edge: .trailing)))
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
store.showingAccentPicker.toggle()
|
||||
}
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(store.accentPreset.base)
|
||||
.frame(width: 14, height: 14)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.3), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Change accent color")
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UpdateBadge: View {
|
||||
@Environment(UpdateChecker.self) private var updateChecker
|
||||
|
||||
|
|
@ -244,7 +299,7 @@ struct FlameMark: View {
|
|||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccentDark, Theme.brandEmberDeep],
|
||||
colors: [Theme.brandAccentLight, Theme.brandAccentDeep],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { homedir } from 'os'
|
|||
|
||||
import type { ParsedProviderCall } from './providers/types.js'
|
||||
|
||||
const CURSOR_CACHE_VERSION = 2
|
||||
|
||||
type ResultCache = {
|
||||
version?: number
|
||||
dbMtimeMs: number
|
||||
dbSizeBytes: number
|
||||
calls: ParsedProviderCall[]
|
||||
|
|
@ -37,7 +40,7 @@ export async function readCachedResults(dbPath: string): Promise<ParsedProviderC
|
|||
const raw = await readFile(getCachePath(), 'utf-8')
|
||||
const cache = JSON.parse(raw) as ResultCache
|
||||
|
||||
if (cache.dbMtimeMs === fp.mtimeMs && cache.dbSizeBytes === fp.size) {
|
||||
if (cache.version === CURSOR_CACHE_VERSION && cache.dbMtimeMs === fp.mtimeMs && cache.dbSizeBytes === fp.size) {
|
||||
return cache.calls
|
||||
}
|
||||
return null
|
||||
|
|
@ -54,6 +57,7 @@ export async function writeCachedResults(dbPath: string, calls: ParsedProviderCa
|
|||
const dir = getCacheDir()
|
||||
await mkdir(dir, { recursive: true })
|
||||
const cache: ResultCache = {
|
||||
version: CURSOR_CACHE_VERSION,
|
||||
dbMtimeMs: fp.mtimeMs,
|
||||
dbSizeBytes: fp.size,
|
||||
calls,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
export const DAILY_CACHE_VERSION = 3
|
||||
export const DAILY_CACHE_VERSION = 4
|
||||
const DAILY_CACHE_FILENAME = 'daily-cache.json'
|
||||
|
||||
export type DailyEntry = {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'anthropic--claude-4.5-opus': 'claude-opus-4-5',
|
||||
'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5',
|
||||
'anthropic--claude-4.5-haiku': 'claude-haiku-4-5',
|
||||
'cursor-auto': 'claude-sonnet-4-5',
|
||||
'cursor-agent-auto': 'claude-sonnet-4-5',
|
||||
'copilot-auto': 'claude-sonnet-4-5',
|
||||
'kiro-auto': 'claude-sonnet-4-5',
|
||||
}
|
||||
|
||||
let userAliases: Record<string, string> = {}
|
||||
|
|
@ -201,7 +205,15 @@ export function calculateCost(
|
|||
)
|
||||
}
|
||||
|
||||
const autoModelNames: Record<string, string> = {
|
||||
'cursor-auto': 'Cursor (auto)',
|
||||
'cursor-agent-auto': 'Cursor (auto)',
|
||||
'copilot-auto': 'Copilot (auto)',
|
||||
'kiro-auto': 'Kiro (auto)',
|
||||
}
|
||||
|
||||
export function getShortModelName(model: string): string {
|
||||
if (autoModelNames[model]) return autoModelNames[model]
|
||||
const canonical = resolveAlias(getCanonicalName(model))
|
||||
const shortNames: Record<string, string> = {
|
||||
'claude-opus-4-7': 'Opus 4.7',
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ function inferModelFromToolCallIds(events: TranscriptEvent[]): string {
|
|||
if (t.toolCallId?.startsWith('call_')) return 'gpt-4.1'
|
||||
}
|
||||
}
|
||||
return 'gpt-4.1'
|
||||
return 'copilot-auto'
|
||||
}
|
||||
|
||||
function parseTranscriptEvents(content: string, sessionId: string, seenKeys: Set<string>): ParsedProviderCall[] {
|
||||
|
|
@ -374,6 +374,7 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage
|
|||
displayName: 'Copilot',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model === 'copilot-auto') return 'Copilot (auto)'
|
||||
for (const [key, name] of modelDisplayEntries) {
|
||||
if (model === key || model.startsWith(key + '-')) return name
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type ParsedTurn = {
|
|||
assistant: AssistantTurn
|
||||
}
|
||||
|
||||
const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5'
|
||||
const CURSOR_AGENT_COST_MODEL = 'claude-sonnet-4-5'
|
||||
const CHARS_PER_TOKEN = 4
|
||||
const MAX_USER_TEXT_LENGTH = 500
|
||||
const DIGITS_ONLY = /^\d+$/
|
||||
|
|
@ -129,10 +129,14 @@ function prettifyProjectId(raw: string): string {
|
|||
}
|
||||
|
||||
function resolveModel(raw: string | null | undefined): string {
|
||||
if (!raw || raw === 'default') return CURSOR_AGENT_DEFAULT_MODEL
|
||||
if (!raw || raw === 'default') return 'cursor-agent-auto'
|
||||
return raw
|
||||
}
|
||||
|
||||
function costModel(model: string): string {
|
||||
return model === 'cursor-agent-auto' ? CURSOR_AGENT_COST_MODEL : model
|
||||
}
|
||||
|
||||
function toConversationId(transcriptPath: string): string {
|
||||
const filename = basename(transcriptPath, '.txt')
|
||||
if (filename.length === 36 && UUID_LIKE.test(filename)) return filename
|
||||
|
|
@ -378,7 +382,7 @@ function createParser(
|
|||
seenKeys.add(deduplicationKey)
|
||||
|
||||
const costUSD = calculateCost(
|
||||
model,
|
||||
costModel(model),
|
||||
inputTokens,
|
||||
outputTokens + reasoningTokens,
|
||||
0,
|
||||
|
|
@ -424,7 +428,7 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
|
|||
displayName: 'Cursor Agent',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model === 'default') return modelDisplayNames.default
|
||||
if (model === 'cursor-agent-auto') return 'Cursor (auto)'
|
||||
const label = modelDisplayNames[model] ?? model
|
||||
return `${label} (est.)`
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { readCachedResults, writeCachedResults } from '../cursor-cache.js'
|
|||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const CURSOR_DEFAULT_MODEL = 'claude-sonnet-4-5'
|
||||
const CURSOR_COST_MODEL = 'claude-sonnet-4-5'
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)',
|
||||
|
|
@ -23,7 +23,7 @@ const modelDisplayNames: Record<string, string> = {
|
|||
'gpt-5.1-codex-high': 'GPT-5.1 Codex',
|
||||
'gpt-5': 'GPT-5',
|
||||
'gpt-4.1': 'GPT-4.1',
|
||||
'default': 'Auto (Sonnet est.)',
|
||||
'cursor-auto': 'Cursor (auto)',
|
||||
}
|
||||
|
||||
type BubbleRow = {
|
||||
|
|
@ -89,12 +89,12 @@ function extractLanguages(codeBlocksJson: string | null): string[] {
|
|||
}
|
||||
|
||||
function resolveModel(raw: string | null): string {
|
||||
if (!raw || raw === 'default') return CURSOR_DEFAULT_MODEL
|
||||
if (!raw || raw === 'default') return CURSOR_COST_MODEL
|
||||
return raw
|
||||
}
|
||||
|
||||
function modelForDisplay(raw: string | null): string {
|
||||
if (!raw || raw === 'default') return 'default'
|
||||
if (!raw || raw === 'default') return 'cursor-auto'
|
||||
return raw
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { claude } from './claude.js'
|
||||
import { codex } from './codex.js'
|
||||
import { copilot } from './copilot.js'
|
||||
import { kiro } from './kiro.js'
|
||||
import { pi, omp } from './pi.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, pi, omp]
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, kiro, pi, omp]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
|
||||
|
|
|
|||
279
src/providers/kiro.ts
Normal file
279
src/providers/kiro.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||||
'claude-sonnet-4-5': 'Sonnet 4.5',
|
||||
'claude-sonnet-4': 'Sonnet 4',
|
||||
'claude-haiku-4-5': 'Haiku 4.5',
|
||||
'claude-3-7-sonnet': 'Sonnet 3.7',
|
||||
'claude-3-5-sonnet': 'Sonnet 3.5',
|
||||
'claude-3-5-haiku': 'Haiku 3.5',
|
||||
}
|
||||
|
||||
const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
readFile: 'Read',
|
||||
read_file: 'Read',
|
||||
writeFile: 'Edit',
|
||||
write_file: 'Edit',
|
||||
editFile: 'Edit',
|
||||
edit_file: 'Edit',
|
||||
createFile: 'Write',
|
||||
create_file: 'Write',
|
||||
deleteFile: 'Delete',
|
||||
listDir: 'LS',
|
||||
list_dir: 'LS',
|
||||
openFolders: 'LS',
|
||||
runCommand: 'Bash',
|
||||
run_command: 'Bash',
|
||||
searchFiles: 'Grep',
|
||||
search_files: 'Grep',
|
||||
findFiles: 'Glob',
|
||||
find_files: 'Glob',
|
||||
webSearch: 'WebSearch',
|
||||
web_search: 'WebSearch',
|
||||
}
|
||||
|
||||
type KiroChatMessage = {
|
||||
role: 'human' | 'bot' | 'tool'
|
||||
content: string
|
||||
}
|
||||
|
||||
type KiroChatFile = {
|
||||
executionId: string
|
||||
actionId: string
|
||||
chat: KiroChatMessage[]
|
||||
metadata: {
|
||||
modelId: string
|
||||
modelProvider: string
|
||||
workflow: string
|
||||
workflowId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModelId(raw: string): string {
|
||||
return raw.replace(/(\d+)\.(\d+)/g, '$1-$2')
|
||||
}
|
||||
|
||||
function extractToolNames(content: string): string[] {
|
||||
const tools: string[] = []
|
||||
const regex = /<tool_use>\s*<name>([^<]+)<\/name>/g
|
||||
let match
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const name = match[1]!.trim()
|
||||
tools.push(toolNameMap[name] ?? name)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set<string>): ParsedProviderCall[] {
|
||||
const results: ParsedProviderCall[] = []
|
||||
const { chat, metadata } = data
|
||||
|
||||
let modelId = normalizeModelId(metadata.modelId ?? '')
|
||||
if (modelId === 'auto' || !modelId) modelId = 'kiro-auto'
|
||||
|
||||
let pendingUserMessage = ''
|
||||
const allTools: string[] = []
|
||||
|
||||
for (const msg of chat) {
|
||||
if (msg.role === 'human') {
|
||||
if (msg.content.startsWith('<identity>')) continue
|
||||
pendingUserMessage = msg.content.slice(0, 500)
|
||||
}
|
||||
if (msg.role === 'bot') {
|
||||
allTools.push(...extractToolNames(msg.content))
|
||||
}
|
||||
}
|
||||
|
||||
const botMessages = chat.filter(m => m.role === 'bot' && m.content.length > 0)
|
||||
const totalOutputChars = botMessages.reduce((sum, m) => sum + m.content.length, 0)
|
||||
if (totalOutputChars === 0) return results
|
||||
|
||||
const dedupKey = `kiro:${sessionId}:${data.executionId}`
|
||||
if (seenKeys.has(dedupKey)) return results
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const outputTokens = Math.ceil(totalOutputChars / CHARS_PER_TOKEN)
|
||||
const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN)
|
||||
const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0)
|
||||
const tsDate = metadata.startTime ? new Date(metadata.startTime) : null
|
||||
if (!tsDate || isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results
|
||||
const timestamp = tsDate.toISOString()
|
||||
|
||||
results.push({
|
||||
provider: 'kiro',
|
||||
model: modelId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: [...new Set(allTools)],
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
|
||||
let data: KiroChatFile
|
||||
try {
|
||||
data = JSON.parse(content)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.chat || !data.metadata) return
|
||||
|
||||
const sessionId = data.metadata.workflowId ?? basename(source.path, '.chat')
|
||||
const calls = parseChatFile(data, sessionId, source.project, seenKeys)
|
||||
for (const call of calls) {
|
||||
yield call
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discovery ---
|
||||
|
||||
function getKiroAgentDir(override?: string): string {
|
||||
if (override) return override
|
||||
if (process.platform === 'darwin') {
|
||||
return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
return join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
|
||||
function getKiroWorkspaceStorageDir(override?: string): string {
|
||||
if (override) return override
|
||||
if (process.platform === 'darwin') {
|
||||
return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'workspaceStorage')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'workspaceStorage')
|
||||
}
|
||||
return join(homedir(), '.config', 'Kiro', 'User', 'workspaceStorage')
|
||||
}
|
||||
|
||||
async function readWorkspaceProject(workspaceDir: string): Promise<string> {
|
||||
try {
|
||||
const raw = await readFile(join(workspaceDir, 'workspace.json'), 'utf-8')
|
||||
const data = JSON.parse(raw) as { folder?: string }
|
||||
if (data.folder) {
|
||||
const url = data.folder.replace(/^file:\/\//, '')
|
||||
return basename(decodeURIComponent(url))
|
||||
}
|
||||
} catch {}
|
||||
return basename(workspaceDir)
|
||||
}
|
||||
|
||||
async function resolveWorkspaceProject(agentDir: string, workspaceStorageDir: string, workspaceHash: string): Promise<string> {
|
||||
const wsDir = join(workspaceStorageDir, workspaceHash)
|
||||
const project = await readWorkspaceProject(wsDir)
|
||||
if (project !== workspaceHash) return project
|
||||
|
||||
try {
|
||||
const sessionsPath = join(agentDir, 'workspace-sessions')
|
||||
const dirs = await readdir(sessionsPath)
|
||||
for (const dir of dirs) {
|
||||
const decoded = Buffer.from(dir.replace(/_$/, ''), 'base64').toString('utf-8')
|
||||
if (decoded) return basename(decoded)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return workspaceHash
|
||||
}
|
||||
|
||||
async function discoverSessions(agentDir: string, workspaceStorageDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let workspaceDirs: string[]
|
||||
try {
|
||||
const entries = await readdir(agentDir, { withFileTypes: true })
|
||||
workspaceDirs = entries.filter(e => e.isDirectory() && e.name.length === 32).map(e => e.name)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const wsHash of workspaceDirs) {
|
||||
const wsPath = join(agentDir, wsHash)
|
||||
const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
const entries = await readdir(wsPath)
|
||||
files = entries.filter(f => f.endsWith('.chat'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(wsPath, file)
|
||||
const s = await stat(filePath).catch(() => null)
|
||||
if (!s?.isFile()) continue
|
||||
sources.push({ path: filePath, project, provider: 'kiro' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string): Provider {
|
||||
const agentDir = getKiroAgentDir(agentDirOverride)
|
||||
const wsDir = getKiroWorkspaceStorageDir(workspaceStorageDirOverride)
|
||||
|
||||
return {
|
||||
name: 'kiro',
|
||||
displayName: 'Kiro',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model === 'kiro-auto') return 'Kiro (auto)'
|
||||
for (const [key, name] of modelDisplayEntries) {
|
||||
if (model === key || model.startsWith(key + '-')) return name
|
||||
}
|
||||
return model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessions(agentDir, wsDir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const kiro = createKiroProvider()
|
||||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi', 'omp'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'kiro', 'pi', 'omp'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
@ -62,7 +62,7 @@ describe('provider registry', () => {
|
|||
it('cursor model display names handle auto mode', async () => {
|
||||
const all = await getAllProviders()
|
||||
const cursor = all.find(p => p.name === 'cursor')!
|
||||
expect(cursor.modelDisplayName('default')).toBe('Auto (Sonnet est.)')
|
||||
expect(cursor.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
|
||||
expect(cursor.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)')
|
||||
expect(cursor.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast')
|
||||
expect(cursor.modelDisplayName('unknown-model')).toBe('unknown-model')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { ParsedProviderCall, Provider, SessionSource } from '../../src/prov
|
|||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5'
|
||||
const CURSOR_AGENT_DEFAULT_MODEL = 'cursor-agent-auto'
|
||||
const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000'
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
|
@ -61,9 +61,9 @@ describe('cursor-agent provider', () => {
|
|||
expect(provider?.displayName).toBe('Cursor Agent')
|
||||
})
|
||||
|
||||
it('maps default model to auto with estimation label', () => {
|
||||
it('maps default model to Cursor (auto) label', () => {
|
||||
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
|
||||
expect(provider.modelDisplayName('default')).toBe('Auto (Sonnet est.)')
|
||||
expect(provider.modelDisplayName('cursor-agent-auto')).toBe('Cursor (auto)')
|
||||
})
|
||||
|
||||
it('maps known models and appends estimation label', () => {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ describe('cursor provider', () => {
|
|||
})
|
||||
|
||||
describe('model display names', () => {
|
||||
it('maps default to Auto with estimation label', () => {
|
||||
expect(cursorProvider.modelDisplayName('default')).toBe('Auto (Sonnet est.)')
|
||||
it('maps cursor-auto to Cursor (auto) label', () => {
|
||||
expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
|
||||
})
|
||||
|
||||
it('maps known models to readable names', () => {
|
||||
|
|
|
|||
328
tests/providers/kiro.test.ts
Normal file
328
tests/providers/kiro.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { kiro, createKiroProvider } from '../../src/providers/kiro.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function makeChatFile(opts: {
|
||||
executionId?: string
|
||||
modelId?: string
|
||||
workflowId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
userPrompt?: string
|
||||
botResponses?: string[]
|
||||
}) {
|
||||
const chat = [
|
||||
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
||||
{ role: 'bot', content: '' },
|
||||
{ role: 'tool', content: 'workspace tree...' },
|
||||
{ role: 'bot', content: 'I will follow these instructions.' },
|
||||
]
|
||||
|
||||
if (opts.userPrompt) {
|
||||
chat.push({ role: 'human', content: opts.userPrompt })
|
||||
}
|
||||
|
||||
for (const resp of opts.botResponses ?? ['Done.']) {
|
||||
chat.push({ role: 'bot', content: resp })
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
executionId: opts.executionId ?? 'exec-001',
|
||||
actionId: 'act',
|
||||
context: [],
|
||||
validations: {},
|
||||
chat,
|
||||
metadata: {
|
||||
modelId: opts.modelId ?? 'claude-haiku-4-5',
|
||||
modelProvider: 'qdev',
|
||||
workflow: 'act',
|
||||
workflowId: opts.workflowId ?? 'wf-001',
|
||||
startTime: opts.startTime ?? 1777333000000,
|
||||
endTime: opts.endTime ?? 1777333010000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('kiro provider - chat file parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses a basic chat file', async () => {
|
||||
const wsHash = 'a'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'abc123.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'claude-haiku-4-5',
|
||||
userPrompt: 'explain the code',
|
||||
botResponses: ['Here is an explanation of the code structure.'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'myproject', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('kiro')
|
||||
expect(call.model).toBe('claude-haiku-4-5')
|
||||
expect(call.outputTokens).toBeGreaterThan(0)
|
||||
expect(call.userMessage).toBe('explain the code')
|
||||
expect(call.bashCommands).toEqual([])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stores kiro-auto when model is auto', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'abc.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'auto',
|
||||
botResponses: ['some output'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('kiro-auto')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('skips chat files with no bot output', async () => {
|
||||
const wsHash = 'c'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'empty.chat')
|
||||
await writeFile(chatPath, JSON.stringify({
|
||||
executionId: 'exec-empty',
|
||||
actionId: 'act',
|
||||
context: [],
|
||||
validations: {},
|
||||
chat: [
|
||||
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
||||
{ role: 'bot', content: '' },
|
||||
{ role: 'human', content: 'do something' },
|
||||
{ role: 'bot', content: '' },
|
||||
],
|
||||
metadata: {
|
||||
modelId: 'claude-haiku-4-5',
|
||||
modelProvider: 'qdev',
|
||||
workflow: 'act',
|
||||
workflowId: 'wf-empty',
|
||||
startTime: 1777333000000,
|
||||
endTime: 1777333010000,
|
||||
},
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across parser runs', async () => {
|
||||
const wsHash = 'd'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'dup.chat')
|
||||
await writeFile(chatPath, makeChatFile({ botResponses: ['hello'] }))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const calls1: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
||||
|
||||
const calls2: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for missing file', async () => {
|
||||
const source = { path: '/nonexistent/test.chat', project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for invalid JSON', async () => {
|
||||
const wsHash = 'e'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'bad.chat')
|
||||
await writeFile(chatPath, 'not json at all')
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('estimates tokens from text length', async () => {
|
||||
const wsHash = 'f'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'tokens.chat')
|
||||
const longResponse = 'x'.repeat(400)
|
||||
await writeFile(chatPath, makeChatFile({ botResponses: [longResponse] }))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.outputTokens).toBe(109)
|
||||
})
|
||||
|
||||
it('normalizes dot-versioned model IDs to dashes', async () => {
|
||||
const wsHash = 'h'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'dot.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'claude-haiku-4.5',
|
||||
botResponses: ['response text here'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('claude-haiku-4-5')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('uses workflowId as sessionId', async () => {
|
||||
const wsHash = 'g'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'sess.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
workflowId: 'my-workflow-id',
|
||||
botResponses: ['ok'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe('my-workflow-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - discoverSessions', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers chat files from workspace hash directories', async () => {
|
||||
const wsHash = 'a1b2c3d4e5f6'.padEnd(32, '0')
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'session1.chat'), makeChatFile({}))
|
||||
await writeFile(join(wsDir, 'session2.chat'), makeChatFile({}))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'kiro')).toBe(true)
|
||||
expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true)
|
||||
})
|
||||
|
||||
it('reads project name from workspace.json', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const agentWsDir = join(tmpDir, wsHash)
|
||||
await mkdir(agentWsDir, { recursive: true })
|
||||
await writeFile(join(agentWsDir, 'test.chat'), makeChatFile({}))
|
||||
|
||||
const workspaceStorageDir = join(tmpDir, 'ws-storage')
|
||||
const wsStorageEntry = join(workspaceStorageDir, wsHash)
|
||||
await mkdir(wsStorageEntry, { recursive: true })
|
||||
await writeFile(join(wsStorageEntry, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, workspaceStorageDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('myapp')
|
||||
})
|
||||
|
||||
it('returns empty when directory does not exist', async () => {
|
||||
const provider = createKiroProvider('/nonexistent/agent', '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips non-32-char directories', async () => {
|
||||
const shortDir = join(tmpDir, 'short')
|
||||
await mkdir(shortDir, { recursive: true })
|
||||
await writeFile(join(shortDir, 'test.chat'), makeChatFile({}))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips files without .chat extension', async () => {
|
||||
const wsHash = 'c'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'index.json'), '{}')
|
||||
await writeFile(join(wsDir, 'notes.txt'), 'hello')
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(kiro.name).toBe('kiro')
|
||||
expect(kiro.displayName).toBe('Kiro')
|
||||
})
|
||||
|
||||
it('normalizes model display names', () => {
|
||||
expect(kiro.modelDisplayName('claude-haiku-4-5')).toBe('Haiku 4.5')
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5')
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
expect(kiro.modelDisplayName('unknown-model')).toBe('unknown-model')
|
||||
})
|
||||
|
||||
it('normalizes tool display names', () => {
|
||||
expect(kiro.toolDisplayName('readFile')).toBe('Read')
|
||||
expect(kiro.toolDisplayName('writeFile')).toBe('Edit')
|
||||
expect(kiro.toolDisplayName('runCommand')).toBe('Bash')
|
||||
expect(kiro.toolDisplayName('searchFiles')).toBe('Grep')
|
||||
expect(kiro.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
|
||||
it('longest-prefix match for versioned model IDs', () => {
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-5-20260101')).toBe('Sonnet 4.5')
|
||||
expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue