Project Files
src / files-api.ts
// src/files-api.ts
/**
* This module encapsulates all interactions with the Google AI Files API.
* It handles uploading files, managing their state via a log file,
* checking for expiration, re-uploading if necessary, and cleaning up.
*/
import { GoogleAIFileManager } from "@google/generative-ai/server";
import { GoogleGenerativeAI } from "@google/generative-ai";
import fs from "fs";
import path from "path";
type ActiveFile = {
source: "attachment" | "generated";
localPath: string;
origin?: string; // Original filename (for attachments) to detect changes across workdir copies
originalName?: string; // User-facing name for the attachment
n?: number; // Stable index for attachment (a1, a2, etc.)
fileName: string; // The resource name, e.g., "files/xyz123"
fileUri: string; // The full URI, e.g., "https://..."
mimeType: string;
createdAt: string;
};
type VisionContext = {
activeAttachments: ActiveFile[]; // Array of active attachments (a1..aN), replaced as complete set
activeGenerated: ActiveFile[]; // Array of generated variants (v1..vN), replaced as complete set
};
function getVisionContextPath(chatWd: string): string {
// Canary filename for cross-plugin compatibility
return path.join(chatWd, "vision_context.canary.json");
}
async function readVisionContext(chatWd: string): Promise<VisionContext> {
const contextPath = getVisionContextPath(chatWd);
try {
if (fs.existsSync(contextPath)) {
const raw = await fs.promises.readFile(contextPath, "utf-8");
const parsed = JSON.parse(raw);
try { console.info("[FilesAPI] Read vision context:", contextPath); } catch { }
return {
// Support both old (activeAttachment) and new (activeAttachments) format
activeAttachments: Array.isArray(parsed.activeAttachments) ? parsed.activeAttachments : (parsed.activeAttachment ? [parsed.activeAttachment] : []),
activeGenerated: Array.isArray(parsed.activeGenerated) ? parsed.activeGenerated : [],
};
}
} catch (error) {
console.error("Error reading vision_context.json:", error);
}
// Return a default empty context if file doesn't exist or is invalid
return { activeAttachments: [], activeGenerated: [] };
}
async function writeVisionContext(chatWd: string, context: VisionContext): Promise<void> {
const contextPath = getVisionContextPath(chatWd);
try {
await fs.promises.mkdir(path.dirname(contextPath), { recursive: true });
await fs.promises.writeFile(contextPath, JSON.stringify(context, null, 2));
try { console.info("[FilesAPI] Wrote vision context:", contextPath); } catch { }
} catch (error) {
console.error("Error writing vision_context.json:", error);
}
}
/**
* Sets the active attachments in the vision context. This REPLACES the entire
* attachment set (a1..aN). Old attachments will be detected as orphans and deleted
* by synchronizeVisionContext when they no longer match chat_media_state.json.
* @param chatWd The working directory for the current chat.
* @param attachments Array of attachment info with localPath, mimeType, uploadResult, and optional origin.
* @returns Array of previously active attachments that are no longer in the new set (dropped from Rolling Window).
*/
export async function setActiveAttachments(
chatWd: string,
attachments: Array<{
localPath: string;
mimeType: string;
uploadResult: { fileName: string; fileUri: string };
origin?: string;
originalName?: string;
n?: number;
}>,
): Promise<Array<{ fileName: string; localPath: string; n?: number }>> {
const context = await readVisionContext(chatWd);
const previousAttachments = context.activeAttachments || [];
// Sort by n-value (ascending) for consistent display
const sorted = [...attachments].sort((a, b) => (a.n ?? 0) - (b.n ?? 0));
const newFileNames = new Set(sorted.map(a => a.uploadResult.fileName));
// Find attachments that were active before but are not in the new set
const dropped = previousAttachments
.filter(prev => !newFileNames.has(prev.fileName))
.map(prev => ({ fileName: prev.fileName, localPath: prev.localPath, n: prev.n }));
context.activeAttachments = sorted.map(att => ({
source: "attachment" as const,
localPath: att.localPath,
origin: att.origin || path.basename(att.localPath),
originalName: att.originalName,
n: att.n,
fileName: att.uploadResult.fileName,
fileUri: att.uploadResult.fileUri,
mimeType: att.mimeType,
createdAt: new Date().toISOString(),
}));
await writeVisionContext(chatWd, context);
return dropped;
}
/**
* Deletes files from the Files API that were dropped from the Rolling Window.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
* @param droppedFiles Array of files that were dropped (returned from setActiveAttachments).
*/
export async function cleanupDroppedFromRollingWindow(
apiKey: string,
chatWd: string,
droppedFiles: Array<{ fileName: string; localPath: string; n?: number }>,
): Promise<void> {
if (droppedFiles.length === 0) return;
console.info(`[Rolling Window Cleanup] Deleting ${droppedFiles.length} file(s) dropped from window: ${droppedFiles.map(f => `a${f.n ?? '?'}`).join(', ')}`);
for (const dropped of droppedFiles) {
await deleteFile(apiKey, chatWd, dropped.fileName, {
localPath: dropped.localPath,
reason: `dropped-from-rolling-window (a${dropped.n ?? '?'})`,
});
}
}
/**
* Convenience wrapper: Sets a single active attachment (replaces entire set with one item).
* Note: Ignores dropped files since this is typically used for single-attachment scenarios.
*/
export async function setActiveAttachment(
chatWd: string,
localPath: string,
mimeType: string,
uploadResult: { fileName: string; fileUri: string },
origin?: string,
): Promise<void> {
await setActiveAttachments(chatWd, [{ localPath, mimeType, uploadResult, origin }]);
}
/**
* Adds or updates multiple generated file entries in the vision context. Used to
* track Files API uploads for model-generated images so they can be
* cleaned up later by synchronizeVisionContext. This function is designed to be
* atomic for a batch of new files to prevent race conditions.
*/
export async function addMultipleActiveGenerated(
chatWd: string,
generatedFiles: Array<{ localPath: string; mimeType: string; uploadResult: { fileName: string; fileUri: string } }>
): Promise<void> {
// Idempotent upsert that PRESERVES createdAt for existing entries.
// Only updates fileName/fileUri/mimeType if the same localPath already exists.
if (!generatedFiles.length) return;
const context = await readVisionContext(chatWd);
const existing = Array.isArray(context.activeGenerated) ? context.activeGenerated.slice() : [];
const byPath = new Map(existing.map(e => [e.localPath, e] as const));
// Update existing entries in place
for (const gf of generatedFiles) {
const prev = byPath.get(gf.localPath);
if (prev) {
prev.fileName = gf.uploadResult.fileName;
prev.fileUri = gf.uploadResult.fileUri;
prev.mimeType = gf.mimeType;
// Keep prev.createdAt as-is
}
}
// Append only truly new entries
for (const gf of generatedFiles) {
if (!byPath.has(gf.localPath)) {
const entry: ActiveFile = {
source: "generated",
localPath: gf.localPath,
fileName: gf.uploadResult.fileName,
fileUri: gf.uploadResult.fileUri,
mimeType: gf.mimeType,
createdAt: new Date().toISOString(),
};
existing.push(entry);
byPath.set(gf.localPath, entry);
}
}
context.activeGenerated = existing;
await writeVisionContext(chatWd, context);
}
/**
* Backward-compatible wrapper for adding a single generated file entry to the vision context.
* Older callers use `addActiveGenerated(chatWd, localPath, mimeType, uploadResult)`.
* Internally this forwards to `addMultipleActiveGenerated`.
*/
export async function addActiveGenerated(
chatWd: string,
localPath: string,
mimeType: string,
uploadResult: { fileName: string; fileUri: string },
): Promise<void> {
return addMultipleActiveGenerated(chatWd, [{ localPath, mimeType, uploadResult }]);
}
type LogEvent = {
timestamp: string;
type: "UPLOAD_SUCCESS" | "DELETE_SUCCESS" | "VALIDATION_SUCCESS" | "REUPLOAD_SUCCESS";
localPath?: string;
fileName: string; // The resource name, e.g., "files/xyz123"
fileUri: string; // The full URI, e.g., "https://..."
mimeType?: string;
details?: string;
};
function getLogPath(chatWd: string): string {
// Extract a chat ID to create a unique log file per conversation.
const chatId = path.basename(chatWd).match(/(\d{13})/)?.[1] || "unknown-chat";
return path.join(chatWd, `${chatId}.files-api.jsonl`);
}
async function logEvent(chatWd: string, event: Omit<LogEvent, "timestamp">): Promise<void> {
const logPath = getLogPath(chatWd);
// Use local timezone format (like other logs) instead of UTC
const now = new Date();
const localTimestamp = now.toLocaleString("sv-SE", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }).replace(" ", "T") + "." + String(now.getMilliseconds()).padStart(3, "0");
const logEntry: LogEvent = {
timestamp: localTimestamp,
...event,
};
try {
const formattedJson = JSON.stringify(logEntry, null, 2);
const entrySeparator = "\n\n";
await fs.promises.appendFile(logPath, formattedJson + entrySeparator);
} catch (error) {
console.error("Failed to write to Files API log:", error);
}
}
async function getFileManager(apiKey: string): Promise<GoogleAIFileManager> {
// It's safer to initialize with the key when needed.
return new GoogleAIFileManager(apiKey);
}
/**
* Deletes a file from the Google AI Files API storage.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat, for logging.
* @param fileName The NAME of the file to delete (e.g., "files/xyz123").
*/
export async function deleteFile(
apiKey: string,
chatWd: string,
fileName: string,
info?: { localPath?: string; reason?: string }
): Promise<void> {
try {
const fileManager = await getFileManager(apiKey);
await fileManager.deleteFile(fileName);
console.info(`Successfully deleted file from Files API: ${fileName}`);
await logEvent(chatWd, {
type: "DELETE_SUCCESS",
fileName,
fileUri: "", // URI not needed for deletion log
localPath: info?.localPath,
details: info?.reason || "File deleted.",
});
} catch (error) {
console.error(`Failed to delete file ${fileName}:`, error);
}
}
/**
* Reads the log file for a chat and deletes ALL uploaded files (full cleanup).
* Use this when switching away from Files API mode or cleaning up a chat entirely.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
*/
export async function cleanupChatFiles(apiKey: string, chatWd: string): Promise<void> {
const logPath = getLogPath(chatWd);
try {
if (!fs.existsSync(logPath)) {
return; // No log file, nothing to clean up.
}
const logContent = await fs.promises.readFile(logPath, "utf-8");
const eventsRaw = logContent.split("\n\n").filter(s => s.trim());
const namesToDelete = new Set<string>();
const namesDeleted = new Set<string>();
// Read from bottom to top to get the latest status
for (const eventRaw of eventsRaw.reverse()) {
try {
const event = JSON.parse(eventRaw) as LogEvent;
if (event.type.includes("DELETE")) {
namesDeleted.add(event.fileName);
} else if (event.type.includes("UPLOAD") || event.type.includes("REUPLOAD")) {
if (!namesDeleted.has(event.fileName)) {
namesToDelete.add(event.fileName);
}
}
} catch (e) {
// Ignore parsing errors for malformed entries
}
}
if (namesToDelete.size > 0) {
console.info(`[Files API Cleanup] Found ${namesToDelete.size} file(s) to delete for chat.`);
for (const name of namesToDelete) {
await deleteFile(apiKey, chatWd, name, { reason: "full-chat-cleanup" });
}
console.info(`[Files API Cleanup] Deleted ${namesToDelete.size} file(s). DELETE_SUCCESS entries logged to .files-api.jsonl`);
} else {
console.info(`[Files API Cleanup] No files to delete (all already cleaned up).`);
}
// NOTE: vision_context.canary.json is NOT cleared here - it tracks active images for both
// Files API and Base64 modes. The .files-api.jsonl is also preserved as audit log.
} catch (error) {
console.error("Error during Files API cleanup:", error);
}
}
/**
* Deletes files from the Files API that are NOT in vision_context.canary.json (orphan cleanup).
* This catches files that were uploaded but never properly tracked or fell out of sync.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
*/
export async function cleanupOrphanedFiles(apiKey: string, chatWd: string): Promise<void> {
const logPath = getLogPath(chatWd);
try {
if (!fs.existsSync(logPath)) {
return; // No log file, nothing to clean up.
}
// Read current vision context to know what should be kept
const context = await readVisionContext(chatWd);
const activeFileNames = new Set<string>([
...context.activeAttachments.map(a => a.fileName),
...context.activeGenerated.map(g => g.fileName),
]);
// Parse the log to find all uploaded files that haven't been deleted
const logContent = await fs.promises.readFile(logPath, "utf-8");
const eventsRaw = logContent.split("\n\n").filter(s => s.trim());
const uploadedFiles = new Map<string, string>(); // fileName -> localPath
const deletedFiles = new Set<string>();
for (const eventRaw of eventsRaw) {
try {
const event = JSON.parse(eventRaw) as LogEvent;
if (event.type.includes("DELETE")) {
deletedFiles.add(event.fileName);
} else if (event.type.includes("UPLOAD") || event.type.includes("REUPLOAD")) {
if (!deletedFiles.has(event.fileName)) {
uploadedFiles.set(event.fileName, event.localPath || "unknown");
}
}
} catch (e) {
// Ignore parsing errors
}
}
// Find orphans: uploaded but not in vision_context and not deleted
const orphans: Array<{ fileName: string; localPath: string }> = [];
for (const [fileName, localPath] of uploadedFiles) {
if (!activeFileNames.has(fileName) && !deletedFiles.has(fileName)) {
orphans.push({ fileName, localPath });
}
}
if (orphans.length > 0) {
console.info(`[Orphan Cleanup] Found ${orphans.length} orphaned file(s) not in vision_context.canary.json`);
for (const orphan of orphans) {
await deleteFile(apiKey, chatWd, orphan.fileName, {
localPath: orphan.localPath,
reason: "orphaned-not-in-vision-context"
});
}
} else {
console.info(`[Orphan Cleanup] No orphaned files found.`);
}
} catch (error) {
console.error("Error during orphan cleanup:", error);
}
}
/**
* Ensures a local file is uploaded to the Files API, handling state, expiration, and re-uploads.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
* @param localPath The absolute path to the local file.
* @param mimeType The MIME type of the file.
* @returns The valid full `fileUri` for the uploaded file, or null on failure.
*/
export async function ensureFileIsUploaded(
apiKey: string,
chatWd: string,
localPath: string,
mimeType: string,
): Promise<{ fileUri: string; fileName: string } | null> {
const logPath = getLogPath(chatWd);
const fileManager = await getFileManager(apiKey);
let lastKnownUri: string | null = null;
let lastKnownName: string | null = null;
let lastKnownTimestamp: string | null = null;
let lastDeleteForKnownName: string | null = null;
// 1. Check log for an existing, non-deleted entry for this local file
if (fs.existsSync(logPath)) {
const logContent = await fs.promises.readFile(logPath, "utf-8");
const eventsRaw = logContent.split("\n\n").filter(s => s.trim());
const events: LogEvent[] = [];
for (const er of eventsRaw) { try { events.push(JSON.parse(er) as LogEvent); } catch { } }
// Find last known upload/reupload for this localPath
for (const event of [...events].reverse()) {
try {
if (event.localPath === localPath) {
if (event.type.includes("DELETE")) {
break; // The last action for this file was deletion, so stop.
}
if ((event.type.includes("UPLOAD") || event.type.includes("REUPLOAD")) && event.fileName && event.fileUri) {
lastKnownUri = event.fileUri;
lastKnownName = event.fileName;
lastKnownTimestamp = event.timestamp;
break;
}
}
} catch { }
}
// Track any DELETE for that fileName later in the log
if (lastKnownName) {
for (const event of [...events].reverse()) {
if (event.type.includes("DELETE") && event.fileName === lastKnownName) {
lastDeleteForKnownName = event.timestamp;
break;
}
}
}
}
// 2. If a file was found in the log, validate its age.
if (lastKnownName && lastKnownUri && lastKnownTimestamp) {
// Invalidate if we have a DELETE record for that file after the upload timestamp
try {
if (lastDeleteForKnownName) {
const del = new Date(lastDeleteForKnownName).getTime();
const up = new Date(lastKnownTimestamp).getTime();
if (!Number.isNaN(del) && !Number.isNaN(up) && del >= up) {
lastKnownName = null; lastKnownUri = null; lastKnownTimestamp = null;
}
}
} catch { }
try {
if (lastKnownName && lastKnownUri && lastKnownTimestamp) {
const now = new Date();
const uploadTime = new Date(lastKnownTimestamp);
const ageInHours = (now.getTime() - uploadTime.getTime()) / (1000 * 60 * 60);
if (ageInHours < 47) {
// Remote validation: verify file still exists on server
try {
await fileManager.getFile(lastKnownName);
await logEvent(chatWd, { type: "VALIDATION_SUCCESS", fileName: lastKnownName, fileUri: lastKnownUri, localPath, details: `File is ${ageInHours.toFixed(2)} hours old.` });
return { fileUri: lastKnownUri, fileName: lastKnownName };
} catch (e) {
// Treat as stale/removed on server; fall through to re-upload
await logEvent(chatWd, { type: "REUPLOAD_SUCCESS", fileName: lastKnownName, fileUri: lastKnownUri, localPath, details: "Remote validation failed; reuploading." });
}
} else {
console.warn(`File ${lastKnownName} is older than 47 hours. Re-uploading...`);
}
}
} catch (e) {
console.error(`Error parsing timestamp for ${lastKnownName}:`, e);
// Continue to upload logic below as a fallback.
}
}
// 3. If no valid file exists, upload it.
try {
console.info(`Uploading new file to Files API: ${localPath}`);
const response = await fileManager.uploadFile(localPath, { mimeType, displayName: path.basename(localPath) });
const newFileUri = response.file.uri;
const newFileName = response.file.name;
console.info(`File uploaded successfully. URI: ${newFileUri}, Name: ${newFileName}`);
await logEvent(chatWd, {
type: lastKnownName ? "REUPLOAD_SUCCESS" : "UPLOAD_SUCCESS",
localPath,
fileName: newFileName,
fileUri: newFileUri,
mimeType,
});
return { fileUri: newFileUri, fileName: newFileName };
} catch (error) {
console.error("Files API upload failed:", error);
return null;
}
}
/**
* Constructs the fileData part for a generateContent request.
* @param fileUri The URI of the file obtained from the Files API.
* @param mimeType The MIME type of the file.
* @returns The part object for the API call.
*/
export function buildFileDataPart(fileUri: string, mimeType: string): any {
return {
fileData: {
mimeType,
fileUri,
},
};
}
/**
* Manages the state of active attachments based on a new set of origins.
* It reads the current context, determines if the attachment SET has changed,
* triggers deletion of ALL old files from the server, and clears the context.
* The new attachments will be registered later via setActiveAttachments after upload.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
* @param newOrigins Array of new origin filenames (or empty to clear all).
* @returns true if the set changed and old files were deleted, false if unchanged.
*/
export async function manageActiveAttachments(apiKey: string, chatWd: string, newOrigins: string[]): Promise<boolean> {
const context = await readVisionContext(chatWd);
const oldAttachments = context.activeAttachments || [];
// Build sets for comparison
const oldOriginSet = new Set(oldAttachments.map(a => a.origin || path.basename(a.localPath)));
const newOriginSet = new Set(newOrigins);
// Check if the sets are identical
const isSameSet = oldOriginSet.size === newOriginSet.size &&
[...oldOriginSet].every(o => newOriginSet.has(o));
if (isSameSet) {
return false; // Nothing to do
}
// Delete ALL old attachments from the server
for (const oldAtt of oldAttachments) {
if (oldAtt.fileName) {
const oldOrigin = oldAtt.origin || path.basename(oldAtt.localPath);
console.info(`[Attachment Cleanup] Deleting old attachment '${oldOrigin}': ${oldAtt.fileName}`);
await deleteFile(apiKey, chatWd, oldAtt.fileName);
}
}
// Clear attachments in context (new ones will be set after upload)
context.activeAttachments = [];
await writeVisionContext(chatWd, context);
return true;
}
/**
* Convenience wrapper for single attachment (workaround path).
* @deprecated Use manageActiveAttachments for consistency
*/
export async function manageActiveAttachment(apiKey: string, chatWd: string, i2iSourcePath: string): Promise<ActiveFile | null> {
const newOrigins = i2iSourcePath ? [path.basename(i2iSourcePath)] : [];
const changed = await manageActiveAttachments(apiKey, chatWd, newOrigins);
if (!i2iSourcePath) {
return null;
}
// Return a placeholder ActiveFile (actual upload happens later)
const ext = path.extname(i2iSourcePath).toLowerCase();
let mimeType = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mimeType = "image/jpeg";
else if (ext === ".webp") mimeType = "image/webp";
else if (ext === ".gif") mimeType = "image/gif";
return {
source: "attachment",
localPath: i2iSourcePath,
origin: path.basename(i2iSourcePath),
fileName: "",
fileUri: "",
mimeType,
createdAt: new Date().toISOString(),
};
}
/**
* Updates the vision context file with the results from a successful file upload.
* This should be called after `ensureFileIsUploaded` returns a valid URI.
* @deprecated Use setActiveAttachments instead for batch operations
* @param chatWd The working directory for the current chat.
* @param activeAttachment The attachment object, which should now be updated.
* @param uploadResult The result from the upload, containing the fileName and fileUri.
*/
export async function updateActiveAttachmentWithUploadResult(
chatWd: string,
activeAttachment: ActiveFile,
uploadResult: { fileName: string; fileUri: string },
): Promise<void> {
const context = await readVisionContext(chatWd);
// Update the attachment object with the new details
const updatedAttachment: ActiveFile = {
...activeAttachment,
fileName: uploadResult.fileName,
fileUri: uploadResult.fileUri,
};
// Replace or add this attachment in the array
const existingIdx = context.activeAttachments.findIndex(a => a.localPath === activeAttachment.localPath);
if (existingIdx >= 0) {
context.activeAttachments[existingIdx] = updatedAttachment;
} else {
context.activeAttachments.push(updatedAttachment);
}
await writeVisionContext(chatWd, context);
}
/**
* Synchronizes the vision context with the actual chat history. It finds orphaned
* files (entries in vision_context.json that are no longer referenced in the chat history)
* and deletes them from the remote server.
* @param apiKey The Gemini API key.
* @param chatWd The working directory for the current chat.
* @param history The current chat history object from the SDK.
*/
export async function synchronizeVisionContext(apiKey: string, chatWd: string, history: any /* Chat */): Promise<void> {
// Use chat_media_state.json as single source of truth for referenced files
const statePath = path.join(chatWd, "chat_media_state.json");
let state: any = null;
try {
state = JSON.parse(await fs.promises.readFile(statePath, "utf8"));
} catch (e) {
throw new Error("synchronizeVisionContext: chat_media_state.json missing or invalid. Aborting cleanup.");
}
const referenced = new Set<string>();
const addRef = (p?: string) => {
if (typeof p !== "string" || !p.length) return;
referenced.add(path.isAbsolute(p) ? p : path.join(chatWd, p));
};
try {
// Add ALL attachments to referenced set (supports array)
if (Array.isArray(state.attachments)) {
state.attachments.forEach((a: any) => {
addRef(a.originAbs);
addRef(a.preview || a.filename);
});
}
if (Array.isArray(state.variants)) {
state.variants.forEach((v: any) => {
addRef(v.originAbs);
addRef(v.preview || v.filename);
});
}
} catch { }
const context = await readVisionContext(chatWd);
const activeFiles = [...context.activeAttachments, ...context.activeGenerated];
if (activeFiles.length === 0) return;
const orphans = activeFiles.filter(f => !referenced.has(f.localPath));
if (!orphans.length) return;
console.info(`[Vision Sync] Found ${orphans.length} orphaned file(s) to delete (Files API).`);
for (const o of orphans) {
await deleteFile(apiKey, chatWd, o.fileName, { localPath: o.localPath, reason: "orphaned-not-in-state" });
}
const updatedContext: VisionContext = {
activeAttachments: context.activeAttachments.filter(f => !orphans.some(o => o.localPath === f.localPath)),
activeGenerated: context.activeGenerated.filter(f => !orphans.some(o => o.localPath === f.localPath)),
};
await writeVisionContext(chatWd, updatedContext);
}