|
1 | 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"; |
2 | 2 | import { join } from "path"; |
3 | | -import { tmpdir } from "os"; |
| 3 | +import { tmpdir, homedir } from "os"; |
4 | 4 | import type { HackerNewsPost } from "./types"; |
| 5 | +import type { TLDRResult } from "./services/TLDRService"; |
5 | 6 |
|
6 | 7 | // Cache lives in temp directory so it persists across hot reloads |
7 | 8 | const CACHE_DIR = join(tmpdir(), "hn-cli-cache"); |
8 | 9 | const CACHE_FILE = join(CACHE_DIR, "state.json"); |
9 | 10 |
|
| 11 | +// Persistent cache for AI-generated content (survives reboots) |
| 12 | +// These can be overridden for testing via setPersistentCachePaths() |
| 13 | +let PERSISTENT_CACHE_DIR = join(homedir(), ".cache", "hn-cli"); |
| 14 | +let TLDR_CACHE_FILE = join(PERSISTENT_CACHE_DIR, "tldr-cache.json"); |
| 15 | +let CHAT_CACHE_FILE = join(PERSISTENT_CACHE_DIR, "chat-cache.json"); |
| 16 | + |
| 17 | +// For testing: allows overriding cache paths |
| 18 | +export function setPersistentCachePaths(cacheDir: string): void { |
| 19 | + PERSISTENT_CACHE_DIR = cacheDir; |
| 20 | + TLDR_CACHE_FILE = join(cacheDir, "tldr-cache.json"); |
| 21 | + CHAT_CACHE_FILE = join(cacheDir, "chat-cache.json"); |
| 22 | +} |
| 23 | + |
| 24 | +// For testing: resets cache paths to defaults |
| 25 | +export function resetPersistentCachePaths(): void { |
| 26 | + PERSISTENT_CACHE_DIR = join(homedir(), ".cache", "hn-cli"); |
| 27 | + TLDR_CACHE_FILE = join(PERSISTENT_CACHE_DIR, "tldr-cache.json"); |
| 28 | + CHAT_CACHE_FILE = join(PERSISTENT_CACHE_DIR, "chat-cache.json"); |
| 29 | +} |
| 30 | + |
10 | 31 | // Stories cache expires after 5 minutes |
11 | 32 | const STORIES_TTL_MS = 5 * 60 * 1000; |
12 | 33 |
|
| 34 | +// TLDR and chat cache expires after 7 days |
| 35 | +export const AI_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; |
| 36 | + |
13 | 37 | export interface CachedChatSession { |
14 | 38 | messages: Array<{ role: "user" | "assistant"; content: string }>; |
15 | 39 | suggestions: string[]; |
16 | 40 | originalSuggestions: string[]; |
17 | 41 | followUpCount: number; |
18 | 42 | } |
19 | 43 |
|
| 44 | +// Persistent TLDR cache entry with timestamp |
| 45 | +export interface CachedTLDR { |
| 46 | + result: TLDRResult; |
| 47 | + cachedAt: number; |
| 48 | +} |
| 49 | + |
| 50 | +// Persistent chat session with timestamp |
| 51 | +export interface PersistentChatSession extends CachedChatSession { |
| 52 | + cachedAt: number; |
| 53 | +} |
| 54 | + |
| 55 | +// Persistent TLDR cache (keyed by story ID as string for JSON) |
| 56 | +export interface TLDRCache { |
| 57 | + entries: Record<string, CachedTLDR>; |
| 58 | +} |
| 59 | + |
| 60 | +// Persistent chat cache (keyed by story ID as string for JSON) |
| 61 | +export interface ChatCache { |
| 62 | + sessions: Record<string, PersistentChatSession>; |
| 63 | +} |
| 64 | + |
20 | 65 | export type StoryViewMode = "comments" | "chat"; |
21 | 66 |
|
22 | 67 | export interface AppCache { |
@@ -140,3 +185,144 @@ export function cacheToViewModes( |
140 | 185 | } |
141 | 186 | return map; |
142 | 187 | } |
| 188 | + |
| 189 | +// ============================================ |
| 190 | +// Persistent TLDR Cache (7-day expiry) |
| 191 | +// ============================================ |
| 192 | + |
| 193 | +function ensurePersistentCacheDir(): void { |
| 194 | + if (!existsSync(PERSISTENT_CACHE_DIR)) { |
| 195 | + mkdirSync(PERSISTENT_CACHE_DIR, { recursive: true }); |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +export function loadTLDRCache(): Map<number, TLDRResult> { |
| 200 | + const map = new Map<number, TLDRResult>(); |
| 201 | + try { |
| 202 | + if (!existsSync(TLDR_CACHE_FILE)) { |
| 203 | + return map; |
| 204 | + } |
| 205 | + |
| 206 | + const content = readFileSync(TLDR_CACHE_FILE, "utf-8"); |
| 207 | + const cache: TLDRCache = JSON.parse(content); |
| 208 | + const now = Date.now(); |
| 209 | + |
| 210 | + // Load only non-expired entries |
| 211 | + for (const [id, entry] of Object.entries(cache.entries)) { |
| 212 | + const age = now - entry.cachedAt; |
| 213 | + if (age <= AI_CACHE_TTL_MS) { |
| 214 | + map.set(Number(id), entry.result); |
| 215 | + } |
| 216 | + } |
| 217 | + } catch { |
| 218 | + // Silently fail - caching is optional |
| 219 | + } |
| 220 | + return map; |
| 221 | +} |
| 222 | + |
| 223 | +export function saveTLDRCache(tldrCache: Map<number, TLDRResult>): void { |
| 224 | + try { |
| 225 | + ensurePersistentCacheDir(); |
| 226 | + |
| 227 | + // Load existing cache to preserve timestamps for existing entries |
| 228 | + let existingEntries: Record<string, CachedTLDR> = {}; |
| 229 | + if (existsSync(TLDR_CACHE_FILE)) { |
| 230 | + try { |
| 231 | + const content = readFileSync(TLDR_CACHE_FILE, "utf-8"); |
| 232 | + const existing: TLDRCache = JSON.parse(content); |
| 233 | + existingEntries = existing.entries; |
| 234 | + } catch { |
| 235 | + // Ignore errors reading existing cache |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + const now = Date.now(); |
| 240 | + const entries: Record<string, CachedTLDR> = {}; |
| 241 | + |
| 242 | + for (const [id, result] of tldrCache) { |
| 243 | + const idStr = String(id); |
| 244 | + // Preserve existing timestamp if entry already exists, otherwise use now |
| 245 | + const cachedAt = existingEntries[idStr]?.cachedAt ?? now; |
| 246 | + // Only save if not expired |
| 247 | + if (now - cachedAt <= AI_CACHE_TTL_MS) { |
| 248 | + entries[idStr] = { result, cachedAt }; |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + const cache: TLDRCache = { entries }; |
| 253 | + writeFileSync(TLDR_CACHE_FILE, JSON.stringify(cache, null, 2)); |
| 254 | + } catch { |
| 255 | + // Silently fail - caching is optional |
| 256 | + } |
| 257 | +} |
| 258 | + |
| 259 | +// ============================================ |
| 260 | +// Persistent Chat Cache (7-day expiry) |
| 261 | +// ============================================ |
| 262 | + |
| 263 | +export function loadChatCache(): Map<number, CachedChatSession> { |
| 264 | + const map = new Map<number, CachedChatSession>(); |
| 265 | + try { |
| 266 | + if (!existsSync(CHAT_CACHE_FILE)) { |
| 267 | + return map; |
| 268 | + } |
| 269 | + |
| 270 | + const content = readFileSync(CHAT_CACHE_FILE, "utf-8"); |
| 271 | + const cache: ChatCache = JSON.parse(content); |
| 272 | + const now = Date.now(); |
| 273 | + |
| 274 | + // Load only non-expired entries |
| 275 | + for (const [id, session] of Object.entries(cache.sessions)) { |
| 276 | + const age = now - session.cachedAt; |
| 277 | + if (age <= AI_CACHE_TTL_MS) { |
| 278 | + // Strip cachedAt when returning to match CachedChatSession interface |
| 279 | + const sessionData: CachedChatSession = { |
| 280 | + messages: session.messages, |
| 281 | + suggestions: session.suggestions, |
| 282 | + originalSuggestions: session.originalSuggestions, |
| 283 | + followUpCount: session.followUpCount, |
| 284 | + }; |
| 285 | + map.set(Number(id), sessionData); |
| 286 | + } |
| 287 | + } |
| 288 | + } catch { |
| 289 | + // Silently fail - caching is optional |
| 290 | + } |
| 291 | + return map; |
| 292 | +} |
| 293 | + |
| 294 | +export function saveChatCache(sessions: Map<number, CachedChatSession>): void { |
| 295 | + try { |
| 296 | + ensurePersistentCacheDir(); |
| 297 | + |
| 298 | + // Load existing cache to preserve timestamps for existing entries |
| 299 | + let existingEntries: Record<string, PersistentChatSession> = {}; |
| 300 | + if (existsSync(CHAT_CACHE_FILE)) { |
| 301 | + try { |
| 302 | + const content = readFileSync(CHAT_CACHE_FILE, "utf-8"); |
| 303 | + const existing: ChatCache = JSON.parse(content); |
| 304 | + existingEntries = existing.sessions; |
| 305 | + } catch { |
| 306 | + // Ignore errors reading existing cache |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | + const now = Date.now(); |
| 311 | + const persistentSessions: Record<string, PersistentChatSession> = {}; |
| 312 | + |
| 313 | + for (const [id, session] of sessions) { |
| 314 | + const idStr = String(id); |
| 315 | + // Preserve existing timestamp if entry already exists, otherwise use now |
| 316 | + const cachedAt = existingEntries[idStr]?.cachedAt ?? now; |
| 317 | + // Only save if not expired |
| 318 | + if (now - cachedAt <= AI_CACHE_TTL_MS) { |
| 319 | + persistentSessions[idStr] = { ...session, cachedAt }; |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + const cache: ChatCache = { sessions: persistentSessions }; |
| 324 | + writeFileSync(CHAT_CACHE_FILE, JSON.stringify(cache, null, 2)); |
| 325 | + } catch { |
| 326 | + // Silently fail - caching is optional |
| 327 | + } |
| 328 | +} |
0 commit comments