Forked from
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 { checkAndMarkInjected, makeDedupKey } from "./promotionDedup";
import { parseAttachmentWrappers } from "./generator-utils";
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 — SDK-first (race-free) ∪ SSOT (historical turns)
// SDK paths are delivered by LM Studio in this generate() call, available even before
// conversation.json is written to disk (fixes Turn-1 race condition).
let state = await readChatMediaState(chatWd).catch(() => ({ attachments: [], variants: [], counters: { nextN: 1, nextV: 1 } } as ChatMediaState));
const maxPromotedAttachments = getMaxPromotedAttachments(params.model || "");
if (debugChunks) console.info(`[PromotionFiles] Model=${params.model} maxPromotedAttachments=${maxPromotedAttachments}`);
// History-first: parse [[LMSTUDIO_ATTACHMENT:...]] wrappers from in-memory history messages.
// Preprocessor calls consumeFiles() BEFORE the generator runs, so getAllFiles() returns empty.
// File paths are embedded as [[LMSTUDIO_ATTACHMENT:{"kind":"image","url":"file://..."}]] wrappers.
const historyPaths: string[] = [];
try {
for (const msg of history as any) {
if (typeof msg.getRole === 'function' && msg.getRole() === 'user') {
const text = typeof msg.getText === 'function' ? (msg.getText() || '') : '';
const { parts } = parseAttachmentWrappers(text);
for (const p of parts) {
if (p.kind === 'image' && typeof p.url === 'string') {
const fp = p.url.startsWith('file://') ? p.url.slice(7) : (path.isAbsolute(p.url) ? p.url : null);
if (fp) historyPaths.push(fp);
}
}
}
}
if (debugChunks && historyPaths.length > 0) console.info(`[PromotionFiles] History wrappers: ${historyPaths.length} image(s)`);
} catch (e) {
if (debugChunks) console.warn('[PromotionFiles] History parse failed, SSOT-only:', (e as Error).message);
}
const ssotPaths = await findAllAttachmentsFromConversation(chatWd, debugChunks);
const seen = new Set<string>();
const sourcePaths: string[] = [];
for (const p of [...historyPaths, ...ssotPaths]) {
const r = path.resolve(p);
if (!seen.has(r)) { seen.add(r); sourcePaths.push(r); }
}
if (sourcePaths.length > 0) {
const result = await importAttachmentBatch(
chatWd,
state,
sourcePaths,
{ maxDim: 1024, quality: 85 },
maxPromotedAttachments,
debugChunks
);
if (result.changed) {
state = await readChatMediaState(chatWd);
// No manual idempotency reset: shouldPromoteImages compares window sets automatically
if (debugChunks) console.info('[PromotionFiles] Imported attachments from SDK+SSOT');
}
}
// 2. Idempotency check - determines what to INJECT into prompt (not what to track in Files API)
const persistentMode = visionPromotionPersistent === true;
const { shouldPromoteAttachment, shouldPromoteVariants, promotableNs, newAttachmentNs } = shouldPromoteImages(state, persistentMode, maxPromotedAttachments);
const newAttachmentNsSet = new Set(newAttachmentNs ?? []);
console.info(`[PromotionFiles] Idempotency: persistent=${persistentMode} window=[${promotableNs.join(',')}] lastPromoted=[${(state.lastPromotedAttachmentAs ?? []).join(',')}] new=[${(newAttachmentNs ?? []).join(',')}] → 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 {
// Dedup: prevent title-gen and main-response from both injecting the same attachments
// within the same logical user turn (10s TTL). Only applies in non-persistent mode.
// Source pattern: draw-things-chat/src/orchestrator.ts (recentlyInjected Map)
if (!persistentMode && shouldPromoteAttachment) {
const attachmentNs = (state.attachments || []).map((a: any) => a.n ?? 0);
const dedupKey = makeDedupKey(chatWd, attachmentNs);
if (!checkAndMarkInjected(dedupKey)) {
if (debugChunks) console.info('[PromotionFiles] Dedup: skipping (already injected this turn)');
return { promoParts: [], promotedFiles: [] };
}
}
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}`;
// Inject into prompt only if this attachment is NEW to the current window (delta)
// persistent mode: inject all; idempotent: only items not previously promoted
const injectIntoPrompt = shouldPromoteAttachment && (
persistentMode
|| newAttachmentNs === undefined
|| newAttachmentNsSet.has(att.n ?? 0)
);
if (injectIntoPrompt) {
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, promotableNs);
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 };
}