Skip to content

Commit 916119d

Browse files
authored
Merge pull request #749 from getagentseal/perf/incremental-append-parse
perf(parser): incremental parse of append-grown session files
2 parents 8892553 + 8326aee commit 916119d

4 files changed

Lines changed: 366 additions & 2 deletions

File tree

src/parser.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,7 +1593,7 @@ async function scanProjectDirs(
15931593

15941594
type FileInfo = { dirName: string; fp: NonNullable<Awaited<ReturnType<typeof fingerprintFile>>>; source?: SessionSourceMetadata }
15951595
const unchangedFiles: Array<{ filePath: string; dirName: string; source?: SessionSourceMetadata; cached: CachedFile }> = []
1596-
const changedFiles: Array<{ filePath: string; info: FileInfo }> = []
1596+
const changedFiles: Array<{ filePath: string; info: FileInfo; append?: { cached: CachedFile; readFromOffset: number } }> = []
15971597

15981598
const discoverProgress = createScanProgress('scanning claude project dirs', dirs.length)
15991599
let dirsDone = 0
@@ -1607,6 +1607,12 @@ async function scanProjectDirs(
16071607
const action = reconcileFile(fp, section.files[filePath])
16081608
if (action.action === 'unchanged') {
16091609
unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! })
1610+
} else if (action.action === 'appended') {
1611+
changedFiles.push({
1612+
filePath,
1613+
info: { dirName, fp, source },
1614+
append: { cached: section.files[filePath]!, readFromOffset: action.readFromOffset },
1615+
})
16101616
} else {
16111617
changedFiles.push({ filePath, info: { dirName, fp, source } })
16121618
}
@@ -1629,10 +1635,78 @@ async function scanProjectDirs(
16291635
const progressTotal = changedFiles.length
16301636
let filesDone = 0
16311637
emitScanProgress({ kind: 'tick', provider: 'claude', done: 0, total: progressTotal })
1632-
for (const { filePath, info } of changedFiles) {
1638+
for (const { filePath, info, append } of changedFiles) {
16331639
delete section.files[filePath]
16341640

16351641
try {
1642+
if (append) {
1643+
// Append-only growth: parse ONLY the bytes past the cached resume offset
1644+
// and merge with the cached turns, rather than re-reading the file from 0.
1645+
// On a studio machine where live agents constantly append to session
1646+
// JSONL, this is the dominant warm-run cost. The merged result is
1647+
// byte-for-byte identical to a full re-parse (see mergeBoundaryCalls).
1648+
const tracker = { lastCompleteLineOffset: append.readFromOffset }
1649+
const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset)
1650+
const cached = append.cached
1651+
1652+
const newTurns = newEntries
1653+
? groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds).map(parsedTurnToCachedTurn)
1654+
: []
1655+
1656+
const mergedTurns: CachedTurn[] = cached.turns.map(t => ({ ...t, calls: [...t.calls] }))
1657+
if (newTurns.length > 0) {
1658+
let startIdx = 0
1659+
// A first new turn with no leading user message is a continuation of
1660+
// the last cached turn — merge its calls in (a full re-parse would put
1661+
// them in that same turn), then append the remaining new turns.
1662+
if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) {
1663+
const last = mergedTurns[mergedTurns.length - 1]!
1664+
last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls)
1665+
startIdx = 1
1666+
}
1667+
for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!)
1668+
}
1669+
1670+
// The cached region's dedup keys were not added to seenMsgIds (only
1671+
// unchanged files pre-seed it), so add them now — a full re-parse would
1672+
// have, and later files dedup cross-file against them.
1673+
for (const t of cached.turns) for (const c of t.calls) seenMsgIds.add(c.deduplicationKey)
1674+
1675+
// First-cwd wins, and the first cwd lives in the cached region whenever
1676+
// one was resolved there; only re-derive if the cached region had none.
1677+
let canonicalCwd = cached.canonicalCwd
1678+
let canonicalProjectName = cached.canonicalProjectName
1679+
if (canonicalCwd === undefined && newEntries) {
1680+
const cwd = extractCanonicalCwd(newEntries)
1681+
const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined
1682+
canonicalCwd = canonical?.path
1683+
canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined
1684+
}
1685+
1686+
// Inventory is a sorted set union; cached (older entries) ∪ new = full.
1687+
const mcpInventory = newEntries
1688+
? Array.from(new Set([...cached.mcpInventory, ...extractMcpInventory(newEntries)])).sort()
1689+
: cached.mcpInventory
1690+
1691+
section.files[filePath] = {
1692+
fingerprint: info.fp,
1693+
lastCompleteLineOffset: tracker.lastCompleteLineOffset,
1694+
canonicalCwd,
1695+
canonicalProjectName,
1696+
mcpInventory,
1697+
turns: mergedTurns,
1698+
agentType: cached.agentType,
1699+
}
1700+
;(diskCache as { _dirty?: boolean })._dirty = true
1701+
filesDone++
1702+
await parseProgress.tick(filesDone)
1703+
if (filesDone % 50 === 0 || filesDone === progressTotal) {
1704+
emitScanProgress({ kind: 'tick', provider: 'claude', done: filesDone, total: progressTotal })
1705+
}
1706+
if (onFileParsed) await onFileParsed()
1707+
continue
1708+
}
1709+
16361710
const tracker = { lastCompleteLineOffset: 0 }
16371711
const entries = await parseClaudeEntries(filePath, tracker)
16381712
if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue }
@@ -1968,15 +2042,52 @@ function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn {
19682042

19692043
// ── Cache-Aware Parsing Helpers ────────────────────────────────────────
19702044

2045+
// Merge the calls of the last cached turn with the calls parsed from the
2046+
// appended region when the appended region continues that turn (its first new
2047+
// content had no leading user message). This mirrors `dedupeStreamingMessageIds`
2048+
// at the call level: a Claude message re-emitted across the append boundary
2049+
// (same `msg.id`, or the trailing not-yet-newline-terminated line re-read from
2050+
// the resume offset) collapses to its LAST occurrence, keeping the FIRST
2051+
// occurrence's timestamp — byte-for-byte what a full re-parse of the combined
2052+
// stream produces. Synthetic `claude:<ts>` keys (id-less entries) are never
2053+
// collapsed, matching `getMessageId` returning null for them.
2054+
function mergeBoundaryCalls(cachedCalls: CachedCall[], newCalls: CachedCall[]): CachedCall[] {
2055+
const combined = [...cachedCalls, ...newCalls]
2056+
const firstIdx = new Map<string, number>()
2057+
const lastIdx = new Map<string, number>()
2058+
for (let i = 0; i < combined.length; i++) {
2059+
const key = combined[i]!.deduplicationKey
2060+
if (key.startsWith('claude:')) continue
2061+
if (!firstIdx.has(key)) firstIdx.set(key, i)
2062+
lastIdx.set(key, i)
2063+
}
2064+
if (lastIdx.size === 0) return combined
2065+
const result: CachedCall[] = []
2066+
for (let i = 0; i < combined.length; i++) {
2067+
const call = combined[i]!
2068+
const key = call.deduplicationKey
2069+
if (key.startsWith('claude:')) { result.push(call); continue }
2070+
if (lastIdx.get(key) !== i) continue
2071+
if (firstIdx.get(key) !== i) {
2072+
result.push({ ...call, timestamp: combined[firstIdx.get(key)!]!.timestamp })
2073+
continue
2074+
}
2075+
result.push(call)
2076+
}
2077+
return result
2078+
}
2079+
19712080
async function parseClaudeEntries(
19722081
filePath: string,
19732082
tracker: { lastCompleteLineOffset: number },
2083+
startByteOffset?: number,
19742084
): Promise<JournalEntry[] | null> {
19752085
const entries: JournalEntry[] = []
19762086
let hasLines = false
19772087
for await (const line of readSessionLines(filePath, undefined, {
19782088
largeLineAsBuffer: true,
19792089
byteOffsetTracker: tracker,
2090+
...(startByteOffset !== undefined ? { startByteOffset } : {}),
19802091
})) {
19812092
hasLines = true
19822093
const entry = parseJsonlLine(line)

src/session-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,10 @@ export function reconcileFile(
435435

436436
if (
437437
cached.lastCompleteLineOffset !== undefined &&
438+
// Defensive: never resume past the file's current end. A truncate-then-regrow
439+
// can leave the cached offset stranded beyond live bytes; reading from there
440+
// would silently drop the appended tail, so fall back to a full re-parse.
441+
cached.lastCompleteLineOffset <= current.sizeBytes &&
438442
fp.dev === current.dev &&
439443
fp.ino === current.ino &&
440444
current.sizeBytes > fp.sizeBytes

0 commit comments

Comments
 (0)