From 6b42097d12851b80e7dd442610d1de9bdb2e3fad Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 27 Jul 2026 20:33:38 +0200 Subject: [PATCH] perf(agent): implemented caching for session file scans and actions - Add an LRU cache to `scanSessionFile` in `session-listing.ts` keyed by file path, stat identity, and scan mode. - Add a match key union probe in `CustomEditor` in `custom-editor.ts` to bypass per-action lookups on plain text input. - Add tests covering cache hits, size and mtime invalidations, and negative result caching. --- .../src/modes/components/custom-editor.ts | 44 +++++---- .../src/session/session-listing.ts | 70 ++++++++++++++- .../test/session-listing-cache.test.ts | 90 +++++++++++++++++++ 3 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 packages/coding-agent/test/session-listing-cache.test.ts diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 624321a5a..2c2aa2132 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -71,6 +71,14 @@ function buildMatchKeys(keys: readonly KeyId[]): Set { return matchKeys; } +function unionOfMatchKeys(matchKeys: ReadonlyMap>): Set { + const union = new Set(); + for (const keys of matchKeys.values()) { + for (const key of keys) union.add(key); + } + return union; +} + const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i; @@ -600,14 +608,14 @@ export class CustomEditor extends Editor { buildMatchKeys(keys), ]), ); + /** Union of every action's match keys: one probe in `handleInput` decides + * whether the per-action interception chain can match at all. */ + #actionMatchKeyUnion = unionOfMatchKeys(this.#actionMatchKeys); setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void { this.#actionKeys.set(action, [...keys]); - this.#rebuildActionMatchKeys(action); - } - - #rebuildActionMatchKeys(action: ConfigurableEditorAction): void { - this.#actionMatchKeys.set(action, buildMatchKeys(this.#actionKeys.get(action) ?? [])); + this.#actionMatchKeys.set(action, buildMatchKeys(keys)); + this.#actionMatchKeyUnion = unionOfMatchKeys(this.#actionMatchKeys); } #rebuildCustomMatchKeys(): void { @@ -760,8 +768,10 @@ export class CustomEditor extends Editor { this.#pendingInput.push(data); return; } - const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>"; - const kittyParsed = parseKittySequence(data); + // textEquals avoids getText()'s O(buffer) join on every keystroke; kitty + // sequences always start with ESC, so plain bytes skip the native parse. + const hadBareQueuePrefix = this.textEquals("->") || this.textEquals("=>"); + const kittyParsed = data.charCodeAt(0) === 0x1b ? parseKittySequence(data) : null; if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) { // Caps Lock is modifier bit 64 this.onCapsLock(); @@ -823,7 +833,12 @@ export class CustomEditor extends Editor { // Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces. if (this.#handleSpaceHold(data, canonical)) return; - if (canonical !== undefined) { + // One union probe decides whether any per-action interception below can + // match — plain typing then skips the ~20 per-action set lookups per key. + if ( + canonical !== undefined && + (this.#actionMatchKeyUnion.has(canonical) || this.#customMatchKeys.has(canonical)) + ) { // Intercept configured image paste (async - fires and handles result) if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) { void this.onPasteImage(); @@ -964,14 +979,11 @@ export class CustomEditor extends Editor { // Pass to parent for normal handling super.handleInput(data); - const cursor = this.getCursor(); - if ( - !hadBareQueuePrefix && - (this.getText() === "->" || this.getText() === "=>") && - cursor.line === 0 && - cursor.col === 2 - ) { - this.insertText("\n"); + if (!hadBareQueuePrefix && (this.textEquals("->") || this.textEquals("=>"))) { + const cursor = this.getCursor(); + if (cursor.line === 0 && cursor.col === 2) { + this.insertText("\n"); + } } } diff --git a/packages/coding-agent/src/session/session-listing.ts b/packages/coding-agent/src/session/session-listing.ts index 586cbd825..10de80def 100644 --- a/packages/coding-agent/src/session/session-listing.ts +++ b/packages/coding-agent/src/session/session-listing.ts @@ -2,8 +2,9 @@ import * as os from "node:os"; import * as path from "node:path"; import type { Message } from "@oh-my-pi/pi-ai"; import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils"; +import { LRUCache } from "lru-cache/raw"; import { computeDefaultSessionDir } from "./session-paths"; -import { FileSessionStorage, type SessionStorage } from "./session-storage"; +import { FileSessionStorage, type SessionStorage, type SessionStorageStat } from "./session-storage"; /** * Coarse lifecycle status of a session, derived from its last persisted message. @@ -65,6 +66,44 @@ const SESSION_LIST_SUFFIX_BYTES = 32_768; const SESSION_LIST_PARALLEL_THRESHOLD = 64; const SESSION_LIST_MAX_WORKERS = 16; +/** + * Memoizes {@link scanSessionFile} results keyed by stat identity so listing + * refreshes (resume picker opens, startup recent-sessions, cross-project + * scans) skip the open+read+parse for unchanged files. The `statSync` still + * runs on every scan — it IS the invalidation check: a hit requires both + * `mtimeMs` and `size` to match. This covers the two mutation paths: + * - streaming appends grow `size` (and bump `mtimeMs`); + * - `updateSessionTitle` rewrites the fixed-width title slot in place via + * `writeSync`, which leaves `size` unchanged but updates `mtimeMs`. + * Negative results (unparseable files) are cached too, as `undefined` info. + * Entries are small header objects, so a generous cap is cheap. + */ +const SESSION_SCAN_CACHE_MAX = 4096; + +interface SessionScanCacheEntry { + mtimeMs: number; + size: number; + info: SessionInfo | undefined; +} + +type SessionScanCache = LRUCache; + +/** All {@link FileSessionStorage} instances view the same real filesystem, so they share one cache. */ +const fileSessionScanCache: SessionScanCache = new LRUCache({ max: SESSION_SCAN_CACHE_MAX }); +/** Other storages (in-memory test doubles) each carry their own cache to avoid cross-instance path collisions. */ +const kScanCache = Symbol("session-listing.scanCache"); + +interface StorageWithScanCache extends SessionStorage { + [kScanCache]?: SessionScanCache; +} + +function getSessionScanCache(storage: SessionStorage): SessionScanCache { + if (storage instanceof FileSessionStorage) return fileSessionScanCache; + const holder = storage as StorageWithScanCache; + if (!holder[kScanCache]) holder[kScanCache] = new LRUCache({ max: SESSION_SCAN_CACHE_MAX }); + return holder[kScanCache]; +} + function sanitizeSessionName(value: string | undefined): string | undefined { if (!value) return undefined; const firstLine = value.split(/\r?\n/)[0] ?? ""; @@ -355,8 +394,22 @@ async function scanSessionFile( storage: SessionStorage, withStatus: boolean, ): Promise { + let stat: SessionStorageStat; + try { + stat = storage.statSync(file); + } catch { + // Missing/unstatable file: no stat identity to cache under. + return undefined; + } + const cache = getSessionScanCache(storage); + // `withStatus` changes what a scan reads (tail window) and returns, so the + // two variants are cached under distinct keys. + const cacheKey = withStatus ? `s\0${file}` : `h\0${file}`; + const cached = cache.get(cacheKey); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.info ? { ...cached.info } : undefined; + } try { - const stat = storage.statSync(file); const [content, suffix] = await storage.readTextSlices( file, SESSION_LIST_PREFIX_BYTES, @@ -365,7 +418,12 @@ async function scanSessionFile( const { size, mtime } = stat; const entries = parseJsonlLenient>(content); const header = parseSessionListHeader(content, entries); - if (!header) return undefined; + if (!header) { + // Cache the negative result too: an unparseable file stays unparseable + // until its stat identity changes. + cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: undefined }); + return undefined; + } let parsedMessageCount = 0; let firstMessage = ""; @@ -398,7 +456,7 @@ async function scanSessionFile( firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? ""; const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content)); - return { + const info: SessionInfo = { path: file, id: header.id, cwd: header.cwd ?? "", @@ -412,6 +470,10 @@ async function scanSessionFile( allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage, status: withStatus ? deriveSessionStatus(suffix) : undefined, }; + // The cache keeps its own shallow copy; hits also hand out copies, so + // callers can never mutate the shared cached object. + cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: { ...info } }); + return info; } catch { return undefined; } diff --git a/packages/coding-agent/test/session-listing-cache.test.ts b/packages/coding-agent/test/session-listing-cache.test.ts new file mode 100644 index 000000000..b3f0d6f94 --- /dev/null +++ b/packages/coding-agent/test/session-listing-cache.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { listSessions } from "@oh-my-pi/pi-coding-agent/session/session-listing"; +import { MemorySessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; + +const SESSION_DIR = "/sessions/project"; + +function writeSession(storage: MemorySessionStorage, file: string, firstPrompt: string): void { + storage.writeTextSync( + file, + [ + JSON.stringify({ + type: "session", + id: "cache-id", + cwd: "/repo", + title: "Cached Title", + timestamp: "2026-06-27T00:00:00.000Z", + }), + JSON.stringify({ type: "message", message: { role: "user", content: firstPrompt } }), + "", + ].join("\n"), + ); +} + +describe("session listing scan cache", () => { + it("serves an unchanged file from cache without re-reading it", async () => { + const storage = new MemorySessionStorage(); + const file = `${SESSION_DIR}/cached.jsonl`; + writeSession(storage, file, "first prompt"); + const readSpy = spyOn(storage, "readTextSlices"); + + const first = await listSessions(SESSION_DIR, storage); + expect(first.map(s => s.id)).toEqual(["cache-id"]); + expect(readSpy).toHaveBeenCalledTimes(1); + + const second = await listSessions(SESSION_DIR, storage); + expect(readSpy).toHaveBeenCalledTimes(1); + expect(second).toEqual(first); + }); + + it("invalidates when the file's size changes", async () => { + const storage = new MemorySessionStorage(); + const file = `${SESSION_DIR}/grows.jsonl`; + writeSession(storage, file, "short"); + const readSpy = spyOn(storage, "readTextSlices"); + + await listSessions(SESSION_DIR, storage); + expect(readSpy).toHaveBeenCalledTimes(1); + + // Simulate the active session growing during streaming. + writeSession(storage, file, "a much longer replacement prompt"); + const after = await listSessions(SESSION_DIR, storage); + expect(readSpy).toHaveBeenCalledTimes(2); + expect(after[0]?.firstMessage).toBe("a much longer replacement prompt"); + }); + + it("invalidates when mtime changes even at identical size", async () => { + const storage = new MemorySessionStorage(); + const file = `${SESSION_DIR}/retitle.jsonl`; + writeSession(storage, file, "same-length"); + const readSpy = spyOn(storage, "readTextSlices"); + + await listSessions(SESSION_DIR, storage); + expect(readSpy).toHaveBeenCalledTimes(1); + + // Simulate updateSessionTitle's in-place slot rewrite: same byte length, + // newer mtime (MemorySessionStorage stamps Date.now on write). Advance + // the clock deterministically instead of sleeping. + const nowSpy = spyOn(Date, "now").mockReturnValue(Date.now() + 10); + try { + writeSession(storage, file, "SAME-LENGTH"); + } finally { + nowSpy.mockRestore(); + } + const after = await listSessions(SESSION_DIR, storage); + expect(readSpy).toHaveBeenCalledTimes(2); + expect(after[0]?.firstMessage).toBe("SAME-LENGTH"); + }); + + it("caches negative results for unparseable files", async () => { + const storage = new MemorySessionStorage(); + const file = `${SESSION_DIR}/garbage.jsonl`; + storage.writeTextSync(file, "not json at all\n"); + const readSpy = spyOn(storage, "readTextSlices"); + + expect(await listSessions(SESSION_DIR, storage)).toEqual([]); + expect(readSpy).toHaveBeenCalledTimes(1); + expect(await listSessions(SESSION_DIR, storage)).toEqual([]); + expect(readSpy).toHaveBeenCalledTimes(1); + }); +});