Project Files
src / promotionFiles.ts
import { type BuildVisionPromotionPartsParams, harvestToolGeneratedVariantsFromLatestToolMessage, shouldPromoteImages, markAsPromoted } from "./promotion";
import {
ensureFileIsUploaded,
buildFileDataPart,
synchronizeVisionContext,
addMultipleActiveGenerated,
setActiveAttachments,
cleanupDroppedFromRollingWindow,
cleanupOrphanedFiles
} from "./files-api";
import { readChatMediaState, type ChatMediaState } from "./chat-media-state";
import { importAttachmentBatch, findAllAttachmentsFromConversation } from "./attachments";
import { getMaxPromotedAttachments } from "./capabilities";
import path from "path";
export async function buildPromotionPartsFiles(
params: BuildVisionPromotionPartsParams
): Promise<{ promoParts: any[]; promotedFiles: string[] }> {
const { ctl, history, apiKey, chatWd, debugChunks, visionPromotionPersistent } = params as any;
const promoParts: any[] = [];
const promotedFiles: string[] = [];
if (!apiKey) {
if (debugChunks) console.warn("[PromotionFiles] No API key provided; skipping Files API promotion.");
return { promoParts, promotedFiles };
}
// 0. Harvest tool-generated variants (no copies, no preview generation) so state is up to date.
try {
await harvestToolGeneratedVariantsFromLatestToolMessage(ctl, history as any, chatWd, debugChunks);
} catch (e) {
if (debugChunks) console.warn("[PromotionFiles] Tool variants harvest failed:", (e as Error).message);
}
// 1. Load current state and import attachments from SSOT (conversation.json)
let state = await readChatMediaState(chatWd).catch(() => ({ attachments: [], variants: [], counters: { nextN: 1, nextV: 1 } } as ChatMediaState));
// Try to import attachments from SSOT (ALL attachments from entire conversation)
const ssotPaths = await findAllAttachmentsFromConversation(chatWd, debugChunks);
// Get model-specific limit for visual promotion (Rolling Window size)
const maxPromotedAttachments = getMaxPromotedAttachments(params.model || "");
if (debugChunks) console.info(`[PromotionFiles] Model=${params.model} maxPromotedAttachments=${maxPromotedAttachments}`);
if (ssotPaths.length > 0) {
const result = await importAttachmentBatch(
chatWd,
state,
ssotPaths,
{ maxDim: 1024, quality: 85 },
maxPromotedAttachments,
debugChunks
);
if (result.changed) {
state = await readChatMediaState(chatWd);
if (debugChunks) console.info('[PromotionFiles] Imported attachments from SSOT');
}
}
// 2. Idempotency check - determines what to INJECT into prompt (not what to track in Files API)
const persistentMode = visionPromotionPersistent === true;
const { shouldPromoteAttachment, shouldPromoteVariants } = shouldPromoteImages(state, persistentMode);
// ALWAYS log idempotency check for debugging
const attachments = Array.isArray(state.attachments) ? state.attachments : [];
const maxN = attachments.length > 0 ? Math.max(0, ...attachments.map((a: any) => a.n ?? 0)) : 0;
console.info(`[PromotionFiles] Idempotency: persistent=${persistentMode} maxN=${maxN} lastPromoted=${state.lastPromotedAttachmentN ?? 'undefined'} → shouldPromoteAtt=${shouldPromoteAttachment} shouldPromoteVar=${shouldPromoteVariants}`);
// NOTE: We always proceed to ensure Files API registration is up-to-date (vision_context.canary.json)
// The idempotency flags control whether we inject into the prompt, not whether we track files.
const skipPromptInjection = !shouldPromoteAttachment && !shouldPromoteVariants;
if (skipPromptInjection) {
console.info('[PromotionFiles] ✓ Skipping prompt injection (idempotent), but will update Files API tracking...');
} else {
console.info('[PromotionFiles] → Proceeding with promotion...');
}
// 3. Synchronize context (cleanup orphans) - AFTER importing attachments
try {
await synchronizeVisionContext(apiKey, chatWd, history);
} catch (e) {
if (debugChunks) console.warn("[PromotionFiles] Sync failed:", (e as Error).message);
}
// Attachments from State
// In Files API mode, we upload directly from originAbs (no local copies)
// NOTE: Attachments outside the Rolling Window are NOT promoted at all (no text-only labels).
if (state.attachments && state.attachments.length > 0) {
// Sort by n-value (ascending) to ensure chronological order for Rolling Window
const allAttachments = [...state.attachments].sort((a, b) => (a.n ?? 0) - (b.n ?? 0));
// Take the LAST N (highest n-values = most recent attachments)
const promotedAttachments = allAttachments.slice(-maxPromotedAttachments);
if (debugChunks) {
const promotedNs = promotedAttachments.map(a => `a${a.n}:${a.originalName || a.origin}`).join(', ');
console.info(`[PromotionFiles] Rolling Window: total=${allAttachments.length} promoting=${promotedAttachments.length} [${promotedNs}]`);
}
const uploadedForContext: Array<{ localPath: string; mimeType: string; uploadResult: { fileName: string; fileUri: string }; origin?: string; originalName?: string; n?: number }>
= [];
for (const att of promotedAttachments) {
const uploadPath = att.originAbs || (att.preview ? path.join(chatWd, att.preview) : path.join(chatWd, att.filename || ""));
const origin = att.origin || att.filename || path.basename(uploadPath);
const ext = path.extname(uploadPath).toLowerCase();
let mime = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mime = "image/jpeg";
else if (ext === ".webp") mime = "image/webp";
const upload = await ensureFileIsUploaded(apiKey, chatWd, uploadPath, mime);
if (upload) {
const stableN = typeof att.n === "number" ? att.n : 0;
const originalName = att.originalName || att.origin || `attachment-${stableN}`;
// Only inject into prompt if shouldPromoteAttachment is true
if (shouldPromoteAttachment) {
const label = `Attachment [a${stableN}] ${originalName}`;
promoParts.push({ text: label });
promoParts.push(buildFileDataPart(upload.fileUri, mime));
promotedFiles.push(uploadPath);
}
// Always track in vision_context for Files API management
uploadedForContext.push({ localPath: uploadPath, mimeType: mime, uploadResult: upload, origin, originalName, n: stableN });
}
}
// Register the full promoted attachment set atomically (prevents clobbering to a single entry)
if (uploadedForContext.length > 0) {
const droppedFiles = await setActiveAttachments(chatWd, uploadedForContext);
if (debugChunks) console.info('[PromotionFiles] Registered active attachments in vision context:', uploadedForContext.map(x => x.localPath).join(', '));
// Cleanup files that fell out of the Rolling Window
if (droppedFiles.length > 0 && apiKey) {
await cleanupDroppedFromRollingWindow(apiKey, chatWd, droppedFiles);
}
}
}
// Variants from State
if (state.variants && state.variants.length > 0) {
const generatedFilesToRegister: Array<{ localPath: string; mimeType: string; uploadResult: { fileName: string; fileUri: string } }> = [];
let variantsToProcess = [...state.variants];
// Sort by v field for consistent ordering
variantsToProcess.sort((a, b) => a.v - b.v || a.createdAt.localeCompare(b.createdAt));
if (params.showOnlyLastImageVariant && variantsToProcess.length > 0) {
variantsToProcess = [variantsToProcess[variantsToProcess.length - 1]];
if (debugChunks) console.info(`[PromotionFiles] Filtering variants to only the last one (V${variantsToProcess[0].v}) due to showOnlyLastImageVariant`);
}
// Limit to max 3 variants
variantsToProcess = variantsToProcess.slice(0, 3);
for (const v of variantsToProcess) {
// Files-API mode must upload the original if available.
// - External tool variants: use originAbs
// - Legacy variants: use chatWd/filename (original PNG in workdir)
const localPath = (v as any).originAbs
? (v as any).originAbs
: path.join(chatWd, v.filename);
const ext = path.extname(localPath).toLowerCase();
let mime = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mime = "image/jpeg";
else if (ext === ".webp") mime = "image/webp";
const upload = await ensureFileIsUploaded(apiKey, chatWd, localPath, mime);
if (upload) {
// Only inject into prompt if shouldPromoteVariants is true
if (shouldPromoteVariants) {
const stableV = typeof (v as any).v === "number" ? (v as any).v : undefined;
const vTag = stableV ? `v${stableV}` : "v?";
const label = `Generated Image [${vTag}]`;
promoParts.push({ text: label });
promoParts.push(buildFileDataPart(upload.fileUri, mime));
promotedFiles.push(localPath);
}
// Always track in vision_context for Files API management
generatedFilesToRegister.push({ localPath, mimeType: mime, uploadResult: upload });
}
}
// Register generated files in vision_context so they are tracked (always, for Files API management)
if (generatedFilesToRegister.length > 0) {
await addMultipleActiveGenerated(chatWd, generatedFilesToRegister);
}
}
// Mark as promoted for idempotency tracking
if (promotedFiles.length > 0) {
await markAsPromoted(chatWd, state, shouldPromoteAttachment, shouldPromoteVariants);
if (debugChunks) console.info(`[PromotionFiles] Marked as promoted: attachments=${shouldPromoteAttachment} variants=${shouldPromoteVariants}`);
}
// Cleanup orphaned files (uploaded but not in vision_context.canary.json)
// This catches files that fell out of sync due to bugs or old code versions
try {
await cleanupOrphanedFiles(apiKey, chatWd);
} catch (e) {
if (debugChunks) console.warn("[PromotionFiles] Orphan cleanup failed:", (e as Error).message);
}
return { promoParts, promotedFiles };
}