Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
- **Kiro chat sessions no longer under-report input.** The token estimate for chat-file sessions (legacy `.chat` and chat-shaped IDE files) now counts every human turn's full text instead of only the last 500 characters, so multi-turn sessions and long prompts stop under-reporting input (and cost) severalfold. The estimate still counts human turns only — tool and system content, and resent context, are not included — and the summary still shows the last prompt, capped at 500 chars. Already-cached sessions re-parse once so the corrected numbers replace the old ones, and the daily cache bumps to v17 with this fix (v16 was skipped — main already claimed it for the codex structural-discovery fix), so finalized day totals re-derive too — for days whose kiro chat files still exist: since the v14 NEVER-LOSE carry-forward, a bump re-derives what sources survive and carries every sourceless (day, provider) slice forward with its old values, so a day whose kiro chat files have since been deleted keeps the pre-fix cost after the bump (minor in practice, since kiro IDE sources usually persist, but a carry-forward, not a refresh). (#909)

## 0.9.19 - 2026-07-20

Expand Down
2 changes: 1 addition & 1 deletion docs/providers/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire
- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them.
- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `<tool_use><name>...</name>` or expose structured `toolCalls` / `tool_calls` / `tools` entries.
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`.
- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere. The chat-file arm now sums every human turn's full text (identity preamble excluded) instead of only the last 500-char slice, so its input no longer undercounts for long or multi-turn chats; it still skips `tool`/`system` records, which the modern-execution, CLI, and v2 arms count as input (tool results are fed back to the model), and no arm sees the full resent context. v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed. v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`.
- **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5.

## When fixing a bug here
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17 — 16 is skipped on purpose: main already spent it on the codex
// structural-discovery fix (eece4cf), so a v16 cache in the wild is a
// main-owned cache meaning the codex fix, not this one. Claiming 16 here too
// would let a user who has ever run a main build load its v16 cache as
// CURRENT and COMPLETE, and this invalidation would never fire. 17 is the
// first version that also means the kiro chat-file fix.
//
// kiro chat-file input tokens are now estimated from every human turn's full
// text instead of the last 500 chars (#909, this PR), so a v15 rollup
// finalized by the pre-fix binary carries kiro costs off by up to
// severalfold. Nothing downstream can notice on its own: `usage-aggregator`
// serves every day before today from this cache, and retention is ten years,
// so an upgrading user with a warm complete cache would keep the stale
// pre-fix kiro day totals forever while freshly reparsed sessions disagreed
// with them. Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
Expand Down Expand Up @@ -57,8 +73,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
kiro: 'ide-parsing-v1-est-cost',
// v2: chat-file input tokens now estimate from the FULL prompt (sum of every
// human turn), not the last 500-char slice — so costUSD changed for chat-arm
// sessions. Bump so already-cached kiro sessions re-parse once and the
// corrected tokens/cost land instead of being served pre-fix forever.
kiro: 'ide-parsing-v2-est-cost',
quickdesk: 'emf-sqlite-v2-est-cost',
kimicode: 'wire-usage-v1-est-cost',
'kilo-code': 'worktree-project-grouping-v1',
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/tests/daily-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,79 @@ describe('ensureCacheHydrated: timezone invalidation', () => {
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
})
})

