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 + }, + } +}