mono/packages/kbot/ref/utils/imageStore.ts
2026-04-01 01:05:48 +02:00

168 lines
4.2 KiB
TypeScript

import { mkdir, open } from 'fs/promises'
import { join } from 'path'
import { getSessionId } from '../bootstrap/state.js'
import type { PastedContent } from './config.js'
import { logForDebugging } from './debug.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { getFsImplementation } from './fsOperations.js'
const IMAGE_STORE_DIR = 'image-cache'
const MAX_STORED_IMAGE_PATHS = 200
// In-memory cache of stored image paths
const storedImagePaths = new Map<number, string>()
/**
* Get the image store directory for the current session.
*/
function getImageStoreDir(): string {
return join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR, getSessionId())
}
/**
* Ensure the image store directory exists.
*/
async function ensureImageStoreDir(): Promise<void> {
const dir = getImageStoreDir()
await mkdir(dir, { recursive: true })
}
/**
* Get the file path for an image by ID.
*/
function getImagePath(imageId: number, mediaType: string): string {
const extension = mediaType.split('/')[1] || 'png'
return join(getImageStoreDir(), `${imageId}.${extension}`)
}
/**
* Cache the image path immediately (fast, no file I/O).
*/
export function cacheImagePath(content: PastedContent): string | null {
if (content.type !== 'image') {
return null
}
const imagePath = getImagePath(content.id, content.mediaType || 'image/png')
evictOldestIfAtCap()
storedImagePaths.set(content.id, imagePath)
return imagePath
}
/**
* Store an image from pastedContents to disk.
*/
export async function storeImage(
content: PastedContent,
): Promise<string | null> {
if (content.type !== 'image') {
return null
}
try {
await ensureImageStoreDir()
const imagePath = getImagePath(content.id, content.mediaType || 'image/png')
const fh = await open(imagePath, 'w', 0o600)
try {
await fh.writeFile(content.content, { encoding: 'base64' })
await fh.datasync()
} finally {
await fh.close()
}
evictOldestIfAtCap()
storedImagePaths.set(content.id, imagePath)
logForDebugging(`Stored image ${content.id} to ${imagePath}`)
return imagePath
} catch (error) {
logForDebugging(`Failed to store image: ${error}`)
return null
}
}
/**
* Store all images from pastedContents to disk.
*/
export async function storeImages(
pastedContents: Record<number, PastedContent>,
): Promise<Map<number, string>> {
const pathMap = new Map<number, string>()
for (const [id, content] of Object.entries(pastedContents)) {
if (content.type === 'image') {
const path = await storeImage(content)
if (path) {
pathMap.set(Number(id), path)
}
}
}
return pathMap
}
/**
* Get the file path for a stored image by ID.
*/
export function getStoredImagePath(imageId: number): string | null {
return storedImagePaths.get(imageId) ?? null
}
/**
* Clear the in-memory cache of stored image paths.
*/
export function clearStoredImagePaths(): void {
storedImagePaths.clear()
}
function evictOldestIfAtCap(): void {
while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
const oldest = storedImagePaths.keys().next().value
if (oldest !== undefined) {
storedImagePaths.delete(oldest)
} else {
break
}
}
}
/**
* Clean up old image cache directories from previous sessions.
*/
export async function cleanupOldImageCaches(): Promise<void> {
const fsImpl = getFsImplementation()
const baseDir = join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR)
const currentSessionId = getSessionId()
try {
let sessionDirs
try {
sessionDirs = await fsImpl.readdir(baseDir)
} catch {
return
}
for (const sessionDir of sessionDirs) {
if (sessionDir.name === currentSessionId) {
continue
}
const sessionPath = join(baseDir, sessionDir.name)
try {
await fsImpl.rm(sessionPath, { recursive: true, force: true })
logForDebugging(`Cleaned up old image cache: ${sessionPath}`)
} catch {
// Ignore errors for individual directories
}
}
try {
const remaining = await fsImpl.readdir(baseDir)
if (remaining.length === 0) {
await fsImpl.rmdir(baseDir)
}
} catch {
// Ignore
}
} catch {
// Ignore errors reading base directory
}
}