Project Files
src / promotionBase64.ts
import fs from "fs";
import path from "path";
import { type Chat } from "@lmstudio/sdk";
import {
type BuildVisionPromotionPartsParams,
recoverProVariantsFromHistory,
harvestToolGeneratedVariantsFromLatestToolMessage,
buildPromotionItems,
toGeminiInlineDataParts,
shouldPromoteImages,
markAsPromoted,
} from "./promotion";
import { readChatMediaState, writeChatMediaStateAtomic } from "./chat-media-state";
import { addMultipleActiveGenerated } from "./files-api";
import {
findAllAttachmentsFromConversation,
importAttachmentBatch,
} from "./attachments";
import { resizeMaxDimJpegFromFile } from "./image";
import { getMaxPromotedAttachments } from "./capabilities";
/**
* Build promotion parts for Base64 mode (Mode B).
*
* REFACTORED (2025-12) to use:
* - importAttachmentBatch (stable n-numbering, no copies)
* - buildPromotionItems (stable labels with n-field)
* - toGeminiInlineDataParts (unified base64 encoding)
*/
export async function buildPromotionPartsB(params: BuildVisionPromotionPartsParams): Promise<{ promoParts: any[]; promotedFiles: string[] }> {
const { ctl, history, chatWd, debugChunks, shouldUseFilesApi, showOnlyLastImageVariant, visionPromotionPersistent } = params as any;
const promoParts: any[] = [];
const promotedFiles: string[] = [];
try {
// Step 1: Deterministic variants pipeline.
// - External tools: harvest from history (tool/assistant) WITHOUT copies or preview generation.
// - Gemini-native (gemini-3-pro-image-preview): use legacy recover path.
if (params.model === "gemini-3-pro-image-preview") {
try {
await recoverProVariantsFromHistory(history as Chat, chatWd, debugChunks, false);
} catch (e) {
if (debugChunks) console.warn('[Variants] Gemini-native recover failed:', (e as Error).message);
}
} else {
try {
const res = await harvestToolGeneratedVariantsFromLatestToolMessage(ctl, history as Chat, chatWd, debugChunks);
if (debugChunks) {
console.info(`[Variants] Harvest result: source=${res.source} found=${res.foundVariants} recorded=${res.recordedVariants} reason=${res.reason}`);
}
} catch (e) {
if (debugChunks) console.warn('[Variants] Harvest failed:', (e as Error).message);
}
}
// Step 2: Load current state
let state = await readChatMediaState(chatWd).catch(() => ({ attachments: [], variants: [], counters: {} } as any));
// Step 3: Import attachments from SSOT (conversation.json)
// This uses the new importAttachmentBatch with stable n-numbering
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(`[Promotion B] Model=${params.model} maxPromotedAttachments=${maxPromotedAttachments}`);
if (ssotPaths.length > 0) {
// Use new batch import with stable n-numbering
const importResult = await importAttachmentBatch(
chatWd,
state,
ssotPaths,
{ maxDim: 1024, quality: 85 },
maxPromotedAttachments, // model-specific limit
debugChunks
);
if (importResult.changed) {
state = await readChatMediaState(chatWd);
}
}
// Step 4: Idempotency check - skip if nothing new (unless persistent mode)
const persistentMode = visionPromotionPersistent === true;
const { shouldPromoteAttachment, shouldPromoteVariants } = shouldPromoteImages(state, persistentMode);
if (!shouldPromoteAttachment && !shouldPromoteVariants) {
if (debugChunks) console.info('[Promotion B] Idempotent mode: no new attachments/variants, skipping promotion');
return { promoParts: [], promotedFiles: [] };
}
// Step 5: Build promotion items using new function
// This uses stable n-field for labels
const hasAttachments = Array.isArray(state.attachments) && state.attachments.length > 0;
const hasVariants = Array.isArray(state.variants) && state.variants.length > 0;
if (!hasAttachments && !hasVariants) {
if (debugChunks) console.info('[Promotion B] No attachments or variants to promote');
return { promoParts: [], promotedFiles: [] };
}
// Determine variant limit
let maxVariantItems = 3;
if (showOnlyLastImageVariant && state.variants?.length > 0) {
maxVariantItems = 1;
if (debugChunks) console.info('[Promotion B] Limiting to only last variant due to showOnlyLastImageVariant');
}
const items = buildPromotionItems(chatWd, state, {
labels: true,
maxAttachmentItems: maxPromotedAttachments,
maxVariantItems,
});
// NOTE: Attachments outside the Rolling Window are NOT promoted at all (no text-only labels).
// The model only sees the last N attachments. If user references an older one, the agent
// should communicate that it's outside the visual context.
if (items.length === 0) {
if (debugChunks) console.info('[Promotion B] buildPromotionItems returned empty');
return { promoParts: [], promotedFiles: [] };
}
// Step 6: Convert to Gemini inlineData parts
const parts = await toGeminiInlineDataParts(items);
promoParts.push(...parts);
// Track promoted files
for (const it of items) {
promotedFiles.push(path.basename(it.previewAbs));
}
// Step 7: Write canary for debugging/audit
try {
const generatedForCanary = items.map((it) => ({
localPath: it.previewAbs,
mimeType: /\.png$/i.test(it.previewAbs) ? "image/png" : "image/jpeg",
uploadResult: { fileName: path.basename(it.previewAbs), fileUri: it.previewAbs },
}));
if (generatedForCanary.length) {
await addMultipleActiveGenerated(chatWd, generatedForCanary);
}
} catch { /* best-effort canary */ }
if (debugChunks) {
console.info(`[Promotion B] Promoted ${items.length} item(s): ${promotedFiles.join(', ')}`);
}
// Step 8: Mark as promoted for idempotency tracking
await markAsPromoted(chatWd, state, shouldPromoteAttachment, shouldPromoteVariants);
} catch (e) {
const err = e as any;
const msg = (err && (err.stack || err.message)) ? (err.stack || err.message) : String(err);
console.error("Promotion parts error (Base64):", msg);
}
return { promoParts, promotedFiles };
}