fix(parser): flatten already-sliced previews and memoize canonical path walks

flatSlice returned strings within the bound unchanged, but provider adapters
pre-truncate with .slice(0, 500) before the cache site, so those views still
pinned their parent buffers. Always flatten; the round-trip is ~150ns per turn.
Use utf16le so lone surrogates survive the copy.

Cache the canonical-path Promise instead of the resolved value so calls in one
Promise.all batch share a single walk.

Document the one-time kiro re-parse and worktree regrouping.
This commit is contained in:
iamtoruk 2026-08-16 01:49:10 -07:00
parent db35fdd079
commit c7c3a878d8
5 changed files with 40 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -38,11 +38,12 @@ export function normalizeContentBlocks<T extends { type?: string; text?: string
/// 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.
/// 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 {
if (s.length <= max) return s
return Buffer.from(s.slice(0, max), 'utf-8').toString('utf-8')
return Buffer.from(s.slice(0, max), 'utf16le').toString('utf16le')
}
/// Force a FLAT copy of a string regardless of length.
@ -53,5 +54,5 @@ export function flatSlice(s: string, max: number): string {
/// 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')
return Buffer.from(s, 'utf16le').toString('utf16le')
}

View file

@ -98,12 +98,15 @@ function isCoworkSession(cwd: string, filePath: string): boolean {
// (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 }>()
// 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 = await resolveCanonicalProjectPathUncached(cwd)
const result = resolveCanonicalProjectPathUncached(cwd)
canonicalPathCache.set(cwd, result)
return result
}

View file

@ -34,17 +34,35 @@ describe('flatSlice', () => {
expect(out).toBe(s.slice(0, 500))
})
it('documents the mid-surrogate-pair cut behavior (U+FFFD)', () => {
it('preserves a lone surrogate at a mid-pair cut', () => {
// 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.
// lone surrogate. utf16le round-trips code units byte-for-byte, so the
// lone surrogate survives intact (unlike utf-8, which would replace it
// with U+FFFD).
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
expect(out.charCodeAt(500)).toBe(s.charCodeAt(500)) // lone surrogate preserved
})
it('does not retain the parent of an already-sliced view', () => {
// The bug this early-return removal fixes: provider adapters pre-truncate
// with .slice(0, 500) before the cache-site flatSlice call, so a naive
// "already within bound" early return would skip flattening and leave
// the SlicedString pinning its 100KB parent.
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
const parent = (i % 10).toString().repeat(100_000) + i
const preSliced = parent.slice(0, 500)
kept.push(flatSlice(preSliced, 2000))
}
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)
})
it('does not retain the parent string (heap growth stays bounded)', () => {