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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user