// A complete v15 cache is trusted as-is (unchanged savings hash, matching tz,
// complete marker set) — so without the v17 bump, an upgrading user keeps the
// pre-fix kiro day totals forever while freshly reparsed sessions disagree
// with them. The bump mints a fresh filename, adoption marks the result
// incomplete, and the next hydration re-derives the window; the corrected kiro
// estimate (every human turn's full text, #909) wins wherever the sources
// survive, and sourceless days carry forward under the v14 NEVER-LOSE rule.
describe('ensureCacheHydrated: schema version invalidation (#909)', () => {
function kiroDay(date: string, cost: number, calls: number, inputTokens: number): DailyEntry {
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 1,
inputTokens,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: {
'claude-haiku-4-5': { calls, cost, savingsUSD: 0, inputTokens, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
categories: {},
providers: {
kiro: { cost, calls, savingsUSD: 0, sessions: 1, inputTokens, outputTokens: 200 },
},
}
}

it('re-derives a warm complete v15 cache instead of serving its pre-fix kiro totals', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))

const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
// A cache exactly as a pre-fix release left it: current schema at the
// time, finalized off a complete parse, watermark at yesterday, matching
// tz and savings hash. Nothing but the version bump can invalidate it.
const v15 = {
version: 15,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [kiroDay('2026-06-11', 4.55, 1, 500)],
complete: true,
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), JSON.stringify(v15), 'utf-8')

let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
// The corrected kiro accounting: 750 input tokens (3000 chars / 4)
// instead of the 500-char-slice 125, i.e. the full-prompt estimate.
() => [kiroDay('2026-06-11', 18.2, 1, 750)],
)

