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.
This commit is contained in:
can1357
2026-07-27 20:33:38 +02:00
parent 1d74bc73e3
commit 6b42097d12
3 changed files with 184 additions and 20 deletions
@@ -71,6 +71,14 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
return matchKeys;
}
function unionOfMatchKeys(matchKeys: ReadonlyMap<ConfigurableEditorAction, ReadonlySet<string>>): Set<string> {
const union = new Set<string>();
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");
}
}
}
@@ -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<string, SessionScanCacheEntry>;
/** 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<SessionInfo | undefined> {
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<Record<string, unknown>>(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;
}
@@ -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);
});
});