diff --git a/CHANGELOG.md b/CHANGELOG.md index 477c7f4f..5a40b216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 0252e901..d6e10b32 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -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 diff --git a/src/content-utils.ts b/src/content-utils.ts index f3717194..0c41afb6 100644 --- a/src/content-utils.ts +++ b/src/content-utils.ts @@ -38,11 +38,12 @@ export function normalizeContentBlocks() +// 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>() 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 } diff --git a/tests/flat-slice.test.ts b/tests/flat-slice.test.ts index 78d93bac..8e997ba4 100644 --- a/tests/flat-slice.test.ts +++ b/tests/flat-slice.test.ts @@ -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)', () => {