// The whole point: the window is re-parsed rather than served frozen.
expect(parseCalls).toBe(1)
// ...and the fresh derivation wins over the stale v15 day, at the
// provider-slice level where the pre-fix kiro cost actually lived.
const day = hydrated.days.find(d => d.date === '2026-06-11')
expect(day?.cost).toBe(18.2)
expect(day?.providers['kiro']?.cost).toBe(18.2)
expect(day?.providers['kiro']?.inputTokens).toBe(750)
expect(hydrated.version).toBe(DAILY_CACHE_VERSION)
expect(hydrated.complete).toBe(true)
// The v15 file is never rewritten or deleted — old binaries still own it.
expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), 'utf-8')).version).toBe(15)
})
})
122 changes: 121 additions & 1 deletion packages/cli/tests/kiro-cache-invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,18 @@ function kiroAgentDir(): string {

// What computeEnvFingerprint('kiro') returned before kiro had an entry in
// PROVIDER_PARSE_VERSIONS: no env vars, no parser version, i.e. a hash of
// zero parts. This is the fingerprint sitting in every pre-fix cache.
// zero parts. This is the fingerprint sitting in every pre-registration cache.
function preFixFingerprint(): string {
return createHash('sha256').update([].join('\0')).digest('hex').slice(0, 16)
}

// What computeEnvFingerprint('kiro') returned under the FIRST parser version
// ('ide-parsing-v1-est-cost'): the fingerprint sitting in every cache written
// by the release that shipped the 500-char-slice input-token estimate.
function v1Fingerprint(): string {
return createHash('sha256').update('parser=ide-parsing-v1-est-cost').digest('hex').slice(0, 16)
}

// Writes one IDE execution file in the context.messages[].entries format that
// the pre-fix parser turned into 0 turns, and returns its path.
async function seedExecutionFile(): Promise<string> {
Expand All @@ -84,6 +91,33 @@ async function seedExecutionFile(): Promise<string> {
return path
}

// Writes one chat-shaped IDE file (chat array + metadata) with a 3000-char
// human prompt, and returns its path. Routes to decodeKiroChatFile.
async function seedChatFile(): Promise<string> {
const dir = join(kiroAgentDir(), 'c'.repeat(32))
await mkdir(dir, { recursive: true })
const path = join(dir, 'chat-stale-001.chat')
await writeFile(path, JSON.stringify({
executionId: 'exec-chat-stale-001',
actionId: 'act',
chat: [
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
{ role: 'bot', content: 'I will follow these instructions.' },
{ role: 'human', content: 'x'.repeat(3000) },
{ role: 'bot', content: 'short' },
],
metadata: {
modelId: 'claude-haiku-4-5',
modelProvider: 'qdev',
workflow: 'act',
workflowId: 'wf-chat-stale-001',
startTime: 1780000000000,
endTime: 1780000001000,
},
}))
return path
}

async function seedCache(execPath: string, envFingerprint: string): Promise<void> {
const fp = await fingerprintFile(execPath)
if (!fp) throw new Error('failed to fingerprint seeded execution file')
Expand All @@ -102,6 +136,60 @@ async function seedCache(execPath: string, envFingerprint: string): Promise<void
await writeFile(sessionCachePath(), JSON.stringify(cache))
}

// Seeds the same chat file as seedChatFile, but cached under the given
// fingerprint with a turn carrying the PRE-FIX token estimate (125 = the
// 500-char slice of the 3000-char prompt). If the cache is served as-is, the
// stale estimate survives; a fingerprint bump discards the section and the
// corrected math (3000 / 4 = 750) lands on re-parse.
async function seedChatCache(envFingerprint: string): Promise<string> {
const path = await seedChatFile()
const fp = await fingerprintFile(path)
if (!fp) throw new Error('failed to fingerprint seeded chat file')
const cache: SessionCache = {
version: CACHE_VERSION,
providers: {
kiro: {
envFingerprint,
files: {
[path]: {
fingerprint: fp,
mcpInventory: [],
turns: [{
timestamp: '2026-05-27T00:00:00.000Z',
sessionId: 'wf-chat-stale-001',
userMessage: 'x'.repeat(500),
calls: [{
provider: 'kiro',
model: 'claude-haiku-4-5',
usage: {
inputTokens: 125,
outputTokens: 2,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-05-27T00:00:00.000Z',
tools: [],
bashCommands: [],
skills: [],
subagentTypes: [],
deduplicationKey: 'kiro:wf-chat-stale-001:exec-chat-stale-001',
}],
}],
},
},
},
},
}
await mkdir(CACHE_DIR, { recursive: true })
await writeFile(sessionCachePath(), JSON.stringify(cache))
return path
}

async function parseKiroCalls() {
const projects = await parseAllSessions(undefined, 'kiro')
return projects
Expand All @@ -127,6 +215,26 @@ describe('Kiro session cache invalidation', () => {
expect(computeEnvFingerprint('kiro')).not.toBe(preFixFingerprint())
})

it('bumps the kiro parser version again for the full-prompt input-token fix', () => {
// The v1 fingerprint is what the release that shipped the 500-char-slice
// estimate wrote into every user cache. It must NOT match the current one,
// or cached chat files would keep the pre-fix 125-token estimate forever.
expect(computeEnvFingerprint('kiro')).not.toBe(v1Fingerprint())
})

it('control: a chat cache at the CURRENT fingerprint is honored (stale 125 stays)', async () => {
await seedChatCache(computeEnvFingerprint('kiro'))

const calls = await parseKiroCalls()

// The seeded cache is structurally valid and trusted: the unchanged chat
// file is not re-parsed, so the pre-fix 125-token estimate survives
// verbatim. This proves the seed is real (not silently ignored) — and that
// WITHOUT a fingerprint bump, the stale estimate would be served forever.
expect(calls).toHaveLength(1)
expect(calls[0]!.usage.inputTokens).toBe(125)
})

it('control: a zero-turn cache entry at the current fingerprint is honored', async () => {
const execPath = await seedExecutionFile()
await seedCache(execPath, computeEnvFingerprint('kiro'))
Expand All @@ -140,6 +248,18 @@ describe('Kiro session cache invalidation', () => {
expect(calls).toHaveLength(0)
})

it('regression: a v1 cache fingerprint forces a re-parse that lands the corrected tokens', async () => {
await seedChatCache(v1Fingerprint())

const calls = await parseKiroCalls()

// The v1 fingerprint no longer matches, the stale section is discarded,
// the unchanged chat file re-parses, and the full-prompt estimate (3000
// chars / 4 = 750) replaces the cached 125 — the money-path correction.
expect(calls).toHaveLength(1)
expect(calls[0]!.usage.inputTokens).toBe(750)
})

it('regression: a pre-fix cache fingerprint forces a re-parse that recovers the calls', async () => {
const execPath = await seedExecutionFile()
await seedCache(execPath, preFixFingerprint())
Expand Down
Loading
Loading