diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 3cbe35f..80d954f 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -221,6 +221,7 @@ export const runtimeStatusSchema = z enabled: z.boolean(), maxSessions: z.number(), activeSessions: z.number(), + persistent: z.boolean(), }) .strict(), }) diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index c1bef21..ef2b210 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -11,6 +11,8 @@ export const ARTIFACT_FILENAMES = { anchorMap: 'anchor-map.json', } as const; +export const SESSION_CACHE_FILENAME = 'session-cache.json'; + export const PREFACE_MARKER = '# **Preface** (non-normative)'; export const PREFACE_ROUTE_CITATION = 'Preface/Where to start'; export const ROUTE_INDEX_CITATION = 'J.4'; diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..ee191fe 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -6,6 +6,7 @@ import { ARTIFACT_FILENAMES, DEFAULT_ARTIFACT_DIR, DEFAULT_SOURCE_PATH, + SESSION_CACHE_FILENAME, } from './constants.js'; import { compileFpfSource } from './compiler.js'; import { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; @@ -34,6 +35,7 @@ export interface FpfRuntimeOptions { artifactDir?: string; synthesizer?: LocalAnswerSynthesizer; maxSessions?: number; + persistSessionCache?: boolean; } export class FpfRuntime { @@ -59,12 +61,20 @@ export class FpfRuntime { ]), ) as Record; this.synthesizer = options.synthesizer ?? createSynthesizerFromEnv(); - this.sessionCache = new SessionCache(options.maxSessions ?? 50); + const persistSession = + options.persistSessionCache ?? process.env.FPF_PERSIST_SESSION_CACHE === 'true'; + this.sessionCache = new SessionCache({ + maxSessions: options.maxSessions ?? 50, + persistPath: persistSession + ? resolve(this.artifactDir, SESSION_CACHE_FILENAME) + : undefined, + }); } async refresh(force = false): Promise { await mkdir(this.artifactDir, { recursive: true }); const currentSourceHash = await hashFile(this.sourcePath); + await this.sessionCache.load(currentSourceHash); const existingSnapshot = await this.loadSnapshot(); const compatibleSnapshot = existingSnapshot && !snapshotNeedsRebuild(existingSnapshot); diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index dec87cc..732fad9 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -1,3 +1,6 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + export interface RetrievalSessionState { lastNormalizedQuestion: string; lastSelectedNodeIds: string[]; @@ -6,10 +9,87 @@ export interface RetrievalSessionState { updatedAt: string; } +interface PersistedSessionCache { + sourceHash: string; + entries: Array<[string, RetrievalSessionState]>; +} + +export interface SessionCacheOptions { + maxSessions?: number; + persistPath?: string; + /** Debounce delay in ms before flushing to disk (default 500). */ + flushDelayMs?: number; +} + export class SessionCache { private readonly entries = new Map(); + private readonly maxSessions: number; + private readonly persistPath?: string; + private readonly flushDelayMs: number; + private sourceHash?: string; + private flushPromise?: Promise; + private loadPromise?: Promise; + private flushTimer?: ReturnType; + private hasLoggedWriteError = false; + + constructor(options: SessionCacheOptions = {}) { + this.maxSessions = options.maxSessions ?? 50; + this.persistPath = options.persistPath; + this.flushDelayMs = options.flushDelayMs ?? 500; + } + + async load(sourceHash: string): Promise { + if (this.sourceHash === sourceHash) { + await this.loadPromise; + return; + } + + // Await any in-flight load before reassigning to avoid race conditions + if (this.loadPromise) { + await this.loadPromise; + } + + if (this.sourceHash !== undefined) { + // Cancel any pending flush — entries are about to be cleared so writing them + // under the new sourceHash would persist stale session context. + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + this.entries.clear(); + } + this.sourceHash = sourceHash; + + if (!this.persistPath) { + return; + } + this.loadPromise = this.readFromDisk(this.persistPath, sourceHash); + await this.loadPromise; + } - constructor(private readonly maxSessions = 50) {} + /** Read persisted entries from disk. Path is passed explicitly to avoid non-null assertions. */ + private async readFromDisk(path: string, sourceHash: string): Promise { + try { + const raw = await readFile(path, 'utf8'); + const data: PersistedSessionCache = JSON.parse(raw); + if (data.sourceHash !== sourceHash || this.sourceHash !== sourceHash) { + return; + } + const tuples = data.entries; + if (!Array.isArray(tuples)) { + return; + } + const start = Math.max(0, tuples.length - this.maxSessions); + for (let i = start; i < tuples.length; i++) { + const [key, value] = tuples[i]; + if (!this.entries.has(key)) { + this.entries.set(key, value); + } + } + } catch { + // File missing or corrupt — start fresh + } + } get(sessionId: string): RetrievalSessionState | undefined { const value = this.entries.get(sessionId); @@ -33,13 +113,65 @@ export class SessionCache { } this.entries.delete(oldest); } + this.scheduleFlush(); } - summary(): { enabled: boolean; maxSessions: number; activeSessions: number } { + summary(): { enabled: boolean; maxSessions: number; activeSessions: number; persistent: boolean } { return { enabled: true, maxSessions: this.maxSessions, activeSessions: this.entries.size, + persistent: this.persistPath != null, + }; + } + + /** Debounced flush — batches rapid set() calls into a single disk write. */ + private scheduleFlush(): void { + if (!this.persistPath || !this.sourceHash) { + return; + } + // Clear any pending debounce timer so we only write once after the last set() + if (this.flushTimer) { + clearTimeout(this.flushTimer); + } + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.doFlush(); + }, this.flushDelayMs); + } + + private doFlush(): void { + if (!this.persistPath || !this.sourceHash) { + return; + } + const path = this.persistPath; + // Capture the hash at flush time so we can verify it hasn't changed + // between scheduling and the async write completing. + const hashAtFlush = this.sourceHash; + const data: PersistedSessionCache = { + sourceHash: hashAtFlush, + entries: Array.from(this.entries.entries()), }; + const json = JSON.stringify(data); + this.flushPromise = (this.flushPromise ?? Promise.resolve()) + .then(async () => { + // If the sourceHash changed while we were waiting, skip this write — + // the entries were captured under a potentially stale hash. + if (this.sourceHash !== hashAtFlush) { + return; + } + await mkdir(dirname(path), { recursive: true }); + // Atomic write: write to temp file then rename to avoid corruption on crash + const tmpPath = join(dirname(path), `.session-cache.tmp.${Date.now()}`); + await writeFile(tmpPath, json, 'utf8'); + await rename(tmpPath, path); + }) + .catch((err: unknown) => { + // Log the first write failure so silent disk issues are visible + if (!this.hasLoggedWriteError) { + this.hasLoggedWriteError = true; + console.error('[SessionCache] disk write failed:', err); + } + }); } } diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 8b01fcf..2e84a36 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -421,6 +421,7 @@ export interface RuntimeStatus { enabled: boolean; maxSessions: number; activeSessions: number; + persistent: boolean; }; }