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
31 changes: 31 additions & 0 deletions src/content-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,34 @@ 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. Strings already within the bound are returned as-is: they ARE
/// the parent, so nothing extra is retained.
export function flatSlice(s: string, max: number): string {
if (s.length <= max) return s
return Buffer.from(s.slice(0, max), 'utf-8').toString('utf-8')
}

/// 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, 'utf-8').toString('utf-8')
}
30 changes: 24 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,24 @@ 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.
const canonicalPathCache = new Map<string, { 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 = await 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 +1278,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 +1917,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 +2470,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 +2501,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 +2524,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 +3270,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
99 changes: 99 additions & 0 deletions tests/flat-slice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Tests for flatSlice — the SlicedString-retention fix.
*
* Background: `String.prototype.slice` returns a V8 SlicedString that
* retains its entire parent string. Storing short slices of large session
* strings (100KB+ agent prompts) in the long-lived session cache pinned
* gigabytes of parent buffers during cold parses, OOMing the default heap
* (issue observed at ~5.5GB peak for 3.2GB of kiro session files; ~300MB
* after flattening).
*/

import { describe, it, expect } from 'vitest'

import { flatSlice, flatString } from '../src/content-utils.js'

describe('flatSlice', () => {
it('returns the prefix for strings over the bound', () => {
const big = 'x'.repeat(10_000)
const out = flatSlice(big, 500)
expect(out.length).toBe(500)
expect(out).toBe(big.slice(0, 500))
})

it('returns the string itself when within the bound', () => {
const small = 'hello world'
expect(flatSlice(small, 500)).toBe(small)
})

it('handles multi-byte characters without corruption', () => {
// Emoji + CJK near the boundary — Buffer round-trip must not produce
// invalid UTF-8 replacement chars for chars fully inside the slice.
const s = '🐾'.repeat(300) // each emoji is 2 UTF-16 code units
const out = flatSlice(s, 500)
expect(out).toBe(s.slice(0, 500))
})

it('documents the mid-surrogate-pair cut behavior (U+FFFD)', () => {
// A cut landing between the high and low surrogate of a pair leaves a
// lone surrogate. Plain .slice() preserves it; the Buffer round-trip
// replaces it with U+FFFD. Either way the string is length-bounded and
// the preceding content is intact — this test pins the chosen behavior
// so a future implementation change is a conscious decision.
const s = 'ab' + '🐾'.repeat(300) // odd offset puts every emoji across even boundaries
const out = flatSlice(s, 501) // cuts mid-pair
expect(out.length).toBe(501)
expect(out.slice(0, 500)).toBe(s.slice(0, 500)) // content before the cut intact
expect(out.charCodeAt(500)).toBe(0xfffd) // lone surrogate became U+FFFD
})

it('does not retain the parent string (heap growth stays bounded)', () => {
// Property test for the retention fix: keep 1000 short prefixes of
// 1000 distinct 100KB strings. With plain .slice() each prefix pins its
// 100KB parent (~200MB in UTF-16 total). With flatSlice, retained data
// is ~1000 × 500 chars ≈ 1MB. Assert heap growth is far below the
// retention scenario. Threshold is generous (50MB) to be CI-safe while
// still failing decisively if retention returns (>190MB). When the test
// runner exposes gc (vitest under --expose-gc), force a collection so
// transient parent garbage doesn't inflate the measurement.
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
// Distinct content so V8 cannot intern/share the parents.
const parent = (i % 10).toString().repeat(100_000)
kept.push(flatSlice(parent + i, 500))
}
if (typeof global.gc === 'function') global.gc()
const after = process.memoryUsage().heapUsed
const growthMB = (after - before) / 1048576
expect(kept.length).toBe(1000)
expect(growthMB).toBeLessThan(50)
})
})

describe('flatString', () => {
it('returns an equal string for any input', () => {
expect(flatString('')).toBe('')
expect(flatString('hello')).toBe('hello')
expect(flatString('🐾 multi-byte ✓')).toBe('🐾 multi-byte ✓')
})

it('does not retain the parent of a regex match group', () => {
// match[1] is a SlicedString retaining the entire subject. flatString
// must break that link: keep 1000 short match groups of distinct 100KB
// subjects and assert bounded heap growth (same thresholds as the
// flatSlice retention test).
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
const subject = `<name>tool_${i}</name>` + (i % 10).toString().repeat(100_000)
const m = /<name>([^<]+)<\/name>/.exec(subject)
kept.push(flatString(m![1]!))
}
if (typeof global.gc === 'function') global.gc()
const after = process.memoryUsage().heapUsed
const growthMB = (after - before) / 1048576
expect(kept.length).toBe(1000)
expect(growthMB).toBeLessThan(50)
})
})
Loading
Loading