@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
238 lines (237 loc) • 8.97 kB
JavaScript
/**
* Language-model response cache, as a Vercel AI SDK middleware.
*
* Changelog generation is highly repetitive across runs: previewing with
* `--dry-run` and then generating for real, re-running after a failed provider
* call, or regenerating the same release, all re-send byte-identical prompts for
* commits whose diffs have not changed. Caching on the model boundary removes
* that repeat cost without any call site having to know about it.
*
* Key: SHA-256 over the model identity plus the RESPONSE-AFFECTING call
* parameters only (see `responseAffectingParams`). Transport/runtime fields
* (`abortSignal`, `headers`) are deliberately excluded — they vary per call and
* would defeat the cache entirely without changing what the model returns.
*
* Storage is a small in-memory LRU in front of a JSON-file cache under the
* user's cache directory, so hits survive across CLI invocations (the case that
* actually matters here — a single run rarely repeats a prompt).
*
* Every failure path falls OPEN: a cache problem returns the freshly generated
* result rather than turning a working generation into an error. Streaming
* (`wrapStream`) is intentionally not cached — this package never streams model
* output into a changelog.
*/
import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
const MEMORY_LRU_MAX_ENTRIES = 128;
const MAX_PAYLOAD_BYTES = 256 * 1024;
const MAX_DISK_ENTRIES = 500;
const memoryStore = new Map();
export function defaultCacheDirectory() {
return path.join(os.homedir(), '.cache', 'ai-changelog-generator', 'model-responses');
}
/**
* Opt-out via AI_CACHE_ENABLED=false. Caching is on by default because a stale
* entry can only occur for a byte-identical prompt to the same model, which by
* construction would have produced an equivalent answer.
*/
export function isModelCacheEnabled() {
return process.env.AI_CACHE_ENABLED?.trim().toLowerCase() !== 'false';
}
function pruneMemoryStore() {
const now = Date.now();
for (const [key, entry] of memoryStore) {
if (entry.expiresAt <= now) {
memoryStore.delete(key);
}
}
// Map preserves insertion order, so the first key is the oldest.
while (memoryStore.size >= MEMORY_LRU_MAX_ENTRIES) {
const oldest = memoryStore.keys().next().value;
if (oldest === undefined) {
break;
}
memoryStore.delete(oldest);
}
}
/**
* Deterministic serialization: plain JSON.stringify is key-order dependent, so
* two structurally identical parameter objects could hash differently and miss.
*/
export function stableStringify(value) {
if (value === null || value === undefined) {
return JSON.stringify(value ?? null);
}
if (typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
}
const entries = Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return `{${entries
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`)
.join(',')}}`;
}
/**
* The subset of call parameters that can change what the model returns.
* Anything omitted here is transport or bookkeeping.
*/
export function responseAffectingParams(params) {
return {
prompt: params.prompt,
maxOutputTokens: params.maxOutputTokens,
temperature: params.temperature,
stopSequences: params.stopSequences,
topP: params.topP,
topK: params.topK,
presencePenalty: params.presencePenalty,
frequencyPenalty: params.frequencyPenalty,
responseFormat: params.responseFormat,
seed: params.seed,
tools: params.tools,
toolChoice: params.toolChoice,
providerOptions: params.providerOptions,
};
}
export function fingerprint(model, params) {
const payload = stableStringify({
modelId: model?.modelId,
provider: model?.provider,
specificationVersion: model?.specificationVersion,
params: responseAffectingParams(params),
});
return createHash('sha256').update(payload).digest('hex');
}
/**
* A non-deterministic call (temperature > 0 with no fixed seed) is still cached:
* the point is reproducible changelog output for an unchanged repository. Calls
* that cannot be represented safely are skipped instead.
*/
function canCache(params) {
try {
const encoded = stableStringify(responseAffectingParams(params));
return encoded.length <= MAX_PAYLOAD_BYTES;
}
catch {
return false;
}
}
async function readDisk(cacheDir, key, ttlSeconds) {
try {
const file = path.join(cacheDir, `${key}.json`);
const info = await stat(file);
if (Date.now() - info.mtimeMs > ttlSeconds * 1000) {
await unlink(file).catch(() => { });
return null;
}
return await readFile(file, 'utf8');
}
catch {
return null;
}
}
async function writeDisk(cacheDir, key, value) {
try {
await mkdir(cacheDir, { recursive: true });
// Temp file + rename so a concurrent reader never sees a partial entry.
const file = path.join(cacheDir, `${key}.json`);
const temp = `${file}.${process.pid}.tmp`;
await writeFile(temp, value, 'utf8');
await rename(temp, file);
await enforceDiskBudget(cacheDir);
}
catch {
// Fall open: an unwritable cache must not fail generation.
}
}
/** Keep the on-disk cache bounded; oldest entries are evicted first. */
async function enforceDiskBudget(cacheDir) {
try {
const files = (await readdir(cacheDir)).filter((f) => f.endsWith('.json'));
if (files.length <= MAX_DISK_ENTRIES) {
return;
}
const withTimes = await Promise.all(files.map(async (f) => {
try {
const info = await stat(path.join(cacheDir, f));
return { f, time: info.mtimeMs };
}
catch {
return { f, time: 0 };
}
}));
withTimes.sort((a, b) => a.time - b.time);
const excess = withTimes.slice(0, withTimes.length - MAX_DISK_ENTRIES);
await Promise.all(excess.map((e) => unlink(path.join(cacheDir, e.f)).catch(() => { })));
}
catch {
// Best effort.
}
}
export function createModelCacheMiddleware(options = {}) {
const ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
const cacheDir = options.cacheDir ?? defaultCacheDirectory();
return {
wrapGenerate: async ({ doGenerate, params, model }) => {
if (!isModelCacheEnabled() || !canCache(params)) {
return doGenerate();
}
let key;
try {
key = fingerprint(model, params);
}
catch {
return doGenerate();
}
const now = Date.now();
const memoryHit = memoryStore.get(key);
if (memoryHit && memoryHit.expiresAt > now) {
options.onHit?.(key);
try {
return JSON.parse(memoryHit.value);
}
catch {
memoryStore.delete(key);
}
}
const diskHit = await readDisk(cacheDir, key, ttlSeconds);
if (diskHit) {
try {
const parsed = JSON.parse(diskHit);
pruneMemoryStore();
memoryStore.set(key, { value: diskHit, expiresAt: now + ttlSeconds * 1000 });
options.onHit?.(key);
return parsed;
}
catch {
// Corrupt entry: fall through and regenerate.
}
}
options.onMiss?.(key);
const result = await doGenerate();
try {
const serialized = JSON.stringify(result);
if (serialized.length <= MAX_PAYLOAD_BYTES) {
pruneMemoryStore();
memoryStore.set(key, { value: serialized, expiresAt: now + ttlSeconds * 1000 });
await writeDisk(cacheDir, key, serialized);
}
}
catch {
// A result that will not serialize is simply not cached.
}
return result;
},
};
}
/** Exposed for tests: drops the in-process layer without touching disk. */
export function clearModelCacheMemory() {
memoryStore.clear();
}