Project Files
src / attachments.ts
import fs from "fs";
import path from "path";
import os from "os";
import { fileUriToPath, copyFile, toIsoLikeTimestamp, resizeMaxDimJpegFromFile } from "./image";
import { type AttachmentItem, type ChatMediaState, writeChatMediaStateAtomic } from "./chat-media-state";
// ============================================================================
// Helper to find LM Studio home directory
// ============================================================================
export function findLMStudioHome(): string {
// Check environment variable first
if (process.env.LMSTUDIO_HOME) return process.env.LMSTUDIO_HOME;
// Default to ~/.lmstudio
return path.join(os.homedir(), ".lmstudio");
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
// ============================================================================
// Extract a 13-digit chat id from the chat working directory name
// ============================================================================
// Extract a 13-digit chat id from the chat working directory name (per LM Studio convention)
export function findChatIdFromWd(chatWd: string): string | null {
try {
const base = path.basename(chatWd || "");
const m = base.match(/(\d{13})/);
return m ? m[1] : null;
} catch {
return null;
}
}
// Attempt to locate the last attached user-file for this chat by inspecting the
// LM Studio conversation JSON (~/.lmstudio/conversations/<chatId>.conversation.json).
// Returns an array of absolute paths to original files (if found).
export async function findLastAttachmentFromConversation(chatWd: string, debug = false): Promise<string[]> {
try {
const chatId = findChatIdFromWd(chatWd);
if (!chatId) {
if (debug) console.warn("Unable to find chat id from workdir:", chatWd);
return [];
}
const homedir = os.homedir();
const convPath = path.join(homedir, ".lmstudio", "conversations", `${chatId}.conversation.json`);
const convExists = await fs.promises.stat(convPath).then(() => true).catch(() => false);
if (!convExists) {
if (debug) console.warn("Conversation file not found:", convPath);
return [];
}
const raw = await fs.promises.readFile(convPath, "utf8");
let parsed: any = null;
try { parsed = JSON.parse(raw); } catch (e) { if (debug) console.error("Failed to parse conversation JSON:", (e as Error).message); return []; }
// Try to locate messages array; if not present, scan root for text fields.
const messages = Array.isArray(parsed?.messages) ? parsed.messages : Array.isArray(parsed?.history) ? parsed.history : null;
const userMessages = Array.isArray(messages) ? messages : (function collectMsgs(obj: any) {
const out: any[] = [];
const rec = (v: any) => {
if (!v || typeof v !== 'object') return;
if (Array.isArray(v)) { for (const it of v) rec(it); return; }
if (typeof v.role === 'string' && v.role === 'user' && (typeof v.text === 'string' || v.content)) out.push(v);
for (const k of Object.keys(v)) rec(v[k]);
};
rec(obj);
return out;
})(parsed);
// Walk messages from last to first trying several heuristics to find attachment references
for (let i = userMessages.length - 1; i >= 0; i--) {
const msg = userMessages[i];
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '');
if (text && text.length) {
// First try existing wrapper format
try {
const parsedWr = parseAttachmentWrappers(text);
if (parsedWr && Array.isArray(parsedWr.parts) && parsedWr.parts.length) {
const resolved: string[] = [];
for (const p of parsedWr.parts) {
if (p.kind === 'image' && p.url) {
const fp = fileUriToPath(p.url) || (path.isAbsolute(p.url) ? p.url : null);
if (fp) {
const ok = await fs.promises.stat(fp).then(() => true).catch(() => false);
if (ok) resolved.push(fp);
}
}
}
if (resolved.length) return resolved;
}
} catch (e) { if (debug) console.error("Wrapper parse error:", (e as Error).message); }
// Fallback: search for references to user-files/<filename> (max 2)
try {
const re = /user-files\/(?:originals\/)?([A-Za-z0-9._-]+\.[A-Za-z0-9]{1,5})/g;
let m: RegExpExecArray | null;
const foundFiles: string[] = [];
while ((m = re.exec(text)) !== null) {
const fname = m[1];
const candidate = path.join(os.homedir(), '.lmstudio', 'user-files', fname);
const ok = await fs.promises.stat(candidate).then(() => true).catch(() => false);
if (ok) {
foundFiles.push(candidate);
if (foundFiles.length >= 2) break; // Max 2
}
}
if (foundFiles.length > 0) return foundFiles;
} catch (e) { if (debug) console.error("user-files regex error:", (e as Error).message); }
}
// Try fields on the message object that might point to attachment metadata
try {
const walker = (obj: any): string | null => {
if (!obj || typeof obj !== 'object') return null;
if (typeof obj.url === 'string' && obj.url.includes('/user-files')) {
const fp = fileUriToPath(obj.url) || (path.isAbsolute(obj.url) ? obj.url : null);
if (fp) return fp;
}
for (const k of Object.keys(obj)) {
const res = walker(obj[k]);
if (res) return res;
}
return null;
};
const maybe = walker(msg);
if (maybe) {
const ok = await fs.promises.stat(maybe).then(() => true).catch(() => false);
if (ok) return [maybe];
}
} catch (e) { if (debug) console.error("Message walk error:", (e as Error).message); }
}
return [];
} catch (e) {
if (debug) console.error("findLastAttachmentFromConversation error:", (e as Error).message);
return [];
}
}
// Read a conversation JSON file directly and extract the last attached filename(s).
// This is a robust JSON parser that applies several heuristics (wrappers, user-files
// references, embedded JSON, file:// URIs, and nested object walkers) and returns
// matching absolute paths where available or filenames otherwise.
export async function extractLastAttachmentsFromConversationFile(convPath: string, debug = false): Promise<string[]> {
try {
const ok = await fs.promises.stat(convPath).then(() => true).catch(() => false);
if (!ok) {
if (debug) console.warn('Conversation file not found:', convPath);
return [];
}
const raw = await fs.promises.readFile(convPath, 'utf8');
let parsed: any;
try { parsed = JSON.parse(raw); } catch (e) { if (debug) console.error('Failed to parse conversation JSON:', (e as Error).message); return []; }
// Locate messages array or collect user messages
const messages = Array.isArray(parsed?.messages) ? parsed.messages : Array.isArray(parsed?.history) ? parsed.history : null;
const userMessages = Array.isArray(messages) ? messages : (function collectMsgs(obj: any) {
const out: any[] = [];
const rec = (v: any) => {
if (!v || typeof v !== 'object') return;
if (Array.isArray(v)) { for (const it of v) rec(it); return; }
if (typeof v.role === 'string' && v.role === 'user' && (typeof v.text === 'string' || v.content)) out.push(v);
for (const k of Object.keys(v)) rec(v[k]);
};
rec(obj);
return out;
})(parsed);
// Scan backwards and look specifically for objects matching the compact file attachment shape:
// { type: "file", fileIdentifier: "<Dateiname>", fileType: "image", sizeBytes: ... }
// Collect up to 2 attachments from the last user message with attachments
for (let i = userMessages.length - 1; i >= 0; i--) {
const msg = userMessages[i];
const foundInMessage: string[] = [];
const findAll = (obj: any, results: any[] = []): any[] => {
if (!obj || typeof obj !== 'object') return results;
// arrays: scan elements
if (Array.isArray(obj)) {
for (const item of obj) {
try { findAll(item, results); } catch { }
}
return results;
}
// direct match
try {
if (obj.type === 'file' && obj.fileIdentifier && obj.fileType === 'image') {
results.push(obj);
if (results.length >= 2) return results; // Max 2
}
} catch { /* ignore */ }
// deep scan properties
const keys = Object.keys(obj);
for (const key of keys) {
try {
findAll(obj[key], results);
if (results.length >= 2) return results; // Max 2
} catch { }
}
return results;
};
try {
const foundObjects = findAll(msg, []);
for (const found of foundObjects) {
if (found.fileIdentifier) {
const fname = String(found.fileIdentifier);
const candidate = path.join(os.homedir(), '.lmstudio', 'user-files', fname);
const exists = await fs.promises.stat(candidate).then(() => true).catch(() => false);
if (exists) {
foundInMessage.push(candidate);
if (debug) console.info('Found user-file attachment:', candidate);
if (foundInMessage.length >= 2) break; // Max 2
} else {
if (debug) console.warn('Attachment referenced but not present in user-files:', fname);
}
}
}
if (foundInMessage.length > 0) return foundInMessage;
} catch (e) { if (debug) console.error('Message walk error:', (e as Error).message); }
}
return [];
} catch (e) {
if (debug) console.error('extractLastAttachmentsFromConversationFile error:', (e as Error).message);
return [];
}
}
// Copy a resolved attachment into the chat working directory with a stable name
export async function copyAttachmentToWorkdir(srcAbs: string, chatWd: string, debug = false): Promise<string | null> {
const res = await copyAttachmentToWorkdirDetailed(srcAbs, chatWd, debug);
return res ? res.path : null;
}
export async function copyAttachmentToWorkdirDetailed(srcAbs: string, chatWd: string, debug = false): Promise<{ path: string; created: boolean } | null> {
try {
// Check if a file with the same content already exists to avoid duplicates
const dirents = await fs.promises.readdir(chatWd);
const srcStat = await fs.promises.stat(srcAbs);
const srcSize = srcStat.size;
for (const f of dirents) {
if (/^attachment-image-/.test(f)) {
const dstPath = path.join(chatWd, f);
try {
const dstStat = await fs.promises.stat(dstPath);
if (dstStat.size === srcSize) {
// As a simple heuristic, if sizes match, assume it's the same file.
// A more robust check would involve hashing the file content.
if (debug) console.info('Attachment already exists in workdir:', dstPath);
return { path: dstPath, created: false };
}
} catch (e) {
// Ignore errors for individual file stats
}
}
}
const ext = path.extname(srcAbs) || '.png';
const iso = toIsoLikeTimestamp(new Date());
// Try v1, v2... to avoid collisions
for (let v = 1; v < 10; v++) {
const name = `attachment-image-${iso}-v${v}${ext}`;
const dst = path.join(chatWd, name);
const exists = await fs.promises.stat(dst).then(() => true).catch(() => false);
if (!exists) {
// copy original source (it's already local) -> use copyFile
await copyFile(srcAbs, dst);
if (debug) console.info('Copied attachment to workdir:', dst);
return { path: dst, created: true };
}
}
if (debug) console.warn('Failed to create unique attachment filename in workdir');
return null;
} catch (e) {
if (debug) console.error('copyAttachmentToWorkdir error:', (e as Error).message);
return null;
}
}
// Minimal parser to reuse existing generator helper
function parseAttachmentWrappers(
text: string,
): { text: string; parts: Array<{ kind: "image" | "text" | "text_link"; url?: string; text?: string }> } {
const parts: Array<{ kind: "image" | "text" | "text_link"; url?: string; text?: string }> = [];
if (!text) return { text, parts };
const re = /\[\[LMSTUDIO_ATTACHMENT:\s*(\{[\s\S]*?\})\s*\]\]/g;
let cleaned = text;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
try {
const obj = JSON.parse(m[1]);
if (obj && obj.kind === "image" && typeof obj.url === "string") {
parts.push({ kind: "image", url: obj.url });
} else if (obj && obj.kind === "text" && typeof obj.text === "string") {
parts.push({ kind: "text", text: obj.text });
} else if (obj && obj.kind === "text_link" && typeof obj.url === "string") {
parts.push({ kind: "text_link", url: obj.url });
}
cleaned = cleaned.replace(m[0], "");
} catch {
// ignore malformed wrappers
}
}
return { text: cleaned, parts };
}
// ============================================================================
// findAllAttachmentsFromLastTurn - prefer pending clientInputFiles, else latest user message
// Based on: instructions/standalone_generator_guide/src/orchestrator.ts
// ============================================================================
/**
* Scan conversation.json for attachments that are relevant "now":
* 1) clientInputFiles (pending attachments before message is sent)
* 2) attachments from the most recent user message
*
* IMPORTANT: This intentionally does NOT accumulate attachments from older turns.
* Downstream state keeps attachments across text-only turns; this function is
* only responsible for detecting the current attachment set.
*/
export async function findAllAttachmentsFromLastTurn(
chatWd: string | undefined,
debug = false
): Promise<string[]> {
if (!chatWd) return [];
const chatId = findChatIdFromWd(chatWd);
const lmHome = findLMStudioHome();
const conversationsDir = path.join(lmHome, "conversations");
const userFilesDir = path.join(lmHome, "user-files");
// Try multiple candidate paths
const candidates = [
chatId ? path.join(conversationsDir, `${chatId}.conversation.json`) : null,
path.join(chatWd, ".conversation.json"),
path.join(chatWd, "conversation.json"),
].filter((p): p is string => p !== null);
for (const convPath of candidates) {
if (!(await pathExists(convPath))) continue;
try {
const raw = await fs.promises.readFile(convPath, "utf-8");
const json = JSON.parse(raw);
const normalizeMaybeFileUri = (p: string): string => {
const asPath = fileUriToPath(p);
if (asPath) return asPath;
return p;
};
const tryExtractClientInputFiles = (root: any): string[] | null => {
try {
const files = (root as any)?.clientInputFiles;
if (!Array.isArray(files) || files.length === 0) return null;
const found: string[] = [];
for (const f of files) {
const id = (f as any)?.fileIdentifier;
const type = (f as any)?.fileType;
if (typeof id === "string" && id.trim() && type === "image") {
found.push(path.join(userFilesDir, id));
}
}
return found.length > 0 ? found : null;
} catch {
return null;
}
};
// Priority 0: clientInputFiles can appear on root or nested objects.
const fromClientInput =
tryExtractClientInputFiles(json) ??
tryExtractClientInputFiles((json as any)?.conversation) ??
tryExtractClientInputFiles((json as any)?.chat) ??
tryExtractClientInputFiles((json as any)?.state);
if (fromClientInput && fromClientInput.length > 0) {
const unique: string[] = [];
const seen = new Set<string>();
for (const f of fromClientInput) {
const n = path.resolve(normalizeMaybeFileUri(f));
if (seen.has(n)) continue;
seen.add(n);
unique.push(n);
}
if (debug) console.info(`[findAllAttachments] Using clientInputFiles (pending): ${unique.length} item(s)`);
return unique;
}
// Priority 1: attachments from the most recent USER message.
const tryExtractMostRecentUserMessageAttachments = (root: any): string[] | null => {
const candidateArrays: any[] = [];
const maybePushArray = (a: any) => { if (Array.isArray(a) && a.length) candidateArrays.push(a); };
try {
maybePushArray((root as any)?.messages);
maybePushArray((root as any)?.conversation?.messages);
maybePushArray((root as any)?.chat?.messages);
maybePushArray((root as any)?.history);
maybePushArray((root as any)?.turns);
maybePushArray((root as any)?.items);
} catch { /* ignore */ }
for (const arr of candidateArrays) {
for (let i = arr.length - 1; i >= 0; i--) {
const m = arr[i];
if (!m || typeof m !== "object") continue;
// Unwrap LM Studio versioned messages
let msgObj: any = m;
try {
const versions = Array.isArray((m as any).versions) ? (m as any).versions : null;
const selRaw = (m as any).currentlySelected;
const sel = typeof selRaw === "number" && Number.isFinite(selRaw) ? selRaw : 0;
if (versions && versions.length) {
msgObj = sel >= 0 && sel < versions.length ? versions[sel] : versions[versions.length - 1];
}
} catch { /* ignore */ }
const role = (msgObj as any).role ?? (msgObj as any).author ?? (msgObj as any).sender;
const type = (msgObj as any).type ?? (msgObj as any).messageType;
const isUser = role === "user" || role === "human" || type === "user" || type === "user_message";
if (!isUser) continue;
const found: string[] = [];
try {
const content = (msgObj as any)?.content;
if (Array.isArray(content)) {
for (const part of content) collectImageFileCandidates(part, found, userFilesDir);
} else {
collectImageFileCandidates(msgObj, found, userFilesDir);
}
} catch {
collectImageFileCandidates(msgObj, found, userFilesDir);
}
if (!found.length) {
// Most recent user message has no attachments; do not fall back to older turns.
return [];
}
const seen = new Set<string>();
const unique: string[] = [];
for (const f of found) {
const n = path.resolve(normalizeMaybeFileUri(f));
if (seen.has(n)) continue;
seen.add(n);
unique.push(n);
}
return unique;
}
}
return null;
};
const fromLatestUserMsg = tryExtractMostRecentUserMessageAttachments(json);
if (fromLatestUserMsg !== null) {
if (debug) console.info(`[findAllAttachments] Using latest user-message attachments: ${fromLatestUserMsg.length} item(s)`);
return fromLatestUserMsg;
}
// Last resort: deep scan entire JSON
const allFound: string[] = [];
collectImageFileCandidates(json, allFound, userFilesDir);
const seen = new Set<string>();
const unique: string[] = [];
for (const f of allFound) {
const n = path.resolve(normalizeMaybeFileUri(f));
if (seen.has(n)) continue;
seen.add(n);
unique.push(n);
}
if (debug && unique.length > 0) {
console.info(`[findAllAttachments] Fallback deep-scan found ${unique.length} attachment(s) in ${convPath}`);
}
return unique;
} catch (e) {
if (debug) console.warn(`[findAllAttachments] Failed to parse ${convPath}:`, (e as Error).message);
}
}
return [];
}
function collectImageFileCandidates(obj: any, found: string[], userFilesDir: string): void {
if (!obj || typeof obj !== "object") return;
// Various structures LM Studio uses
const fileId = obj.fileIdentifier ?? obj.identifier ?? obj.file_id;
if (typeof fileId === "string" && fileId.trim()) {
found.push(path.join(userFilesDir, fileId));
return;
}
// Recurse into nested objects
for (const v of Object.values(obj)) {
if (Array.isArray(v)) {
for (const item of v) collectImageFileCandidates(item, found, userFilesDir);
} else if (typeof v === "object" && v !== null) {
collectImageFileCandidates(v, found, userFilesDir);
}
}
}
// ============================================================================
// findAllAttachmentsFromConversation - ALL attachments from entire history
// ============================================================================
/**
* Scan conversation.json for ALL attachments ever added to the chat.
* Returns attachments in chronological order (as they appear in conversation.json).
*
* This is different from findAllAttachmentsFromLastTurn which only looks at
* the latest user message or clientInputFiles.
*
* Use this function to build a complete inventory of all attachments in chat_media_state.json.
*
* Sources scanned (in order):
* 1. clientInputFiles (pending attachments in draft)
* 2. All user messages with file attachments (chronological order)
*
* @returns Array of absolute paths to user-files, in order of first appearance
*/
export async function findAllAttachmentsFromConversation(
chatWd: string | undefined,
debug = false
): Promise<string[]> {
if (!chatWd) return [];
const chatId = findChatIdFromWd(chatWd);
const lmHome = findLMStudioHome();
const conversationsDir = path.join(lmHome, "conversations");
const userFilesDir = path.join(lmHome, "user-files");
const candidates = [
chatId ? path.join(conversationsDir, `${chatId}.conversation.json`) : null,
path.join(chatWd, ".conversation.json"),
path.join(chatWd, "conversation.json"),
].filter((p): p is string => p !== null);
for (const convPath of candidates) {
if (!(await pathExists(convPath))) continue;
try {
const raw = await fs.promises.readFile(convPath, "utf-8");
const json = JSON.parse(raw);
const normalizeMaybeFileUri = (p: string): string => {
const asPath = fileUriToPath(p);
if (asPath) return asPath;
return p;
};
const allFound: string[] = [];
const seenPaths = new Set<string>();
const addUnique = (p: string) => {
const normalized = path.resolve(normalizeMaybeFileUri(p));
if (!seenPaths.has(normalized)) {
seenPaths.add(normalized);
allFound.push(normalized);
}
};
// Helper to extract image file candidates from an object
const collectFromObj = (obj: any): void => {
if (!obj || typeof obj !== "object") return;
// Check for file attachment patterns
const fileId = obj.fileIdentifier ?? obj.identifier ?? obj.file_id;
const fileType = obj.fileType ?? obj.type;
if (typeof fileId === "string" && fileId.trim() && fileType === "image") {
addUnique(path.join(userFilesDir, fileId));
return;
}
// Recurse into nested objects
for (const v of Object.values(obj)) {
if (Array.isArray(v)) {
for (const item of v) collectFromObj(item);
} else if (typeof v === "object" && v !== null) {
collectFromObj(v);
}
}
};
// 1. Collect from clientInputFiles (pending/draft attachments)
const tryClientInputFiles = (root: any): void => {
const files = root?.clientInputFiles;
if (Array.isArray(files)) {
for (const f of files) {
const id = f?.fileIdentifier;
const type = f?.fileType;
if (typeof id === "string" && id.trim() && type === "image") {
addUnique(path.join(userFilesDir, id));
}
}
}
};
tryClientInputFiles(json);
tryClientInputFiles(json?.conversation);
tryClientInputFiles(json?.chat);
tryClientInputFiles(json?.state);
// 2. Collect from ALL messages (chronological order)
const messageArrays: any[] = [];
const maybeAddArray = (a: any) => {
if (Array.isArray(a) && a.length > 0) messageArrays.push(a);
};
maybeAddArray(json?.messages);
maybeAddArray(json?.conversation?.messages);
maybeAddArray(json?.chat?.messages);
maybeAddArray(json?.history);
maybeAddArray(json?.turns);
maybeAddArray(json?.items);
for (const messages of messageArrays) {
// Process messages in chronological order (first to last)
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
// Unwrap LM Studio versioned messages
let msgObj: any = msg;
try {
const versions = Array.isArray(msg?.versions) ? msg.versions : null;
const selRaw = msg?.currentlySelected;
const sel = typeof selRaw === "number" && Number.isFinite(selRaw) ? selRaw : 0;
if (versions && versions.length) {
msgObj = sel >= 0 && sel < versions.length ? versions[sel] : versions[versions.length - 1];
}
} catch { /* ignore */ }
// Check if this is a user message
const role = msgObj?.role ?? msgObj?.author ?? msgObj?.sender;
const type = msgObj?.type ?? msgObj?.messageType;
const isUser = role === "user" || role === "human" || type === "user" || type === "user_message";
if (!isUser) continue;
// Collect attachments from this user message
try {
const content = msgObj?.content;
if (Array.isArray(content)) {
for (const part of content) collectFromObj(part);
} else {
collectFromObj(msgObj);
}
} catch {
collectFromObj(msgObj);
}
}
}
if (debug && allFound.length > 0) {
console.info(`[findAllAttachmentsComplete] Found ${allFound.length} attachment(s) in ${convPath}`);
}
return allFound;
} catch (e) {
if (debug) console.warn(`[findAllAttachmentsComplete] Failed to parse ${convPath}:`, (e as Error).message);
}
}
return [];
}
// ============================================================================
// NEW: getOriginalFileName - resolve LM Studio metadata for original filename
// ============================================================================
/**
* Resolve the original filename from LM Studio user-files metadata.
* LM Studio stores metadata in <userFilesDir>/<fileIdentifier>.meta.json
*/
export async function getOriginalFileName(fileIdentifier: string): Promise<string | undefined> {
const lmHome = findLMStudioHome();
const metaCandidates = [
path.join(lmHome, "user-files", `${fileIdentifier}.metadata.json`),
path.join(lmHome, "user-files", `${fileIdentifier}.meta.json`),
];
try {
for (const p of metaCandidates) {
try {
const raw = await fs.promises.readFile(p, "utf-8");
const meta = JSON.parse(raw);
const resolved = meta?.originalName ?? meta?.name ?? meta?.fileName ?? meta?.filename ?? undefined;
if (typeof resolved === "string" && resolved.trim().length > 0) return resolved;
} catch {
// keep trying candidates
}
}
return undefined;
} catch {
// No metadata file - fall back to fileIdentifier as name
return undefined;
}
}
// ============================================================================
// NEW: importAttachmentBatch - stable n-numbering, idempotent, no copies
// Based on: standalone_generator_guide/src/media-promotion-core/attachments.ts
// ============================================================================
export type PreviewOptions = {
maxDim?: number;
quality?: number;
};
/**
* Batch-import attachments from SSOT (conversation.json).
*
* Key behaviors:
* - Replaces entire attachments array with exactly what's in SSOT
* - Preserves n-values for existing attachments (stable numbering)
* - At empty state, n starts at 1
* - Empty SSOT → keeps existing state (attachments persist across text-only turns)
* - Only generates previews, NO copies of originals
* - Preview naming: preview-<origin> (e.g., preview-1766100380042 - 811.jpg)
*/
export async function importAttachmentBatch(
chatWd: string,
state: ChatMediaState,
sourcePaths: string[],
previewOpts: PreviewOptions = { maxDim: 1024, quality: 85 },
maxPreviewAttachments = 2,
debug = false
): Promise<{ changed: boolean }> {
const markerPath = path.join(chatWd, "attachment-i2i-pending.json");
const normalizeAbs = (p: string) => {
try { return path.resolve(p); } catch { return p; }
};
const normalizedSource = sourcePaths
.filter((p) => typeof p === "string" && p.trim().length > 0)
.map(normalizeAbs);
// De-dupe while preserving order
const normalizedSourceDeduped: string[] = [];
{
const seen = new Set<string>();
for (const p of normalizedSource) {
if (!seen.has(p)) {
seen.add(p);
normalizedSourceDeduped.push(p);
}
}
}
// CASE 1: No NEW attachments in this turn → keep existing state
// Attachments persist across text-only turns until replaced by new attachments.
if (normalizedSourceDeduped.length === 0) {
if (debug) console.info("[importAttachmentBatch] No new attachments in current turn; keeping existing state.");
return { changed: false };
}
// Idempotence: if SSOT paths match current state, skip re-import
try {
const current = Array.isArray(state.attachments) ? state.attachments : [];
const currentOrigins = current
.map((a) => a && typeof a.originAbs === "string" ? a.originAbs : "")
.filter((p) => p.trim().length > 0)
.map(normalizeAbs);
const same =
currentOrigins.length === normalizedSourceDeduped.length &&
currentOrigins.every((p, i) => p === normalizedSourceDeduped[i]);
if (same && current.length > 0) {
const shouldHavePreview = (i: number) => {
const total = currentOrigins.length;
const cap = Math.max(0, Math.floor(maxPreviewAttachments));
return cap > 0 && i >= Math.max(0, total - cap);
};
// Check if previews exist (for LAST maxPreviewAttachments)
let allOk = true;
for (let i = 0; i < current.length; i++) {
if (!shouldHavePreview(i)) continue;
const a = current[i];
const pv = a && typeof a.preview === "string" ? a.preview : "";
if (!pv) { allOk = false; break; }
const pvAbs = path.join(chatWd, pv);
if (!(await pathExists(pvAbs))) { allOk = false; break; }
}
if (allOk) {
if (debug) console.info("[importAttachmentBatch] SSOT matches current state; skipping re-import (idempotent).");
return { changed: false };
}
}
} catch (e) {
if (debug) console.warn("[importAttachmentBatch] Idempotence check failed; continuing with import:", (e as Error).message);
}
// CASE 2: SSOT has attachments → Import ALL into state + pending
// Build a map of existing attachments by originAbs for stable numbering
const existingByOrigin = new Map<string, AttachmentItem>();
for (const a of state.attachments || []) {
if (a && typeof a.originAbs === "string") {
existingByOrigin.set(normalizeAbs(a.originAbs), a);
}
}
const imported: AttachmentItem[] = [];
const pendingPaths: string[] = [];
// CRITICAL: If no existing attachments, reset n to 1 for clean numbering
let nextN = existingByOrigin.size === 0
? 1
: Math.max(1, state.counters?.nextAttachmentN ?? 1);
const shouldHavePreview = (i: number) => {
const total = normalizedSourceDeduped.length;
const cap = Math.max(0, Math.floor(maxPreviewAttachments));
return cap > 0 && i >= Math.max(0, total - cap);
};
for (let i = 0; i < normalizedSourceDeduped.length; i++) {
const abs = normalizedSourceDeduped[i];
if (!(await pathExists(abs))) {
if (debug) console.warn(`[importAttachmentBatch] Source not found, skipping: ${abs}`);
continue;
}
// Check if this attachment already exists (preserve its n)
const existing = existingByOrigin.get(normalizeAbs(abs));
if (existing) {
// Keep existing record (preserve n, createdAt, etc.)
// Ensure preview is present for LAST maxPreviewAttachments and absent for others.
// Refresh originalName if it was previously unknown / looked like fileIdentifier.
try {
const fileIdentifier = path.basename(abs);
if (!existing.originalName || existing.originalName === existing.origin) {
const resolvedName = await getOriginalFileName(fileIdentifier);
if (resolvedName) existing.originalName = resolvedName;
}
} catch { /* best-effort */ }
if (shouldHavePreview(i)) {
const desiredPreviewName = `preview-${path.basename(abs)}`;
if (!existing.preview) {
existing.preview = desiredPreviewName;
}
const previewAbs = path.join(chatWd, existing.preview);
if (!(await pathExists(previewAbs))) {
if (debug) console.info(`[importAttachmentBatch] Generating/regenerating preview for n=${existing.n}`);
await encodeJpegPreview(abs, previewAbs, previewOpts);
}
} else {
// Metadata-only attachments should not carry preview in state.
existing.preview = undefined;
}
imported.push(existing);
pendingPaths.push(abs);
if (debug) console.info(`[importAttachmentBatch] Keeping existing attachment n=${existing.n}: ${path.basename(abs)}`);
continue;
}
// New attachment - assign next n
const origin = path.basename(abs);
// Generate preview (NO copy of original!)
let previewName: string | undefined = undefined;
if (shouldHavePreview(i)) {
previewName = `preview-${origin}`;
const previewAbs = path.join(chatWd, previewName);
await encodeJpegPreview(abs, previewAbs, previewOpts);
}
// Resolve originalName from LM Studio metadata
let originalName: string | undefined = undefined;
const userFilesDir = path.join(findLMStudioHome(), "user-files");
if (abs.startsWith(userFilesDir)) {
const fileIdentifier = path.basename(abs);
const resolvedName = await getOriginalFileName(fileIdentifier);
originalName = resolvedName || fileIdentifier;
if (debug && resolvedName) {
console.info(`[importAttachmentBatch] Resolved original filename: ${fileIdentifier} → ${originalName}`);
}
} else {
// Non-LM Studio attachments: use basename as original name
originalName = path.basename(abs);
}
imported.push({
filename: undefined, // NO copy - originAbs is the source
origin,
originAbs: abs,
originalName,
preview: previewName,
createdAt: new Date().toISOString(),
n: nextN++,
});
pendingPaths.push(abs);
}
if (imported.length === 0) {
if (debug) console.warn("[importAttachmentBatch] No valid attachments imported");
return { changed: false };
}
// MERGE: Keep existing attachments that are NOT in the new SSOT, then append new ones
// This preserves attachment history across turns (Rolling Window only limits promotion, not state)
const newOriginSet = new Set(imported.map(a => normalizeAbs(a.originAbs || "")));
const existingToKeep = (state.attachments || []).filter(a => {
const origin = a && typeof a.originAbs === "string" ? normalizeAbs(a.originAbs) : "";
return origin && !newOriginSet.has(origin);
});
// Combine: existing attachments (not in new set) + newly imported, then SORT by n-value
const merged = [...existingToKeep, ...imported];
merged.sort((a, b) => (a.n ?? 0) - (b.n ?? 0));
state.attachments = merged;
state.counters = state.counters || {};
state.counters.nextAttachmentN = nextN;
state.lastEvent = { type: "attachment", at: new Date().toISOString() };
// NOTE: Preserve idempotency tracking fields (lastPromotedAttachmentN, lastPromotedTs, lastVariantsTs)
// These are set by markAsPromoted() and must survive import cycles
if (debug) console.info(`[importAttachmentBatch] State now has ${state.attachments.length} attachment(s): ${existingToKeep.length} kept + ${imported.length} new/updated`);
// Write pending.json with ALL imported paths
const pending = {
files: pendingPaths,
createdAt: new Date().toISOString(),
usedAt: "",
consumed: false,
};
await fs.promises.mkdir(chatWd, { recursive: true });
await fs.promises.writeFile(markerPath, JSON.stringify(pending, null, 2), "utf-8");
await writeChatMediaStateAtomic(chatWd, state);
if (debug) console.info(`[importAttachmentBatch] Imported ${imported.length} attachment(s) from SSOT`);
return { changed: true };
}
/**
* Generate JPEG preview from source image
*/
async function encodeJpegPreview(srcAbs: string, dstAbs: string, opts: PreviewOptions): Promise<void> {
const maxDim = opts.maxDim ?? 1024;
const quality = opts.quality ?? 85;
const jpeg = await resizeMaxDimJpegFromFile(srcAbs, maxDim, quality);
await fs.promises.writeFile(dstAbs, jpeg);
}
// ============================================================================
// NEW: synchronizeAttachmentInventory - full inventory sync with stable n
// ============================================================================
export type SyncResult = {
changed: boolean;
added: number;
removed: number;
kept: number;
totalNow: number;
promotedNs: number[]; // The n-values of attachments that will be visually promoted (last 2)
};
/**
* Synchronize the attachment inventory with the SSOT (conversation.json).
*
* This function ensures chat_media_state.json reflects ALL attachments currently
* present in conversation.json, with stable n-numbering:
*
* - New attachments get the next available n
* - Existing attachments keep their n forever (even after gaps from deletions)
* - Deleted attachments are removed from the array
* - Only the LAST 2 attachments get visual promotion (preview + base64)
* - All other attachments are listed with metadata only (n, originalName)
*
* @param chatWd - Chat working directory
* @param state - Current ChatMediaState (will be mutated)
* @param ssotPaths - Complete list of attachment paths from conversation.json (use findAllAttachmentsFromConversation)
* @param previewOpts - Preview generation options
* @param maxPreviewAttachments - How many attachments to generate previews for (default 2, the last N)
* @param debug - Enable debug logging
*/
export async function synchronizeAttachmentInventory(
chatWd: string,
state: ChatMediaState,
ssotPaths: string[],
previewOpts: PreviewOptions = { maxDim: 1024, quality: 85 },
maxPreviewAttachments = 2,
debug = false
): Promise<SyncResult> {
const normalizeAbs = (p: string) => {
try { return path.resolve(p); } catch { return p; }
};
// Normalize and dedupe SSOT paths while preserving order
const ssotNormalized: string[] = [];
{
const seen = new Set<string>();
for (const p of ssotPaths) {
if (typeof p !== "string" || !p.trim()) continue;
const norm = normalizeAbs(p);
if (!seen.has(norm)) {
seen.add(norm);
ssotNormalized.push(norm);
}
}
}
// Build map of existing attachments by originAbs for stable n lookup
const existingByOrigin = new Map<string, AttachmentItem>();
for (const a of state.attachments || []) {
if (a && typeof a.originAbs === "string") {
existingByOrigin.set(normalizeAbs(a.originAbs), a);
}
}
// Determine highest existing n to continue numbering
const existingNs = (state.attachments || []).map((a) => a.n ?? 0);
const maxExistingN = existingNs.length > 0 ? Math.max(0, ...existingNs) : 0;
let nextN = Math.max(1, state.counters?.nextAttachmentN ?? (maxExistingN + 1));
// Which indices should have previews? The LAST maxPreviewAttachments
const shouldHavePreview = (i: number) => {
const cap = Math.max(0, Math.floor(maxPreviewAttachments));
return cap > 0 && i >= Math.max(0, ssotNormalized.length - cap);
};
const synced: AttachmentItem[] = [];
let added = 0;
let kept = 0;
for (let i = 0; i < ssotNormalized.length; i++) {
const abs = ssotNormalized[i];
if (!(await pathExists(abs))) {
if (debug) console.warn(`[syncInventory] Source not found, skipping: ${abs}`);
continue;
}
const existing = existingByOrigin.get(abs);
if (existing) {
// --- KEEP existing attachment, preserve n ---
kept++;
// Refresh originalName if needed
try {
const fileIdentifier = path.basename(abs);
if (!existing.originalName || existing.originalName === existing.origin) {
const resolvedName = await getOriginalFileName(fileIdentifier);
if (resolvedName) existing.originalName = resolvedName;
}
} catch { /* best-effort */ }
// Manage preview based on position
if (shouldHavePreview(i)) {
const desiredPreviewName = `preview-${path.basename(abs)}`;
if (!existing.preview) existing.preview = desiredPreviewName;
const previewAbs = path.join(chatWd, existing.preview);
if (!(await pathExists(previewAbs))) {
if (debug) console.info(`[syncInventory] Generating preview for n=${existing.n}`);
await encodeJpegPreview(abs, previewAbs, previewOpts);
}
} else {
// Not in visual promotion range - remove preview reference
existing.preview = undefined;
}
synced.push(existing);
if (debug) console.info(`[syncInventory] Kept n=${existing.n}: ${existing.originalName || path.basename(abs)}`);
} else {
// --- NEW attachment - assign next n ---
added++;
const origin = path.basename(abs);
// Resolve originalName
let originalName: string | undefined = undefined;
const userFilesDir = path.join(findLMStudioHome(), "user-files");
if (abs.startsWith(userFilesDir)) {
const fileIdentifier = path.basename(abs);
const resolvedName = await getOriginalFileName(fileIdentifier);
originalName = resolvedName || fileIdentifier;
} else {
originalName = path.basename(abs);
}
// Generate preview only for visual promotion
let previewName: string | undefined = undefined;
if (shouldHavePreview(i)) {
previewName = `preview-${origin}`;
const previewAbs = path.join(chatWd, previewName);
await encodeJpegPreview(abs, previewAbs, previewOpts);
}
const newItem: AttachmentItem = {
filename: undefined,
origin,
originAbs: abs,
originalName,
preview: previewName,
createdAt: new Date().toISOString(),
n: nextN++,
};
synced.push(newItem);
if (debug) console.info(`[syncInventory] Added n=${newItem.n}: ${originalName}`);
}
}
// Calculate removed count
const removed = existingByOrigin.size - kept;
// Sort by n for consistent state
synced.sort((a, b) => (a.n ?? 0) - (b.n ?? 0));
// Determine which n-values will be visually promoted
const promotedNs = synced.slice(-Math.min(maxPreviewAttachments, synced.length)).map((a) => a.n);
// Update state
state.attachments = synced;
state.counters = state.counters || {};
state.counters.nextAttachmentN = nextN;
state.lastEvent = { type: "attachment", at: new Date().toISOString() };
const changed = added > 0 || removed > 0;
if (changed) {
await writeChatMediaStateAtomic(chatWd, state);
if (debug) {
console.info(`[syncInventory] Sync complete: +${added} -${removed} =${kept} total=${synced.length} promotedNs=[${promotedNs.join(",")}]`);
}
} else {
if (debug) console.info(`[syncInventory] No changes detected.`);
}
return {
changed,
added,
removed,
kept,
totalNow: synced.length,
promotedNs,
};
}