Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.

## 0.9.20 - 2026-08-10

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/providers/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire
- 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`.
- **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.
- **`projectPath` for git attribution.** The parser now records the session's working directory as `projectPath` (CLI `meta.cwd`, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), which sync attribution needs to resolve the git repo. The `project-path-v1` parse-version bump re-parses cached kiro history once; sessions in linked git worktrees now group under the main repo.

## When fixing a bug here

Expand Down
32 changes: 32 additions & 0 deletions src/content-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,35 @@ export function normalizeContentBlocks<T extends { type?: string; text?: string
if (typeof content === 'string') return [{ type: 'text', text: content } as T]
return []
}

/// Take a bounded prefix of a string as a FLAT copy.
///
/// `String.prototype.slice` returns a V8 SlicedString — a view object that
/// retains a reference to its ENTIRE parent string. Session files routinely
/// carry 100KB+ message strings (agent-injected system prompts, tool
/// results); storing a short `.slice()` of each in a long-lived structure
/// (the session cache) pins every parent buffer for the life of the process.
/// Across thousands of session files this balloons a cold parse of a few GB
/// of JSONL into an out-of-memory crash (~5.5GB peak observed), while a warm
/// run — whose strings were flattened by the cache's JSON round-trip — needs
/// only ~300MB for the same data.
///
/// Round-tripping through a Buffer forces a fresh flat string with no parent
/// reference. This always runs, even when `s` is already within `max`:
/// callers may pass an already-sliced view (provider adapters pre-truncate
/// with `.slice(0, 500)` before the cache-site call), and that view is
/// itself a SlicedString pinning its own large parent.
export function flatSlice(s: string, max: number): string {
return Buffer.from(s.slice(0, max), 'utf16le').toString('utf16le')
}

/// Force a FLAT copy of a string regardless of length.
///
/// Companion to `flatSlice` for strings that are ALREADY short but were
/// produced as views over a large parent — regex match groups
/// (`match[1]` retains the entire subject string) and `trim()` results
/// both come back as V8 SlicedStrings. Use this when storing such values
/// in long-lived structures; use `flatSlice` when also bounding length.
export function flatString(s: string): string {
return Buffer.from(s, 'utf16le').toString('utf16le')
}
33 changes: 27 additions & 6 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { basename, dirname, join, resolve, sep } from 'path'
import { readSessionLines } from './fs-utils.js'
import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js'
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
import { normalizeContentBlocks } from './content-utils.js'
import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js'
Expand Down Expand Up @@ -91,7 +91,27 @@ function isCoworkSession(cwd: string, filePath: string): boolean {
})
}

// Memoizes resolveCanonicalProjectPath: every ParsedProviderCall with a
// projectPath pays the .git-marker directory walk (one lstat per ancestor
// level), and a session's calls all share one cwd — without this cache a
// cold parse re-walks the same few directories thousands of times
// (measured ~+5% cold-parse time for a large kiro store). Filesystem facts
// can go stale in a long-lived process (a dir converted to a worktree
// mid-run), so the cache is cleared with the session cache.
// Stores the Promise, not the resolved value: callers within the same
// Promise.all batch would otherwise all miss the cache and each re-walk the
// filesystem before the first walk's result lands.
const canonicalPathCache = new Map<string, Promise<{ path: string; isWorktree: boolean }>>()

async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
const cached = canonicalPathCache.get(cwd)
if (cached) return cached
const result = resolveCanonicalProjectPathUncached(cwd)
canonicalPathCache.set(cwd, result)
return result
}

async function resolveCanonicalProjectPathUncached(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
const trimmed = cwd.trim()
if (!trimmed) return { path: cwd, isWorktree: false }

Expand Down Expand Up @@ -1261,7 +1281,7 @@ export function collectToolResultMeta(entry: JournalEntry, map: Map<string, Tool
export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void {
if (entry.type === 'ai-title') {
const t = (entry as Record<string, unknown>)['aiTitle']
if (typeof t === 'string' && t.trim()) meta.title = t.trim().slice(0, 200)
if (typeof t === 'string' && t.trim()) meta.title = flatString(t.trim().slice(0, 200))
} else if (entry.type === 'pr-link') {
const url = (entry as Record<string, unknown>)['prUrl']
if (typeof url === 'string' && url && !meta.prLinks.includes(url)) meta.prLinks.push(url)
Expand Down Expand Up @@ -1900,7 +1920,7 @@ export async function readAgentType(filePath: string): Promise<string | undefine
const metaPath = filePath.replace(/\.jsonl$/, '.meta.json')
try {
const t = (JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: unknown }).agentType
if (typeof t === 'string' && t.trim()) return t.trim().slice(0, 100)
if (typeof t === 'string' && t.trim()) return flatString(t.trim().slice(0, 100))
} catch { /* missing or unreadable meta */ }
// Workflow agents always live under `subagents/workflows/`, so fall back to that
// even when the meta sidecar is absent.
Expand Down Expand Up @@ -2453,7 +2473,7 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
return {
timestamp: turn.timestamp,
sessionId: turn.sessionId,
userMessage: turn.userMessage.slice(0, 2000),
userMessage: flatSlice(turn.userMessage, 2000),
calls: turn.assistantCalls.map(apiCallToCachedCall),
// Stored per-turn directly (already sorted/deduped in groupIntoTurns), unlike
// gitBranch's change-detection dedup, so each turn's refs are self-contained.
Expand Down Expand Up @@ -2484,7 +2504,7 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
return {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
userMessage: flatSlice(call.userMessage, 2000),
calls: [providerCallToCachedCall(call)],
...(prRefs.length ? { prRefs } : {}),
}
Expand All @@ -2507,7 +2527,7 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
turn = {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
userMessage: flatSlice(call.userMessage, 2000),
calls: [],
...(prRefs.length ? { prRefs } : {}),
}
Expand Down Expand Up @@ -3253,6 +3273,7 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string {

export function clearSessionCache(): void {
sessionCache.clear()
canonicalPathCache.clear()
}

function cachePut(key: string, data: ProjectSummary[]) {
Expand Down
23 changes: 16 additions & 7 deletions src/providers/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { basename, dirname, extname, join } from 'path'
import { homedir } from 'os'

import { readSessionFile } from '../fs-utils.js'
import { flatSlice, flatString } from '../content-utils.js'
import { calculateCost } from '../models.js'
import { estimateTokensFromChars } from '../token-estimate.js'
import type { ToolCall } from '../types.js'
Expand Down Expand Up @@ -98,7 +99,10 @@ function extractToolNames(content: string): string[] {
let match
while ((match = regex.exec(content)) !== null) {
const name = match[1]!.trim()
tools.push(toolNameMap[name] ?? name)
// flatString: regex match groups are V8 SlicedStrings that retain the
// ENTIRE subject string — storing them in the session cache would pin
// every scanned assistant-content buffer. Mapped names are flat literals.
tools.push(toolNameMap[name] ?? flatString(name))
}
return tools
}
Expand Down Expand Up @@ -217,7 +221,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
if (msg.role === 'human') {
if (msg.content.startsWith('<identity>')) continue
inputChars += msg.content.length
pendingUserMessage = msg.content.slice(0, 500)
pendingUserMessage = flatSlice(msg.content, 500)
}
if (msg.role === 'bot') {
const msgTools = extractToolNames(msg.content)
Expand Down Expand Up @@ -296,7 +300,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see

if (directInput) {
inputChars += directInput.length
pendingUserMessage = directInput.slice(0, 500)
pendingUserMessage = flatSlice(directInput, 500)
}

if (directOutput) {
Expand Down Expand Up @@ -328,7 +332,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see
if (role === 'human' || role === 'user') {
if (!text) continue
inputChars += text.length
pendingUserMessage = text.slice(0, 500)
pendingUserMessage = flatSlice(text, 500)
} else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') {
if (text) outputChars += text.length
if (text || tools.length > 0) hasOutputActivity = true
Expand Down Expand Up @@ -506,6 +510,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen
userMessage: pendingUserMessage,
sessionId,
project,
...(meta.cwd ? { projectPath: meta.cwd } : {}),
})
turnIndex++
}
Expand All @@ -526,7 +531,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen
for (const item of content) {
const rec = asRecord(item)
if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') {
pendingUserMessage = (rec['data'] as string).slice(0, 500)
pendingUserMessage = flatSlice(rec['data'] as string, 500)
inputChars += (rec['data'] as string).length
}
}
Expand Down Expand Up @@ -605,7 +610,7 @@ async function parseWorkspaceSession(record: Record<string, unknown>, source: Se
const text = extractText(msg['content'])
if (role === 'user' && text) {
inputChars += text.length
pendingUserMessage = text.slice(0, 500)
pendingUserMessage = flatSlice(text, 500)
} else if (role === 'assistant' && !execBacked && text && text !== 'On it.') {
// An item carrying an executionId is execution-backed: its content is
// counted from the execution file, so counting it here would double-count.
Expand Down Expand Up @@ -662,6 +667,9 @@ async function parseWorkspaceSession(record: Record<string, unknown>, source: Se
deduplicationKey: dedupKey,
userMessage: pendingUserMessage,
sessionId,
...(typeof record['workspaceDirectory'] === 'string' && record['workspaceDirectory']
? { projectPath: record['workspaceDirectory'] as string }
: {}),
})

return results
Expand Down Expand Up @@ -774,6 +782,7 @@ async function parseV2Session(source: SessionSource, seenKeys: Set<string>): Pro
userMessage: turnUserMessage,
sessionId,
project: source.project,
...(meta.workspacePaths?.[0] ? { projectPath: meta.workspacePaths[0] } : {}),
})
}
}
Expand All @@ -794,7 +803,7 @@ async function parseV2Session(source: SessionSource, seenKeys: Set<string>): Pro
// for the upcoming turn_start.
if (inTurn) flushTurn()
const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content'])
pendingUserMessage = text.slice(0, 500)
pendingUserMessage = flatSlice(text, 500)
pendingUserChars = text.length
} else if (type === 'turn_start') {
if (inTurn) flushTurn()
Expand Down
7 changes: 6 additions & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,12 @@ 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',
// project-path-v1: the parser now records the session's full working
// directory as projectPath (CLI meta.cwd, v2 workspacePaths[0], workspace
// sessions' workspaceDirectory), which sync attribution needs to resolve
// the git repo. Cached entries from before the bump lack projectPath and
// would serve attribution-blind sessions forever without a re-parse.
kiro: 'ide-parsing-v1-est-cost-project-path-v1',
opencode: 'session-model-v1',
quickdesk: 'emf-sqlite-v2-est-cost',
kimicode: 'wire-usage-v1-est-cost',
Expand Down
Loading
Loading