From e8927ebf7844366fd28dad5e8fac3fd62c72aff8 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 04:23:16 -0300 Subject: [PATCH] perf(extension): cache parsed session files by mtime and size GET /api/sessions (and every session lookup) re-read and JSON.parsed every .jsonl session file synchronously on each request, blocking the agent's event loop. Parsed metadata is now cached per path (bounded to 500 entries) and invalidated when mtime or size changes. --- extensions/piagentui-server.ts | 93 ++++++++++++++++--------- extensions/piagentui-stat-cache.test.ts | 50 +++++++++++++ extensions/piagentui-stat-cache.ts | 34 +++++++++ 3 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 extensions/piagentui-stat-cache.test.ts create mode 100644 extensions/piagentui-stat-cache.ts diff --git a/extensions/piagentui-server.ts b/extensions/piagentui-server.ts index 5ef3c5c..62a3944 100644 --- a/extensions/piagentui-server.ts +++ b/extensions/piagentui-server.ts @@ -21,6 +21,7 @@ import { type StoredSttSettings, type StoredTtsSettings, } from './piagentui-settings-store.js' +import { createStatCache } from './piagentui-stat-cache.js' import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js' import { buildExtensionCatalog, @@ -518,48 +519,76 @@ export default function (pi: ExtensionAPI) { .slice(0, 120) } + type ParsedSessionFile = { + id: string + title: string + cwd: string | undefined + headerTimestamp: string | undefined + messageCount: number + } + + // GET /api/sessions parses every session file; re-parse only files whose mtime/size changed. + const sessionFileCache = createStatCache() + + function parseSessionFile(filePath: string): ParsedSessionFile | null { + const content = fs.readFileSync(filePath, 'utf8') + const lines = content.trim().split('\n').filter(Boolean) + if (lines.length === 0) return null + + const header = JSON.parse(lines[0]) + if (header.type !== 'session' || !header.id) return null + + let sessionInfoTitle = '' + let firstUserMessage = '' + let messageCount = 0 + + for (const line of lines.slice(1)) { + try { + const entry = JSON.parse(line) + if (entry.type === 'session_info') { + sessionInfoTitle = normalizeSessionTitle(entry.name || '') + } else if (entry.type === 'message') { + messageCount++ + if (!firstUserMessage && entry.message?.role === 'user') { + firstUserMessage = normalizeSessionTitle(getMessageText(entry.message)) + } + } + } catch { + // ignore malformed entries + } + } + + return { + id: header.id, + title: sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id), + cwd: header.cwd, + headerTimestamp: header.timestamp, + messageCount, + } + } + function readSessionMetadata(filePath: string): any | null { try { - const content = fs.readFileSync(filePath, 'utf8') - const lines = content.trim().split('\n').filter(Boolean) - if (lines.length === 0) return null - - const header = JSON.parse(lines[0]) - if (header.type !== 'session' || !header.id) return null - - let sessionInfoTitle = '' - let firstUserMessage = '' - let messageCount = 0 - - for (const line of lines.slice(1)) { - try { - const entry = JSON.parse(line) - if (entry.type === 'session_info') { - sessionInfoTitle = normalizeSessionTitle(entry.name || '') - } else if (entry.type === 'message') { - messageCount++ - if (!firstUserMessage && entry.message?.role === 'user') { - firstUserMessage = normalizeSessionTitle(getMessageText(entry.message)) - } - } - } catch { - // ignore malformed entries - } + const stats = fs.statSync(filePath) + let cached = sessionFileCache.get(filePath, stats) + if (!cached) { + cached = { value: parseSessionFile(filePath) } + sessionFileCache.set(filePath, stats, cached.value) } - const stats = fs.statSync(filePath) - const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs - const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id) + const parsed = cached.value + if (!parsed) return null + const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs return { - id: header.id, - title, - directory: header.cwd || latestCtx?.cwd || process.cwd(), + id: parsed.id, + title: parsed.title, + directory: parsed.cwd || latestCtx?.cwd || process.cwd(), time: { created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime, updated: stats.mtimeMs, }, - summary: { deletions: 0, files: 0, additions: messageCount }, + summary: { deletions: 0, files: 0, additions: parsed.messageCount }, } } catch { return null diff --git a/extensions/piagentui-stat-cache.test.ts b/extensions/piagentui-stat-cache.test.ts new file mode 100644 index 0000000..30ef7d5 --- /dev/null +++ b/extensions/piagentui-stat-cache.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { createStatCache } from './piagentui-stat-cache.js' + +const stamp = (mtimeMs: number, size = 10) => ({ mtimeMs, size }) + +describe('createStatCache', () => { + it('misses for unknown paths and hits for the same stamp', () => { + const cache = createStatCache() + expect(cache.get('a', stamp(1))).toBeUndefined() + cache.set('a', stamp(1), 'x') + expect(cache.get('a', stamp(1))).toEqual({ value: 'x' }) + }) + + it('treats a changed mtime or size as a miss and drops the stale entry', () => { + const cache = createStatCache() + cache.set('a', stamp(1, 10), 'x') + expect(cache.get('a', stamp(2, 10))).toBeUndefined() + expect(cache.size).toBe(0) + + cache.set('a', stamp(1, 10), 'x') + expect(cache.get('a', stamp(1, 11))).toBeUndefined() + }) + + it('can cache null values', () => { + const cache = createStatCache() + cache.set('a', stamp(1), null) + expect(cache.get('a', stamp(1))).toEqual({ value: null }) + }) + + it('evicts the oldest entries beyond the limit', () => { + const cache = createStatCache(2) + cache.set('a', stamp(1), 1) + cache.set('b', stamp(1), 2) + cache.set('c', stamp(1), 3) + expect(cache.size).toBe(2) + expect(cache.get('a', stamp(1))).toBeUndefined() + expect(cache.get('b', stamp(1))).toEqual({ value: 2 }) + expect(cache.get('c', stamp(1))).toEqual({ value: 3 }) + }) + + it('refreshing a key moves it to the newest position', () => { + const cache = createStatCache(2) + cache.set('a', stamp(1), 1) + cache.set('b', stamp(1), 2) + cache.set('a', stamp(2), 11) + cache.set('c', stamp(1), 3) + expect(cache.get('b', stamp(1))).toBeUndefined() + expect(cache.get('a', stamp(2))).toEqual({ value: 11 }) + }) +}) diff --git a/extensions/piagentui-stat-cache.ts b/extensions/piagentui-stat-cache.ts new file mode 100644 index 0000000..d1a63ab --- /dev/null +++ b/extensions/piagentui-stat-cache.ts @@ -0,0 +1,34 @@ +export type FileStamp = { mtimeMs: number; size: number } + +/** + * Small bounded cache of values derived from file contents, keyed by path and + * invalidated when the file's mtime or size changes. Oldest entries are evicted first. + */ +export function createStatCache(maxEntries = 500) { + const entries = new Map() + + return { + /** Returns `{ value }` on a hit (the value itself may be null), `undefined` on a miss or stale entry. */ + get(filePath: string, stamp: FileStamp): { value: T } | undefined { + const entry = entries.get(filePath) + if (!entry) return undefined + if (entry.mtimeMs !== stamp.mtimeMs || entry.size !== stamp.size) { + entries.delete(filePath) + return undefined + } + return { value: entry.value } + }, + set(filePath: string, stamp: FileStamp, value: T): void { + entries.delete(filePath) + entries.set(filePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, value }) + while (entries.size > maxEntries) { + const oldest = entries.keys().next().value + if (oldest === undefined) break + entries.delete(oldest) + } + }, + get size(): number { + return entries.size + }, + } +}