From 3bbd29bef942ac6b8c81432b9c5e2d968b6e1627 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 12:52:46 +0000 Subject: [PATCH] perf(gateway): cache session list transcript fields --- CHANGELOG.md | 1 + src/gateway/session-utils.fs.test.ts | 63 +++++++++++++++++++++ src/gateway/session-utils.fs.ts | 83 ++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6736dce..474434197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr. - Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. - Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution. +- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes. - CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale. - CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle. - CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead). diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 0e9346f30..0e324f78d 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -7,6 +7,7 @@ import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readSessionMessages, + readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; @@ -367,6 +368,68 @@ describe("readLastMessagePreviewFromTranscript", () => { }); }); +describe("readSessionTitleFieldsFromTranscript cache", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns cached values without re-reading when unchanged", () => { + const sessionId = "test-cache-1"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello world" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const readSpy = vi.spyOn(fs, "readSync"); + + const first = readSessionTitleFieldsFromTranscript(sessionId, storePath); + const readsAfterFirst = readSpy.mock.calls.length; + expect(readsAfterFirst).toBeGreaterThan(0); + + const second = readSessionTitleFieldsFromTranscript(sessionId, storePath); + expect(second).toEqual(first); + expect(readSpy.mock.calls.length).toBe(readsAfterFirst); + }); + + test("invalidates cache when transcript changes", () => { + const sessionId = "test-cache-2"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "First" } }), + JSON.stringify({ message: { role: "assistant", content: "Old" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const readSpy = vi.spyOn(fs, "readSync"); + + const first = readSessionTitleFieldsFromTranscript(sessionId, storePath); + const readsAfterFirst = readSpy.mock.calls.length; + expect(first.lastMessagePreview).toBe("Old"); + + fs.appendFileSync( + transcriptPath, + `\n${JSON.stringify({ message: { role: "assistant", content: "New" } })}`, + "utf-8", + ); + + const second = readSessionTitleFieldsFromTranscript(sessionId, storePath); + expect(second.lastMessagePreview).toBe("New"); + expect(readSpy.mock.calls.length).toBeGreaterThan(readsAfterFirst); + }); +}); + describe("readSessionMessages", () => { let tmpDir: string; let storePath: string; diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 99a4043cb..032e3686a 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -12,6 +12,60 @@ import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; +type SessionTitleFields = { + firstUserMessage: string | null; + lastMessagePreview: string | null; +}; + +type SessionTitleFieldsCacheEntry = SessionTitleFields & { + mtimeMs: number; + size: number; +}; + +const sessionTitleFieldsCache = new Map(); +const MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES = 5000; + +function readSessionTitleFieldsCacheKey( + filePath: string, + opts?: { includeInterSession?: boolean }, +) { + const includeInterSession = opts?.includeInterSession === true ? "1" : "0"; + return `${filePath}\t${includeInterSession}`; +} + +function getCachedSessionTitleFields(cacheKey: string, stat: fs.Stats): SessionTitleFields | null { + const cached = sessionTitleFieldsCache.get(cacheKey); + if (!cached) { + return null; + } + if (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) { + sessionTitleFieldsCache.delete(cacheKey); + return null; + } + // LRU bump + sessionTitleFieldsCache.delete(cacheKey); + sessionTitleFieldsCache.set(cacheKey, cached); + return { + firstUserMessage: cached.firstUserMessage, + lastMessagePreview: cached.lastMessagePreview, + }; +} + +function setCachedSessionTitleFields(cacheKey: string, stat: fs.Stats, value: SessionTitleFields) { + sessionTitleFieldsCache.set(cacheKey, { + ...value, + mtimeMs: stat.mtimeMs, + size: stat.size, + }); + while (sessionTitleFieldsCache.size > MAX_SESSION_TITLE_FIELDS_CACHE_ENTRIES) { + const oldestKey = sessionTitleFieldsCache.keys().next().value; + if (typeof oldestKey !== "string" || !oldestKey) { + break; + } + sessionTitleFieldsCache.delete(oldestKey); + } +} + export function readSessionMessages( sessionId: string, storePath: string | undefined, @@ -181,21 +235,36 @@ export function readSessionTitleFieldsFromTranscript( sessionFile?: string, agentId?: string, opts?: { includeInterSession?: boolean }, -): { firstUserMessage: string | null; lastMessagePreview: string | null } { +): SessionTitleFields { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) { return { firstUserMessage: null, lastMessagePreview: null }; } + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return { firstUserMessage: null, lastMessagePreview: null }; + } + + const cacheKey = readSessionTitleFieldsCacheKey(filePath, opts); + const cached = getCachedSessionTitleFields(cacheKey, stat); + if (cached) { + return cached; + } + + if (stat.size === 0) { + const empty = { firstUserMessage: null, lastMessagePreview: null }; + setCachedSessionTitleFields(cacheKey, stat, empty); + return empty; + } + let fd: number | null = null; try { fd = fs.openSync(filePath, "r"); - const stat = fs.fstatSync(fd); const size = stat.size; - if (size === 0) { - return { firstUserMessage: null, lastMessagePreview: null }; - } // Head (first user message) let firstUserMessage: string | null = null; @@ -265,7 +334,9 @@ export function readSessionTitleFieldsFromTranscript( // ignore tail read errors } - return { firstUserMessage, lastMessagePreview }; + const result = { firstUserMessage, lastMessagePreview }; + setCachedSessionTitleFields(cacheKey, stat, result); + return result; } catch { return { firstUserMessage: null, lastMessagePreview: null }; } finally {