Skip to content

Commit dc860c7

Browse files
brianlovinclaude
andauthored
Add persistent AI cache for TLDRs and chat with 7-day expiry (#25)
* Add persistent caching for TLDR summaries and chat history - Store TLDR summaries in ~/.cache/hn-cli/tldr-cache.json with 7-day expiry - Store chat sessions in ~/.cache/hn-cli/chat-cache.json with 7-day expiry - Both caches survive app restarts and system reboots - Expired entries are automatically cleaned up on load - Users can regenerate TLDRs by pressing 't' again * Add tests for persistent cache and improve testability - Add setPersistentCachePaths() for test path injection - Add comprehensive tests for TLDR and chat cache functions - Improve merge behavior comment explaining precedence logic - Export AI_CACHE_TTL_MS constant for test verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a7a42c commit dc860c7

3 files changed

Lines changed: 628 additions & 5 deletions

File tree

src/app.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ import {
9595
cacheToSessions,
9696
viewModesToCache,
9797
cacheToViewModes,
98+
loadTLDRCache,
99+
saveTLDRCache,
100+
loadChatCache,
101+
saveChatCache,
98102
type AppCache,
99103
type CachedChatSession,
100104
type StoryViewMode,
@@ -206,6 +210,10 @@ export class HackerNewsApp {
206210
savedAt: Date.now(),
207211
};
208212
saveCache(cache);
213+
214+
// Also save persistent caches (TLDR and chat history with 7-day expiry)
215+
saveTLDRCache(this.tldrCache);
216+
saveChatCache(this.savedChatSessions as Map<number, CachedChatSession>);
209217
}
210218

211219
async initialize(options: InitializeOptions = {}) {
@@ -215,25 +223,40 @@ export class HackerNewsApp {
215223
this.setupLayout();
216224
this.setupKeyboardHandlers();
217225

218-
// If a specific story is requested, skip cache and load fresh
226+
// Load persistent caches (TLDR and chat history with 7-day expiry)
227+
this.tldrCache = loadTLDRCache();
228+
const persistentChatSessions = loadChatCache();
229+
230+
// If a specific story is requested, skip session cache and load fresh
219231
if (this.requestedStoryId) {
232+
this.savedChatSessions = persistentChatSessions;
220233
await this.loadPosts();
221234
return;
222235
}
223236

224237
// Try to restore from cache first
225238
const cached = loadCache();
226239
if (cached && cached.posts.length > 0) {
227-
await this.restoreFromCache(cached);
240+
await this.restoreFromCache(cached, persistentChatSessions);
228241
} else {
242+
// Even if no session cache, restore persistent chat sessions
243+
this.savedChatSessions = persistentChatSessions;
229244
await this.loadPosts();
230245
}
231246
}
232247

233-
private async restoreFromCache(cached: AppCache) {
248+
private async restoreFromCache(cached: AppCache, persistentChatSessions?: Map<number, CachedChatSession>) {
234249
this.posts = cached.posts;
235250
this.storiesFetchedAt = cached.storiesFetchedAt;
236-
this.savedChatSessions = cacheToSessions(cached.chatSessions) as Map<number, SavedChatSession>;
251+
252+
// Merge session chat cache with persistent chat cache.
253+
// Persistent takes precedence because: on reboot, session cache (in tmpdir) is cleared
254+
// while persistent cache (in ~/.cache) survives. On clean shutdown, both are identical.
255+
const sessionChats = cacheToSessions(cached.chatSessions) as Map<number, SavedChatSession>;
256+
this.savedChatSessions = persistentChatSessions
257+
? new Map([...sessionChats, ...persistentChatSessions]) as Map<number, SavedChatSession>
258+
: sessionChats;
259+
237260
this.storyViewModes = cached.storyViewModes
238261
? cacheToViewModes(cached.storyViewModes)
239262
: new Map();
@@ -882,6 +905,8 @@ export class HackerNewsApp {
882905
this.tldrLoadingStoryId = null;
883906
this.stopTldrLoadingAnimation();
884907
this.stopAiIndicator(storyIndex);
908+
// Persist TLDR cache immediately
909+
saveTLDRCache(this.tldrCache);
885910
// Only rerender if we're viewing the story that just completed
886911
if (this.selectedPost?.id === storyId) {
887912
this.rerenderCurrentStory();
@@ -1234,6 +1259,15 @@ export class HackerNewsApp {
12341259
stopTypingIndicator(this.chatPanelState);
12351260
}
12361261
this.stopAiIndicator(storyIndex);
1262+
// Save current chat session before persisting cache
1263+
if (this.selectedPost && this.chatPanelState) {
1264+
this.savedChatSessions.set(this.selectedPost.id, {
1265+
messages: [...this.chatPanelState.messages],
1266+
suggestions: [...this.chatPanelState.suggestions.suggestions],
1267+
originalSuggestions: [...this.chatPanelState.suggestions.originalSuggestions],
1268+
followUpCount: this.followUpCount,
1269+
});
1270+
}
12371271
this.saveToCache();
12381272
this.generateFollowUpQuestionsIfNeeded();
12391273
},

src/cache.ts

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,67 @@
11
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
22
import { join } from "path";
3-
import { tmpdir } from "os";
3+
import { tmpdir, homedir } from "os";
44
import type { HackerNewsPost } from "./types";
5+
import type { TLDRResult } from "./services/TLDRService";
56

67
// Cache lives in temp directory so it persists across hot reloads
78
const CACHE_DIR = join(tmpdir(), "hn-cli-cache");
89
const CACHE_FILE = join(CACHE_DIR, "state.json");
910

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+
1031
// Stories cache expires after 5 minutes
1132
const STORIES_TTL_MS = 5 * 60 * 1000;
1233

34+
// TLDR and chat cache expires after 7 days
35+
export const AI_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
36+
1337
export interface CachedChatSession {
1438
messages: Array<{ role: "user" | "assistant"; content: string }>;
1539
suggestions: string[];
1640
originalSuggestions: string[];
1741
followUpCount: number;
1842
}
1943

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+
2065
export type StoryViewMode = "comments" | "chat";
2166

2267
export interface AppCache {
@@ -140,3 +185,144 @@ export function cacheToViewModes(
140185
}
141186
return map;
142187
}
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

Comments
 (0)