/** * lib/cache.ts * Hash-basierter File-Cache für Perplexity-Ergebnisse. * * Vermeidet doppelte Perplexity-Kosten wenn derselbe Claim in mehreren Artikeln * oder in Wiederholungsläufen geprüft wird. * * Ablageort: ~/.pi/agent/cache/perplexity/.json * TTL: 7 Tage (ältere Einträge werden beim Lesen ignoriert) * Schlüssel: SHA256 des normalisierten Claim-Textes * * Verwendung in verify-article.ts: * import { getCached, setCached } from "../lib/cache.js"; * const cached = getCached(claimText); * if (cached) return cached; * const result = await searchPerplexity(claimText, opts); * setCached(claimText, result); */ import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; // --------------------------------------------------------------------------- // Konstanten // --------------------------------------------------------------------------- export const CACHE_DIR = join(homedir(), ".pi", "agent", "cache", "perplexity"); const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage // --------------------------------------------------------------------------- // Interner Typ (Cache-Datei) // --------------------------------------------------------------------------- type CacheEntry = { cachedAt: string; // ISO-Timestamp data: T; }; // --------------------------------------------------------------------------- // Hilfsfunktionen // --------------------------------------------------------------------------- function ensureCacheDir(): void { mkdirSync(CACHE_DIR, { recursive: true }); } /** * Normalisiert einen Claim-Text für konsistentes Hashing: * - Whitespace kollabieren * - Kleinschreibung * - Führende/nachfolgende Leerzeichen entfernen */ function normalizeText(text: string): string { return text.toLowerCase().replace(/\s+/g, " ").trim(); } /** * SHA256-Hash des normalisierten Claim-Textes als Hex-String (64 Zeichen). */ export function claimHash(claimText: string): string { return createHash("sha256").update(normalizeText(claimText)).digest("hex"); } function cachePath(hash: string): string { return join(CACHE_DIR, `${hash}.json`); } // --------------------------------------------------------------------------- // Öffentliche API // --------------------------------------------------------------------------- /** * Liest einen gecachten Perplexity-Wert für den gegebenen Claim-Text. * Gibt null zurück wenn: * - kein Cache-Eintrag vorhanden * - der Eintrag älter als TTL_MS ist * - der Eintrag korrupt ist */ export function getCached(claimText: string): T | null { try { const path = cachePath(claimHash(claimText)); const stat = statSync(path); const ageMs = Date.now() - stat.mtimeMs; if (ageMs > TTL_MS) return null; // abgelaufen const entry = JSON.parse(readFileSync(path, "utf8")) as CacheEntry; return entry.data; } catch { return null; } } /** * Speichert ein Perplexity-Ergebnis im Cache. * Fehler beim Schreiben werden ignoriert (Cache ist optional). */ export function setCached(claimText: string, data: T): void { try { ensureCacheDir(); const entry: CacheEntry = { cachedAt: new Date().toISOString(), data, }; writeFileSync(cachePath(claimHash(claimText)), JSON.stringify(entry, null, 2), "utf8"); } catch { // Cache-Fehler dürfen den Programmablauf nicht unterbrechen } } /** * Löscht abgelaufene Cache-Einträge (älter als TTL_MS). * Gibt die Anzahl gelöschter Einträge zurück. */ export function pruneCache(): number { try { ensureCacheDir(); const files = readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json")); let deleted = 0; for (const file of files) { try { const path = join(CACHE_DIR, file); const ageMs = Date.now() - statSync(path).mtimeMs; if (ageMs > TTL_MS) { unlinkSync(path); deleted++; } } catch { // Einzelne Fehler ignorieren } } return deleted; } catch { return 0; } } /** * Gibt Statistiken über den Cache zurück. */ export function cacheStats(): { total: number; expired: number; sizeBytes: number } { try { ensureCacheDir(); const files = readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json")); let expired = 0; let sizeBytes = 0; for (const file of files) { try { const path = join(CACHE_DIR, file); const stat = statSync(path); sizeBytes += stat.size; if (Date.now() - stat.mtimeMs > TTL_MS) expired++; } catch { // ignorieren } } return { total: files.length, expired, sizeBytes }; } catch { return { total: 0, expired: 0, sizeBytes: 0 }; } }