Project Files
src / indexer.ts
/**
* Indexer - Shared indexing logic for preprocessor and tools
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
import { readFile } from "fs/promises";
import { createHash } from "crypto";
import { glob } from "glob";
import path from "path";
import { homedir } from "os";
import { fileURLToPath } from "url";
import type { ToolsProviderController, PromptPreprocessorController } from "@lmstudio/sdk";
import { configSchematics, globalConfigSchematics, MULTIMODAL_MAX_NEW_EMBEDDINGS_UNLIMITED } from "./config.js";
import { PngMetadataParser } from "./documents/parsers/pngMetadataParser.js";
import { SUPPORTED_IMAGE_GLOB } from "./documents/imageFormats.js";
import { drawthingsLimits, getSelfPluginIdentifier, getThumbnailsFromProject, readCameraImageMetadata } from "./core-bundle.mjs";
import { parseDrawThingsProjectMetadataOnly, parseProjectUri } from "./documents/parsers/projectFileParser.js";
import { isHarvestedProjectResultPath, watchImages, watchProjects } from "./documents/fileWatcher.js";
import { appendEmbeddingLogLine } from "./llama-server-manager.js";
import {
MultimodalEmbeddingClient,
MultimodalEmbeddingStore,
embeddingFingerprint,
fileFingerprint,
multimodalModelIdentity,
projectThumbnailFingerprint,
resolveMultimodalEmbeddingBackend,
} from "./embeddings/index.js";
import { selectEmbeddingCandidates } from "./embeddings/embeddingCandidateScheduler.js";
import { renderEmbeddingMetadataText } from "./embeddings/embeddingMetadata.js";
import { generatePreviewPayload, shouldUsePreviewPayload } from "./media/previewPayload.js";
import type { MultimodalEmbeddingBackendKind } from "./embeddings/index.js";
import {
FIND_IMAGE_USER_FILES_ROOT,
FIND_IMAGE_WORKING_DIRECTORIES_ROOT,
findImageDataPath,
legacyPluginDataPath,
migrateLegacyFileIfMissing,
sanitizeConfigPath,
} from "./paths.js";
import { resolvePluginStringSetting } from "./pluginSettings.js";
import type { GenerationMetadata, IndexedGeneration, SourceInfo } from "./types.js";
import type { ParsedImageMetadata } from "./documents/parsers/pngMetadataParser.js";
// ═══════════════════════════════════════════════════════════════
// Cache & Persistent Store
// ═══════════════════════════════════════════════════════════════
let cachedGenerations: IndexedGeneration[] | null = null;
let lastCompletedGenerations: IndexedGeneration[] | null = null;
let isIndexing = false;
let cacheRevision = 0;
let multimodalEmbeddingClient: MultimodalEmbeddingClient | null = null;
let multimodalEmbeddingStore: MultimodalEmbeddingStore | null = null;
let multimodalEmbeddingStoreDbPath: string | null = null;
// ═══════════════════════════════════════════════════════════════
// Indexing
// ═══════════════════════════════════════════════════════════════
type ConfigController = ToolsProviderController | PromptPreprocessorController;
type PluginConfig = ReturnType<ConfigController["getGlobalPluginConfig"]>;
const IMAGE_GLOB = SUPPORTED_IMAGE_GLOB;
const LMSTUDIO_CONVERSATIONS = path.join(homedir(), ".lmstudio", "conversations");
const CHAT_MEDIA_STATE_FILE = "chat_media_state.json";
const FALLBACK_SELF_PLUGIN_IDENTIFIER = "ceveyne/find-image";
const OTHER_IMAGE_INDEX_PLUGIN_IDENTIFIER = "ceveyne/draw-things-index";
const GENERATION_INDEX_CACHE_VERSION = 4;
const GENERATION_INDEX_CACHE_DERIVATION_VERSION = "v4-png-xmp-create-date";
export const GENERATION_INDEX_CACHE_PATH = findImageDataPath("generation_index_cache.json");
const LEGACY_GENERATION_INDEX_CACHE_PATH = legacyPluginDataPath("generation_index_cache.json");
let ownedWorkingDirectoryChatIds: Set<string> | null = null;
interface GenerationIndexCacheEntry {
fingerprint: string;
generations: GenerationMetadata[];
}
interface GenerationIndexCacheFile {
version: number;
entries: Record<string, GenerationIndexCacheEntry>;
}
let generationIndexCache: GenerationIndexCacheFile | null = null;
/** Progress callback for status updates (can be async) */
export type IndexProgressCallback = (message: string) => void | Promise<void>;
export class IndexingAbortedError extends Error {
constructor() {
super("Indexing cancelled.");
this.name = "AbortError";
}
}
function throwIfIndexingAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new IndexingAbortedError();
}
function isIndexingAbort(error: unknown): boolean {
return error instanceof IndexingAbortedError || (error instanceof Error && error.name === "AbortError");
}
export interface MultimodalEmbeddingRunSummary {
cached: number;
embedded: number;
skipped: Array<{ imageRef: string; reason: string }>;
deferred: number;
deletedStaleFilesystemRows: number;
}
export type EmbeddingRunMode = "query" | "manual-refresh";
export interface IndexGenerationsOptions {
embeddingMode?: EmbeddingRunMode;
}
export async function ensureMultimodalEmbeddingsForGenerations(
config: PluginConfig,
indexedGenerations: IndexedGeneration[],
maxNewEmbeddings: number,
onProgress?: IndexProgressCallback,
abortSignal?: AbortSignal,
options: IndexGenerationsOptions = {},
): Promise<MultimodalEmbeddingRunSummary | null> {
throwIfIndexingAborted(abortSignal);
const multimodalEnabled = config.get("multimodalSearchEnabled");
const multimodalDimension = config.get("multimodalEmbeddingDimension");
const multimodalContextSize = config.get("multimodalEmbeddingContextSize");
const multimodalModelPath = sanitizeConfigPath(config.get("multimodalEmbeddingModelPath"));
const multimodalGgufBinaryPath = sanitizeConfigPath(resolvePluginStringSetting(
config.get("multimodalEmbeddingGgufBinaryPath"),
"multimodalEmbeddingGgufBinaryPath",
));
const multimodalGgufGpuLayers = config.get("multimodalEmbeddingGgufGpuLayers");
const multimodalModel = config.get("multimodalEmbeddingModel");
const multimodalModelKey = multimodalModelIdentity(multimodalModelPath, multimodalModel);
const usePreviewsForQueryEmbedding = config.get("usePreviewsForQueryEmbedding");
const multimodalEmbeddingDataStorePath = sanitizeConfigPath(config.get("multimodalEmbeddingDataStorePath"));
const multimodalBackend = config.get("multimodalEmbeddingBackend") as MultimodalEmbeddingBackendKind;
const resolvedMultimodalBackend = resolveMultimodalEmbeddingBackend(multimodalBackend, multimodalModelPath);
const isGgufBackend = resolvedMultimodalBackend === "integrated-local-gguf";
let deletedStaleFilesystemRows = 0;
if (multimodalEnabled && multimodalModelKey) {
try {
await onProgress?.("Opening multimodal embedding store...");
const expectedDbPath = isGgufBackend ? multimodalEmbeddingDataStorePath : null; // null = MultimodalEmbeddingStore's own default (unused by any implemented backend)
if (!multimodalEmbeddingStore || multimodalEmbeddingStoreDbPath !== expectedDbPath) {
multimodalEmbeddingStore = new MultimodalEmbeddingStore(isGgufBackend ? { dbPath: multimodalEmbeddingDataStorePath } : {});
multimodalEmbeddingStoreDbPath = expectedDbPath;
await multimodalEmbeddingStore.init();
}
const cleanup = multimodalEmbeddingStore.cleanupStaleFilesystemEntries();
deletedStaleFilesystemRows = cleanup.removed;
if (cleanup.removed > 0) {
console.log(`[Index] Removed ${cleanup.removed} stale multimodal embeddings`);
}
multimodalEmbeddingClient = new MultimodalEmbeddingClient({
backend: resolvedMultimodalBackend,
port: config.get("multimodalEmbeddingServicePort"),
lmStudioUrl: config.get("multimodalEmbeddingLmStudioUrl"),
modelPath: multimodalModelPath,
ggufBinaryPath: multimodalGgufBinaryPath,
ggufGpuLayers: multimodalGgufGpuLayers,
model: multimodalModel,
dimension: multimodalDimension,
contextSize: multimodalContextSize,
abortSignal,
onStatus: (message) => void onProgress?.(message),
});
const embeddingSummary = await generateMultimodalEmbeddings(
indexedGenerations,
multimodalEmbeddingClient,
multimodalEmbeddingStore,
multimodalModelKey,
multimodalDimension,
{
maxNewEmbeddings: options.embeddingMode === "manual-refresh"
? MULTIMODAL_MAX_NEW_EMBEDDINGS_UNLIMITED
: maxNewEmbeddings,
flushEvery: config.get("multimodalFlushEvery"),
failFast: false,
isGgufBackend,
usePreviewsForQueryEmbedding,
},
onProgress,
abortSignal,
);
return { ...embeddingSummary, deletedStaleFilesystemRows };
} catch (error) {
if (isIndexingAbort(error)) throw error;
console.warn("[Index] Multimodal indexing unavailable:", error);
await onProgress?.("⚠️ Multimodal indexing unavailable - metadata search remains available");
const failedRunSummary = (error as { multimodalEmbeddingRunSummary?: MultimodalEmbeddingRunSummary })?.multimodalEmbeddingRunSummary;
if (failedRunSummary) return { ...failedRunSummary, deletedStaleFilesystemRows };
return null;
}
} else {
console.log("[Index] Multimodal indexing disabled in config");
return null;
}
}
/**
* Index all configured sources
* Works with both ToolsProviderController and PromptPreprocessorController
*/
export async function indexGenerations(
ctl: ConfigController,
forceReindex = false,
onProgress?: IndexProgressCallback,
onEmbeddingRunComplete?: (summary: MultimodalEmbeddingRunSummary | null) => void,
abortSignal?: AbortSignal,
options?: IndexGenerationsOptions,
): Promise<IndexedGeneration[]> {
throwIfIndexingAborted(abortSignal);
const config = ctl.getGlobalPluginConfig(globalConfigSchematics);
const chatConfig = ctl.getPluginConfig(configSchematics);
const maxNewEmbeddings = chatConfig.get("multimodalMaxNewEmbeddingsPerRun");
// Return cached data if available (invalidated by FileWatchers)
if (cachedGenerations && !forceReindex) {
await onProgress?.(`Using cached index (${cachedGenerations.length} generations)`);
const embeddingSummary = await ensureMultimodalEmbeddingsForGenerations(
config,
cachedGenerations,
maxNewEmbeddings,
onProgress,
abortSignal,
options,
);
onEmbeddingRunComplete?.(embeddingSummary);
return cachedGenerations;
}
if (isIndexing) {
await onProgress?.("Waiting for indexing to complete...");
while (isIndexing) {
throwIfIndexingAborted(abortSignal);
await new Promise(r => setTimeout(r, 100));
}
return cachedGenerations ?? lastCompletedGenerations ?? [];
}
isIndexing = true;
const indexRunRevision = cacheRevision;
const generations: GenerationMetadata[] = [];
let currentResult: IndexedGeneration[] = [];
const activeCacheKeys = new Set<string>();
// ═══════════════════════════════════════════════════════════════
// Source Statistics (for consolidated summary)
// ═══════════════════════════════════════════════════════════════
interface SourceStats {
name: string;
enabled: boolean;
path: string;
exists: boolean;
filesFound: number;
generationsExtracted: number;
}
const sourceStats: SourceStats[] = [];
try {
// ─────────────────────────────────────────────────────────────
// Index Image Directories (metadata optional)
// ─────────────────────────────────────────────────────────────
const contentDirectories = config.get("contentDirectories").map(sanitizeConfigPath);
const imageStats: SourceStats = {
name: "Image Directories",
enabled: contentDirectories.length > 0,
path: contentDirectories.join(", "),
exists: false,
filesFound: 0,
generationsExtracted: 0,
};
if (contentDirectories.length > 0) {
await onProgress?.("Scanning image directories...");
// Collect all image files from all directories
const allImageFiles: string[] = [];
for (const dir of contentDirectories) {
throwIfIndexingAborted(abortSignal);
if (existsSync(dir)) {
imageStats.exists = true;
watchImages(dir);
const imageFiles = await findFiles(dir, IMAGE_GLOB);
allImageFiles.push(...imageFiles);
}
}
imageStats.filesFound = allImageFiles.length;
await onProgress?.(`Found ${allImageFiles.length} images to scan`);
let scanned = 0;
for (const file of allImageFiles) {
throwIfIndexingAborted(abortSignal);
scanned++;
if (scanned % 50 === 0) {
await onProgress?.(`Scanning images ${scanned}/${allImageFiles.length}...`);
}
if (shouldSkipWorkingDirectoryPreview(file)) {
continue;
}
const cacheKey = generationCacheKey("image", file);
const fingerprint = sourceFileFingerprint(file);
activeCacheKeys.add(cacheKey);
const cached = getCachedGenerations(cacheKey, fingerprint);
if (cached) {
await populateImageContentHashes(cached);
generations.push(...cached);
imageStats.generationsExtracted += cached.length;
continue;
}
let extracted: GenerationMetadata;
try {
const metadata = await PngMetadataParser.extractMetadata(file);
if (metadata) {
const sourceInfo = sourceInfoForFilesystemImage(file, imageTypeForPngMetadata(metadata));
extracted = parsedImageToMetadata(metadata, file, sourceInfo);
} else {
extracted = await imageFileToMetadata(file, sourceInfoForFilesystemImage(file));
}
} catch (e) {
extracted = await imageFileToMetadata(file, sourceInfoForFilesystemImage(file));
}
await populateImageContentHashes([extracted]);
generations.push(extracted);
setCachedGenerations(cacheKey, fingerprint, [extracted]);
imageStats.generationsExtracted++;
}
}
sourceStats.push(imageStats);
// ─────────────────────────────────────────────────────────────
// Index host-owned working directories
// ─────────────────────────────────────────────────────────────
const workingDirectoriesEnabled = config.get("searchWorkingDirectories");
const workingDirectoryStats: SourceStats = {
name: "LM Studio Working Directories",
enabled: workingDirectoriesEnabled,
path: FIND_IMAGE_WORKING_DIRECTORIES_ROOT,
exists: false,
filesFound: 0,
generationsExtracted: 0,
};
if (workingDirectoriesEnabled) {
await onProgress?.("Scanning LM Studio working directories...");
workingDirectoryStats.exists = existsSync(FIND_IMAGE_WORKING_DIRECTORIES_ROOT);
ownedWorkingDirectoryChatIds = getOwnedWorkingDirectoryChatIds();
if (workingDirectoryStats.exists) {
watchImages(FIND_IMAGE_WORKING_DIRECTORIES_ROOT);
const imageFiles = await findFiles(FIND_IMAGE_WORKING_DIRECTORIES_ROOT, IMAGE_GLOB);
workingDirectoryStats.filesFound = imageFiles.length;
await onProgress?.(`Found ${imageFiles.length} working-directory images to scan`);
let scanned = 0;
for (const file of imageFiles) {
throwIfIndexingAborted(abortSignal);
scanned++;
if (scanned % 50 === 0) {
await onProgress?.(`Scanning working-directory images ${scanned}/${imageFiles.length}...`);
}
if (shouldSkipOrphanedWorkingDirectoryFile(file)) {
continue;
}
if (shouldSkipWorkingDirectoryPreview(file) || shouldSkipImageIndexMaterializedResult(file) || shouldSkipHarvestedProjectResult(file)) {
continue;
}
const cacheKey = generationCacheKey("working", file);
const fingerprint = sourceFileFingerprint(file);
activeCacheKeys.add(cacheKey);
const cached = getCachedGenerations(cacheKey, fingerprint);
if (cached) {
await populateImageContentHashes(cached);
generations.push(...cached);
workingDirectoryStats.generationsExtracted += cached.length;
continue;
}
let extracted: GenerationMetadata;
try {
const metadata = await PngMetadataParser.extractMetadata(file);
if (metadata) {
const sourceInfo = sourceInfoForFilesystemImage(file, imageTypeForPngMetadata(metadata));
extracted = parsedImageToMetadata(metadata, file, sourceInfo);
} else {
extracted = await imageFileToMetadata(file, sourceInfoForFilesystemImage(file));
}
} catch (e) {
extracted = await imageFileToMetadata(file, sourceInfoForFilesystemImage(file));
}
await populateImageContentHashes([extracted]);
generations.push(extracted);
setCachedGenerations(cacheKey, fingerprint, [extracted]);
workingDirectoryStats.generationsExtracted++;
}
}
}
sourceStats.push(workingDirectoryStats);
// ─────────────────────────────────────────────────────────────
// Index Chat Attachments (user-files)
// ─────────────────────────────────────────────────────────────
const attachmentsEnabled = config.get("searchChatAttachments");
const userFilesDir = FIND_IMAGE_USER_FILES_ROOT;
const attachmentStats: SourceStats = {
name: "Chat Attachments",
enabled: attachmentsEnabled,
path: userFilesDir ?? "Unavailable in standalone",
exists: false,
filesFound: 0,
generationsExtracted: 0,
};
if (attachmentsEnabled && userFilesDir) {
await onProgress?.("Scanning chat attachments...");
attachmentStats.exists = existsSync(userFilesDir);
if (attachmentStats.exists) {
watchImages(userFilesDir);
const imageFiles = await findFiles(userFilesDir, IMAGE_GLOB);
attachmentStats.filesFound = imageFiles.length;
await onProgress?.(`Found ${imageFiles.length} image attachments to scan`);
let scanned = 0;
for (const file of imageFiles) {
throwIfIndexingAborted(abortSignal);
scanned++;
if (scanned % 50 === 0) {
await onProgress?.(`Scanning attachments ${scanned}/${imageFiles.length}...`);
}
const cacheKey = generationCacheKey("attachment", file);
const fingerprint = attachmentSourceFingerprint(file);
activeCacheKeys.add(cacheKey);
const cached = getCachedGenerations(cacheKey, fingerprint);
if (cached) {
await populateImageContentHashes(cached);
generations.push(...cached);
attachmentStats.generationsExtracted += cached.length;
continue;
}
let extracted: GenerationMetadata;
try {
const metadata = await PngMetadataParser.extractMetadata(file);
const originalName = await getOriginalName(file) || path.basename(file);
if (metadata) {
const imageType = imageTypeForPngMetadata(metadata);
const sourceInfo: SourceInfo = imageType
? { type: 'attachment', originalName, filePath: file, imageType }
: { type: 'attachment', originalName, filePath: file };
extracted = parsedImageToMetadata(metadata, file, sourceInfo);
} else {
extracted = await imageFileToMetadata(file, { type: 'attachment', originalName, filePath: file });
}
} catch (e) {
const originalName = await getOriginalName(file) || path.basename(file);
extracted = await imageFileToMetadata(file, { type: 'attachment', originalName, filePath: file });
}
await populateImageContentHashes([extracted]);
generations.push(extracted);
setCachedGenerations(cacheKey, fingerprint, [extracted]);
attachmentStats.generationsExtracted++;
}
}
}
sourceStats.push(attachmentStats);
// ─────────────────────────────────────────────────────────────
// Index Draw Things Projects (.sqlite3)
// ─────────────────────────────────────────────────────────────
const projectsEnabled = config.get("searchDrawThingsProjects");
const projectsDir = sanitizeConfigPath(config.get("drawThingsProjectsDirectory"));
const projectStats: SourceStats = {
name: "Draw Things Projects",
enabled: projectsEnabled,
path: projectsDir,
exists: false,
filesFound: 0,
generationsExtracted: 0,
};
if (projectsEnabled) {
await onProgress?.("Scanning Draw Things projects...");
projectStats.exists = existsSync(projectsDir);
if (projectStats.exists) {
watchProjects(projectsDir);
const projectFiles = await findFiles(projectsDir, "**/*.sqlite3");
projectStats.filesFound = projectFiles.length;
if (projectFiles.length > 0) {
await onProgress?.(`Parsing project files 0/${projectFiles.length}...`);
for (let i = 0; i < projectFiles.length; i++) {
throwIfIndexingAborted(abortSignal);
const projectFile = projectFiles[i];
const cacheKey = generationCacheKey("project", projectFile);
const fingerprint = projectSourceFingerprint(projectFile);
activeCacheKeys.add(cacheKey);
const cached = getCachedGenerations(cacheKey, fingerprint);
if (cached) {
await populateImageContentHashes(cached);
generations.push(...cached);
projectStats.generationsExtracted += cached.length;
continue;
}
await onProgress?.(`Parsing project files ${i + 1}/${projectFiles.length}: ${path.basename(projectFile)}`);
const projectGenerations = await parseDrawThingsProjectMetadataOnly(projectFile);
await populateImageContentHashes(projectGenerations);
generations.push(...projectGenerations);
setCachedGenerations(cacheKey, fingerprint, projectGenerations);
projectStats.generationsExtracted += projectGenerations.length;
}
}
}
}
sourceStats.push(projectStats);
pruneGenerationCache(activeCacheKeys);
saveGenerationIndexCache();
// ═══════════════════════════════════════════════════════════════
// CONSOLIDATED SOURCE SUMMARY
// ═══════════════════════════════════════════════════════════════
console.log("\n[Index] SOURCE SUMMARY:");
for (const s of sourceStats) {
const status = !s.enabled
? "DISABLED"
: !s.exists
? "NOT FOUND"
: s.generationsExtracted > 0
? "OK"
: "EMPTY";
const statusIcon = status === "OK" ? "✓" : status === "DISABLED" ? "○" : "✗";
console.log(`[Index] ${statusIcon} ${s.name}: ${status}`);
console.log(`[Index] Path: ${s.path}`);
if (s.enabled) {
console.log(`[Index] Exists: ${s.exists ? "yes" : "NO"}`);
if (s.exists) {
console.log(`[Index] Files found: ${s.filesFound}`);
console.log(`[Index] Generations extracted: ${s.generationsExtracted}`);
}
}
}
console.log(`[Index] TOTAL: ${generations.length} generations from ${sourceStats.filter(s => s.enabled && s.exists && s.generationsExtracted > 0).length} sources\n`);
await onProgress?.(`Metadata index ready: ${generations.length} generations`);
const embeddingSummary = await ensureMultimodalEmbeddingsForGenerations(
config,
generations,
maxNewEmbeddings,
onProgress,
abortSignal,
options,
);
onEmbeddingRunComplete?.(embeddingSummary);
if (cacheRevision === indexRunRevision) {
cachedGenerations = generations;
} else {
cachedGenerations = null;
await onProgress?.("Index changed while scanning; next search will refresh the index");
}
currentResult = generations;
lastCompletedGenerations = generations;
} finally {
isIndexing = false;
}
return cachedGenerations ?? currentResult;
}
export interface MultimodalImageCandidate {
mediaKey: string;
imageRef: string;
sourceType?: string;
sourceFingerprint: string;
metadataText: string;
kind: "file" | "project";
filePath?: string;
projectPath?: string;
thumbnailId?: number;
}
async function generateMultimodalEmbeddings(
generations: GenerationMetadata[],
client: MultimodalEmbeddingClient,
store: MultimodalEmbeddingStore,
modelKey: string,
dimension: number,
options: {
maxNewEmbeddings: number;
flushEvery: number;
failFast?: boolean;
isGgufBackend?: boolean;
usePreviewsForQueryEmbedding: boolean;
},
onProgress?: IndexProgressCallback,
abortSignal?: AbortSignal,
): Promise<MultimodalEmbeddingRunSummary> {
throwIfIndexingAborted(abortSignal);
const candidates = collectMultimodalImageCandidates(generations);
const flushEvery = Math.max(1, options.flushEvery);
let cached = 0;
let embedded = 0;
const skipped: Array<{ imageRef: string; reason: string }> = [];
const uncachedCandidates: MultimodalImageCandidate[] = [];
for (const candidate of candidates) {
throwIfIndexingAborted(abortSignal);
const sourceFingerprint = embeddingFingerprint(candidate.sourceFingerprint, candidate.metadataText);
const hasFreshOriginal = store.hasFreshOriginalEmbedding(
candidate.mediaKey,
modelKey,
dimension,
sourceFingerprint,
);
if (hasFreshOriginal) {
cached++;
continue;
}
if (options.usePreviewsForQueryEmbedding && store.hasFreshEmbedding(
candidate.mediaKey,
modelKey,
dimension,
sourceFingerprint,
"preview",
)) {
cached++;
continue;
}
if (store.promoteLegacyProjectEmbedding(candidate.mediaKey, modelKey, dimension, sourceFingerprint, "original")
|| (options.usePreviewsForQueryEmbedding
&& store.promoteLegacyProjectEmbedding(candidate.mediaKey, modelKey, dimension, sourceFingerprint, "preview"))) {
cached++;
continue;
}
uncachedCandidates.push(candidate);
}
// Zero disables new embeddings for this run. The UI and maintenance scripts
// use the explicit 100 sentinel for an unlimited backlog run.
const isUnlimited = options.maxNewEmbeddings >= MULTIMODAL_MAX_NEW_EMBEDDINGS_UNLIMITED;
const embedBudget = isUnlimited ? uncachedCandidates.length : Math.max(0, Math.min(options.maxNewEmbeddings, uncachedCandidates.length));
const deferred = uncachedCandidates.length - embedBudget;
const candidatesToEmbed = selectEmbeddingCandidates(
uncachedCandidates,
embedBudget,
(candidate) => candidate.sourceType,
);
const pendingProjectCandidates = new Map<string, { thumbnailIds: number[]; remaining: number }>();
const projectThumbnails = new Map<string, Map<number, Buffer>>();
for (const candidate of candidatesToEmbed) {
if (candidate.kind !== "project" || !candidate.projectPath || candidate.thumbnailId === undefined) continue;
const pending = pendingProjectCandidates.get(candidate.projectPath) ?? { thumbnailIds: [], remaining: 0 };
pending.thumbnailIds.push(candidate.thumbnailId);
pending.remaining++;
pendingProjectCandidates.set(candidate.projectPath, pending);
}
// Report progress as one message per FILE ("Embedding image N/Total
// (X%): name"), fired immediately before that file's own embed call. Each
// candidate is embedded with its own /embeddings request — llama-server
// keeps the model resident across calls, so there is no per-call model
// reload to amortize by batching several images into one request (unlike
// the old llama-vl-embedding subprocess engine, which reloaded the whole
// model on every invocation). Embedding one file at a time also gives true
// per-file failure isolation and store.flush() checkpoints, instead of
// bounding the blast radius of a batch call.
const candidateLabel = (candidate: MultimodalImageCandidate): string => {
if (candidate.kind === "project") {
return `${path.basename(candidate.projectPath ?? candidate.imageRef)}#${candidate.thumbnailId ?? "unknown"}`;
}
return path.basename(candidate.filePath ?? candidate.imageRef);
};
const reportEmbeddingStart = async (candidate: MultimodalImageCandidate) => {
const step = embedded + skipped.length + 1;
const total = candidatesToEmbed.length;
if (total <= 0 || step <= 0) return;
const percent = Math.round((step / total) * 100);
const deferredSuffix = deferred > 0 ? ` — ${deferred.toLocaleString()} more visuals queued for future runs` : "";
await onProgress?.(`Embedding image ${step}/${total} (${percent}%): ${candidateLabel(candidate)}${deferredSuffix}`);
};
for (const candidate of candidatesToEmbed) {
throwIfIndexingAborted(abortSignal);
try {
const started = Date.now();
const sourceFingerprint = embeddingFingerprint(candidate.sourceFingerprint, candidate.metadataText);
let imageBytes: Uint8Array | undefined;
if (candidate.kind === "project") {
if (!candidate.projectPath || candidate.thumbnailId === undefined) {
skipped.push({ imageRef: candidate.imageRef, reason: "missing project thumbnail reference" });
continue;
}
const pending = pendingProjectCandidates.get(candidate.projectPath)!;
let thumbnails = projectThumbnails.get(candidate.projectPath);
if (!thumbnails) {
thumbnails = await getThumbnailsFromProject(candidate.projectPath, pending.thumbnailIds);
projectThumbnails.set(candidate.projectPath, thumbnails);
}
const thumbnailBytes = thumbnails.get(candidate.thumbnailId);
if (!thumbnailBytes) {
skipped.push({ imageRef: candidate.imageRef, reason: "project thumbnail unavailable" });
continue;
}
imageBytes = thumbnailBytes;
} else if (!candidate.filePath) {
skipped.push({ imageRef: candidate.imageRef, reason: "missing image file path" });
continue;
}
if (!imageBytes) imageBytes = new Uint8Array(await readFile(candidate.filePath!));
const usePreviewPayload = await shouldUsePreviewPayload(imageBytes, options.usePreviewsForQueryEmbedding);
const payloadMode = usePreviewPayload ? "preview" : "original";
const isFresh = store.hasFreshEmbedding(
candidate.mediaKey,
modelKey,
dimension,
sourceFingerprint,
payloadMode,
);
if (!isFresh) {
await reportEmbeddingStart(candidate);
const previewPayload = usePreviewPayload ? await generatePreviewPayload(imageBytes) : undefined;
if (previewPayload) {
console.debug(
`[Index] Generated preview: input ${previewPayload.sourceWidth}x${previewPayload.sourceHeight} -> embedded ${previewPayload.payloadWidth}x${previewPayload.payloadHeight} (sum ${previewPayload.payloadWidth + previewPayload.payloadHeight}/${drawthingsLimits.previewMaxSum})`,
);
}
const payloadBytes = previewPayload?.bytes ?? imageBytes;
const result = await client.embedImageBytesAndText(payloadBytes, candidate.metadataText, dimension);
store.setEmbedding({
mediaKey: candidate.mediaKey,
imageRef: candidate.imageRef,
sourceType: candidate.sourceType,
sourceFingerprint,
payloadMode,
previewPolicyEnabled: options.usePreviewsForQueryEmbedding,
embedding: result.embedding,
model: modelKey,
dimension,
});
if (payloadMode === "original") {
store.flush();
store.deleteEmbedding(candidate.mediaKey, modelKey, dimension, "preview");
}
}
embedded++;
if (embedded % flushEvery === 0) {
store.flush();
}
const elapsedMs = Date.now() - started;
console.debug(`[Index] Embedded row for ${candidate.imageRef} in ${elapsedMs} ms`);
if (options.isGgufBackend) {
appendEmbeddingLogLine(
`indexed ${embedded}/${candidatesToEmbed.length} in ${(elapsedMs / 1000).toFixed(1)}s: ${candidate.imageRef}`
);
}
} catch (error) {
if (isIndexingAbort(error)) throw error;
const reason = error instanceof Error ? error.message : String(error);
skipped.push({
imageRef: candidate.imageRef,
reason,
});
console.warn(`[Index] Failed to embed multimodal image: ${candidate.imageRef}`, error);
const step = embedded + skipped.length;
const skipMessage = `WARN Skipped multimodal image ${step}/${candidatesToEmbed.length}: ${candidate.imageRef} - ${reason.replace(/\s+/g, " ").trim()}`;
await onProgress?.(skipMessage);
if (options.isGgufBackend) {
appendEmbeddingLogLine(skipMessage);
}
if (options.failFast || isFatalEmbeddingError(error)) {
store.flush();
if (error && typeof error === "object") {
(error as { multimodalEmbeddingRunSummary?: MultimodalEmbeddingRunSummary }).multimodalEmbeddingRunSummary = {
cached,
embedded,
skipped,
deferred,
deletedStaleFilesystemRows: 0,
};
}
throw error;
}
} finally {
if (candidate.kind === "project" && candidate.projectPath) {
const pending = pendingProjectCandidates.get(candidate.projectPath);
if (pending && --pending.remaining === 0) projectThumbnails.delete(candidate.projectPath);
}
}
}
store.flush();
console.log(`[Index] Multimodal embeddings ready: ${cached} cached, ${embedded} embedded, ${skipped.length} skipped, ${deferred} deferred`);
return { cached, embedded, skipped, deferred, deletedStaleFilesystemRows: 0 };
}
function isFatalEmbeddingError(error: unknown): boolean {
const message = String((error as any)?.message ?? error).toLowerCase();
return [
"model path",
"model load failed",
"model directory",
"model file",
"mmproj",
"gguf embedding service",
"embedding service did not become healthy",
"runner exited",
"runner is not running",
"failed to fetch",
"econnrefused",
"socket hang up",
].some((needle) => message.includes(needle));
}
export function collectMultimodalImageCandidates(generations: GenerationMetadata[]): MultimodalImageCandidate[] {
const candidates = new Map<string, MultimodalImageCandidate>();
for (const gen of generations) {
for (const imageRef of gen.imagePaths ?? []) {
const candidate = multimodalCandidateForImageRef(imageRef, gen, renderEmbeddingMetadataText(gen, path.basename(imageRef)));
if (!candidate) continue;
const key = `${candidate.mediaKey}|${candidate.sourceFingerprint}`;
if (!candidates.has(key)) {
candidates.set(key, candidate);
}
}
}
return Array.from(candidates.values());
}
function multimodalCandidateForImageRef(imageRef: string, generation: GenerationMetadata, metadataText: string): MultimodalImageCandidate | null {
const sourceType = sourceTypeForGeneration(generation);
try {
if (imageRef.startsWith("project://")) {
const parsed = parseProjectUri(imageRef);
if (!parsed || !existsSync(parsed.projectPath)) return null;
return {
mediaKey: imageRef,
imageRef,
sourceType,
sourceFingerprint: generation.sourceInfo?.type === "draw_things_project"
? generation.sourceInfo.generationFingerprint ?? projectThumbnailFingerprint(parsed.projectPath, parsed.thumbnailId)
: projectThumbnailFingerprint(parsed.projectPath, parsed.thumbnailId),
metadataText,
kind: "project",
projectPath: parsed.projectPath,
thumbnailId: parsed.thumbnailId,
};
}
if (!path.isAbsolute(imageRef) || !existsSync(imageRef)) return null;
// NOTE: do NOT re-apply shouldSkipOrphanedWorkingDirectoryFile() here.
// This function runs over the final deduped `generations` list built
// from ALL 4 sources (Content Dirs, Working Directories, Attachments,
// Draw Things Projects), not just the Working Directories source. The
// orphan check is a Working-Directories-source-specific filter (see the
// indexGenerations() working-directory scan loop) meant to exclude
// leftover scratch files whose chat was deleted. Applying it again here
// would silently strip embeddings from any otherwise-valid image that
// simply happens to physically live under working-directories/<chatId>,
// even when it was discovered via a different source and already passed
// that source's own filters — e.g. once LM Studio prunes old
// conversation.json files while their working-directories folders
// persist, this would eventually block embeddings for most of the index.
if (shouldSkipWorkingDirectoryPreview(imageRef)) return null;
// Authoritative embedding gate (Grundregel + Ausnahme, see
// isWorkingDirectoryFileEmbeddable doc comment): applied here, not just
// in the working-directory metadata-scan loop, because that loop's
// exclusion can race with a DIFFERENT plugin's tagging step. Enforcing
// it again at the point embeddings are actually decided closes that gap
// regardless of how imageRef entered `generations`.
if (!isWorkingDirectoryFileEmbeddable(imageRef)) return null;
return {
mediaKey: imageRef,
imageRef,
sourceType,
sourceFingerprint: fileFingerprint(imageRef),
metadataText,
kind: "file",
filePath: imageRef,
};
} catch {
return null;
}
}
interface ChatMediaRecord {
filename?: unknown;
preview?: unknown;
originAbs?: unknown;
originalName?: unknown;
sourceUrl?: unknown;
sourceTool?: unknown;
pluginId?: unknown;
a?: unknown;
v?: unknown;
i?: unknown;
p?: unknown;
}
interface ChatMediaState {
attachments?: ChatMediaRecord[];
variants?: ChatMediaRecord[];
images?: ChatMediaRecord[];
pictures?: ChatMediaRecord[];
}
const chatMediaStateCache = new Map<string, ChatMediaState | null>();
export function sourceInfoForFilesystemImage(filePath: string, detectedImageType?: string): SourceInfo {
const userFileInfo = sourceInfoForUserFile(filePath, detectedImageType);
if (userFileInfo) return userFileInfo;
const chatMediaInfo = sourceInfoForWorkingDirectoryFile(filePath, detectedImageType);
if (chatMediaInfo) return chatMediaInfo;
return detectedImageType
? { type: 'saved_image', filePath, imageType: detectedImageType }
: { type: 'saved_image', filePath };
}
function sourceInfoForUserFile(filePath: string, detectedImageType?: string): SourceInfo | null {
if (!FIND_IMAGE_USER_FILES_ROOT || !isUnderDirectory(filePath, FIND_IMAGE_USER_FILES_ROOT)) return null;
const originalName = path.basename(filePath);
return detectedImageType
? { type: 'attachment', originalName, filePath, imageType: detectedImageType }
: { type: 'attachment', originalName, filePath };
}
function sourceInfoForWorkingDirectoryFile(filePath: string, detectedImageType?: string): SourceInfo | null {
const workingDir = chatWorkingDirectoryForPath(filePath);
if (!workingDir) return null;
const chatId = path.basename(workingDir);
const state = readChatMediaState(workingDir);
const fallback: SourceInfo = detectedImageType
? { type: 'image', chatId, filePath, imageType: detectedImageType }
: { type: 'image', chatId, filePath };
if (!state) return fallback;
const resolvedFilePath = path.resolve(filePath);
const matchRecord = (record: ChatMediaRecord): boolean => {
const candidates = [
localStatePath(workingDir, record.filename),
localStatePath(workingDir, record.preview),
localStatePath(workingDir, record.originAbs),
fileUrlPath(record.sourceUrl),
];
return candidates.some((candidate) => candidate ? samePath(candidate, resolvedFilePath) : false);
};
for (const record of Array.isArray(state.attachments) ? state.attachments : []) {
if (!matchRecord(record)) continue;
const originalName = typeof record.originalName === 'string' && record.originalName.trim()
? record.originalName.trim()
: path.basename(filePath);
const imageType = imageTypeForChatMediaRecord(record, detectedImageType);
return imageType
? { type: 'attachment', originalName, filePath, chatId, imageType }
: { type: 'attachment', originalName, filePath, chatId };
}
for (const record of Array.isArray(state.variants) ? state.variants : []) {
if (!matchRecord(record)) continue;
const imageType = imageTypeForChatMediaRecord(record, detectedImageType);
return imageType
? { type: 'variant', chatId, filePath, imageType }
: { type: 'variant', chatId, filePath };
}
for (const record of Array.isArray(state.images) ? state.images : []) {
if (!matchRecord(record)) continue;
const imageType = imageTypeForChatMediaRecord(record, detectedImageType);
return imageType
? { type: 'image', chatId, filePath, imageType }
: { type: 'image', chatId, filePath };
}
for (const record of Array.isArray(state.pictures) ? state.pictures : []) {
if (!matchRecord(record)) continue;
const imageType = imageTypeForChatMediaRecord(record, detectedImageType);
return imageType
? { type: 'picture', chatId, filePath, imageType }
: { type: 'picture', chatId, filePath };
}
return fallback;
}
function imageTypeForChatMediaRecord(record: ChatMediaRecord, fallback?: string): string | undefined {
const marker = [record.sourceTool, record.pluginId]
.map((value) => typeof value === 'string' ? value.toLowerCase() : '')
.find((value) => value.length > 0) ?? '';
if (marker.includes('process-image') || marker.includes('process_image')) return 'Process Image';
if (marker.includes('analyse-image') || marker.includes('analyse_image') || marker.includes('analyze-image') || marker.includes('analyze_image')) return 'Analyse Image';
if (marker.includes('brave') || marker.includes('image-search') || marker.includes('image_search')) return 'Brave Image Search';
if (marker.includes('generate-image') || marker.includes('generate_image') || marker.includes('draw-things-chat')) return 'Draw Things';
return fallback;
}
function shouldSkipWorkingDirectoryPreview(filePath: string): boolean {
const workingDir = chatWorkingDirectoryForPath(filePath);
if (!workingDir) return false;
const state = readChatMediaState(workingDir);
if (!state) return false;
const resolvedFilePath = path.resolve(filePath);
const records = [
...(Array.isArray(state.attachments) ? state.attachments : []),
...(Array.isArray(state.variants) ? state.variants : []),
...(Array.isArray(state.images) ? state.images : []),
...(Array.isArray(state.pictures) ? state.pictures : []),
];
for (const record of records) {
const previewPath = localStatePath(workingDir, record.preview);
const filenamePath = localStatePath(workingDir, record.filename);
const originPath = localStatePath(workingDir, record.originAbs);
const sourceUrlPath = fileUrlPath(record.sourceUrl);
if (previewPath && samePath(previewPath, resolvedFilePath)) {
if (originPath && existsSync(originPath)) return true;
if (filenamePath && !samePath(filenamePath, previewPath) && existsSync(filenamePath)) return true;
if (sourceUrlPath && FIND_IMAGE_USER_FILES_ROOT && isUnderDirectory(sourceUrlPath, FIND_IMAGE_USER_FILES_ROOT) && existsSync(sourceUrlPath)) return true;
}
if (filenamePath && samePath(filenamePath, resolvedFilePath)) {
if (sourceUrlPath && FIND_IMAGE_USER_FILES_ROOT && isUnderDirectory(sourceUrlPath, FIND_IMAGE_USER_FILES_ROOT) && existsSync(sourceUrlPath)) return true;
}
}
return false;
}
function shouldSkipImageIndexMaterializedResult(filePath: string): boolean {
const workingDir = chatWorkingDirectoryForPath(filePath);
if (!workingDir) return false;
const state = readChatMediaState(workingDir);
if (!state) return false;
const resolvedFilePath = path.resolve(filePath);
const records = [
...(Array.isArray(state.attachments) ? state.attachments : []),
...(Array.isArray(state.variants) ? state.variants : []),
...(Array.isArray(state.images) ? state.images : []),
...(Array.isArray(state.pictures) ? state.pictures : []),
];
for (const record of records) {
if (!isImageIndexMaterializedRecord(record)) continue;
const filenamePath = localStatePath(workingDir, record.filename);
const previewPath = localStatePath(workingDir, record.preview);
if ((filenamePath && samePath(filenamePath, resolvedFilePath)) || (previewPath && samePath(previewPath, resolvedFilePath))) {
return true;
}
}
return false;
}
export function isImageIndexMaterializedProvenance(
pluginIdValue: unknown,
sourceToolValue: unknown,
selfPluginIdentifier = getSelfPluginIdentifier() ?? FALLBACK_SELF_PLUGIN_IDENTIFIER,
): boolean {
const pluginId = typeof pluginIdValue === "string" ? pluginIdValue.trim().toLowerCase() : "";
const sourceTool = typeof sourceToolValue === "string" ? sourceToolValue.trim().toLowerCase() : "";
const selfPluginId = selfPluginIdentifier.trim().toLowerCase();
return pluginId === selfPluginId
|| pluginId === OTHER_IMAGE_INDEX_PLUGIN_IDENTIFIER
|| sourceTool === "find_image"
|| sourceTool === "find-image"
|| sourceTool === "index_image"
|| sourceTool === "index-image";
}
function isImageIndexMaterializedRecord(record: ChatMediaRecord): boolean {
return isImageIndexMaterializedProvenance(record.pluginId, record.sourceTool);
}
/**
* Grundregel: find_image must NEVER embed a plain file living under a chat's
* working-directory. AUSNAHME: a chat_media_state.json record explicitly
* attributes this exact file (by filename or preview path) to a DIFFERENT,
* real tool — i.e. sourceTool/pluginId is present AND is not an image-index
* result materialized by find_image or index_image.
*
* This is deliberately an ALLOWLIST, not a denylist: an untagged match
* (sourceTool/pluginId absent — e.g. because another plugin's own
* reconcile/tagging step for this exact file hasn't run yet) must NOT be
* treated as "safe to embed". Relying on "skip only if positively tagged as
* find_image's own" (the previous approach, see shouldSkipFindImageMaterializedResult)
* has a real race: find_image copies+embeds a file in the SAME synchronous
* call that materializes it, while the tag that would identify it as
* find_image's own copy is written later by a DIFFERENT plugin
* (draw-things-chat's turn-boundary-gated PICTURE RECONCILE) — so at embed
* time the tag can legitimately not exist yet.
*
* Paths outside any chat working-directory (content directories, user-files
* attachments, project thumbnails) are unaffected — this only gates files
* physically under working-directories/<chatId>/.
*/
function isWorkingDirectoryFileEmbeddable(filePath: string): boolean {
const workingDir = chatWorkingDirectoryForPath(filePath);
if (!workingDir) return true;
const state = readChatMediaState(workingDir);
if (!state) return false;
const resolvedFilePath = path.resolve(filePath);
const records = [
...(Array.isArray(state.attachments) ? state.attachments : []),
...(Array.isArray(state.variants) ? state.variants : []),
...(Array.isArray(state.images) ? state.images : []),
...(Array.isArray(state.pictures) ? state.pictures : []),
];
for (const record of records) {
const filenamePath = localStatePath(workingDir, record.filename);
const previewPath = localStatePath(workingDir, record.preview);
const matches = (filenamePath && samePath(filenamePath, resolvedFilePath)) || (previewPath && samePath(previewPath, resolvedFilePath));
if (!matches) continue;
const pluginId = typeof record.pluginId === "string" ? record.pluginId.trim().toLowerCase() : "";
const sourceTool = typeof record.sourceTool === "string" ? record.sourceTool.trim().toLowerCase() : "";
if (!pluginId && !sourceTool) continue; // untagged: keep looking, do not allow on this record alone
if (!isImageIndexMaterializedRecord(record)) return true; // a different, real tool explicitly owns this file
}
return false;
}
function shouldSkipHarvestedProjectResult(filePath: string): boolean {
const relative = path.relative(FIND_IMAGE_WORKING_DIRECTORIES_ROOT, filePath);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
return isHarvestedProjectResultPath(relative);
}
function shouldSkipOrphanedWorkingDirectoryFile(filePath: string): boolean {
const workingDir = chatWorkingDirectoryForPath(filePath);
if (!workingDir) return false;
const chatId = path.basename(workingDir);
if (!chatId) return true;
return !ownedWorkingDirectoryChatIds?.has(chatId);
}
function getOwnedWorkingDirectoryChatIds(): Set<string> {
const chatIds = new Set<string>();
function walk(directory: string): void {
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
} else if (entry.isFile() && entry.name.endsWith(".conversation.json")) {
chatIds.add(entry.name.slice(0, -".conversation.json".length));
}
}
}
walk(LMSTUDIO_CONVERSATIONS);
return chatIds;
}
function chatWorkingDirectoryForPath(filePath: string): string | null {
const relative = path.relative(FIND_IMAGE_WORKING_DIRECTORIES_ROOT, filePath);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null;
const [chatId] = relative.split(path.sep);
if (!chatId) return null;
return path.join(FIND_IMAGE_WORKING_DIRECTORIES_ROOT, chatId);
}
function readChatMediaState(workingDir: string): ChatMediaState | null {
const statePath = path.join(workingDir, CHAT_MEDIA_STATE_FILE);
if (chatMediaStateCache.has(statePath)) return chatMediaStateCache.get(statePath) ?? null;
try {
const state = JSON.parse(readFileSync(statePath, "utf-8")) as ChatMediaState;
chatMediaStateCache.set(statePath, state);
return state;
} catch {
chatMediaStateCache.set(statePath, null);
return null;
}
}
function localStatePath(workingDir: string, value: unknown): string | null {
if (typeof value !== "string" || !value.trim()) return null;
const trimmed = value.trim();
if (/^file:\/\//i.test(trimmed)) return fileUrlPath(trimmed);
if (/^https?:\/\//i.test(trimmed)) return null;
const expanded = trimmed.startsWith("~") ? path.join(homedir(), trimmed.slice(1)) : trimmed;
return path.resolve(path.isAbsolute(expanded) ? expanded : path.join(workingDir, expanded));
}
function fileUrlPath(value: unknown): string | null {
if (typeof value !== "string" || !/^file:\/\//i.test(value)) return null;
try {
return path.resolve(fileURLToPath(value));
} catch {
return null;
}
}
function samePath(a: string, b: string): boolean {
return path.resolve(a) === path.resolve(b);
}
function isUnderDirectory(filePath: string, directory: string): boolean {
const relative = path.relative(directory, filePath);
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function generationCacheKey(kind: "image" | "working" | "attachment" | "project", filePath: string): string {
return `${kind}:${path.resolve(filePath)}`;
}
function fileStatFingerprint(filePath: string): string {
const stat = statSync(filePath);
return `${stat.size}:${Math.round(stat.mtimeMs)}`;
}
function optionalFileStatFingerprint(filePath: string): string {
try {
return existsSync(filePath) ? fileStatFingerprint(filePath) : "missing";
} catch {
return "missing";
}
}
function sourceFileFingerprint(filePath: string): string {
return `file:${GENERATION_INDEX_CACHE_DERIVATION_VERSION}:${fileStatFingerprint(filePath)}`;
}
const ATTACHMENT_METADATA_CACHE_VERSION = "v2-original-name";
function attachmentSourceFingerprint(filePath: string): string {
return `attachment:${ATTACHMENT_METADATA_CACHE_VERSION}:${GENERATION_INDEX_CACHE_DERIVATION_VERSION}:${fileStatFingerprint(filePath)}:meta:${optionalFileStatFingerprint(`${filePath}.metadata.json`)}`;
}
export function projectSourceFingerprint(projectPath: string): string {
return `project:${fileStatFingerprint(projectPath)}:wal:${optionalFileStatFingerprint(`${projectPath}-wal`)}`;
}
function loadGenerationIndexCache(): GenerationIndexCacheFile {
if (generationIndexCache) return generationIndexCache;
migrateLegacyFileIfMissing(GENERATION_INDEX_CACHE_PATH, LEGACY_GENERATION_INDEX_CACHE_PATH);
try {
const parsed = JSON.parse(readFileSync(GENERATION_INDEX_CACHE_PATH, "utf-8")) as GenerationIndexCacheFile;
if (parsed?.entries && typeof parsed.entries === "object") {
generationIndexCache = { version: GENERATION_INDEX_CACHE_VERSION, entries: parsed.entries };
return generationIndexCache;
}
} catch {
// Missing or invalid cache: rebuild lazily.
}
generationIndexCache = { version: GENERATION_INDEX_CACHE_VERSION, entries: {} };
return generationIndexCache;
}
function getCachedGenerations(cacheKey: string, fingerprint: string): GenerationMetadata[] | null {
const entry = loadGenerationIndexCache().entries[cacheKey];
if (!entry || entry.fingerprint !== fingerprint || !Array.isArray(entry.generations)) return null;
return entry.generations;
}
function setCachedGenerations(cacheKey: string, fingerprint: string, generations: GenerationMetadata[]): void {
const previousTagsByImagePath = new Map<string, string[]>();
for (const generation of loadGenerationIndexCache().entries[cacheKey]?.generations ?? []) {
for (const imagePath of generation.imagePaths ?? []) {
if (generation.tags?.length) previousTagsByImagePath.set(imagePath, generation.tags);
}
}
for (const generation of generations) {
const inheritedTags = (generation.imagePaths ?? []).flatMap((imagePath) => previousTagsByImagePath.get(imagePath) ?? []);
if (inheritedTags.length) generation.tags = [...new Set(inheritedTags)];
}
loadGenerationIndexCache().entries[cacheKey] = { fingerprint, generations };
}
function pruneGenerationCache(activeKeys: Set<string>): void {
const cache = loadGenerationIndexCache();
for (const key of Object.keys(cache.entries)) {
if (!activeKeys.has(key)) delete cache.entries[key];
}
}
function saveGenerationIndexCache(): void {
const cache = loadGenerationIndexCache();
mkdirSync(path.dirname(GENERATION_INDEX_CACHE_PATH), { recursive: true });
writeFileSync(GENERATION_INDEX_CACHE_PATH, JSON.stringify(cache));
}
export function findCachedGenerationsByImagePaths(imagePaths: string[]): GenerationMetadata[] {
const targetPaths = new Set(imagePaths);
const matched: GenerationMetadata[] = [];
const visited = new Set<GenerationMetadata>();
const addMatches = (generations: GenerationMetadata[]): void => {
for (const generation of generations) {
if (visited.has(generation) || !(generation.imagePaths ?? []).some((imagePath) => targetPaths.has(imagePath))) continue;
visited.add(generation);
matched.push(generation);
}
};
for (const entry of Object.values(loadGenerationIndexCache().entries)) addMatches(entry.generations);
return matched;
}
export function listCachedGenerationTags(): string[] {
const tagsByNormalizedValue = new Map<string, string>();
for (const entry of Object.values(loadGenerationIndexCache().entries)) {
for (const generation of entry.generations) {
for (const tag of generation.tags ?? []) {
const trimmedTag = tag.trim();
if (trimmedTag && !tagsByNormalizedValue.has(trimmedTag.toLocaleLowerCase())) {
tagsByNormalizedValue.set(trimmedTag.toLocaleLowerCase(), trimmedTag);
}
}
}
}
return [...tagsByNormalizedValue.values()].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" }));
}
async function cachedImageContentHash(imagePath: string): Promise<string | undefined> {
if (imagePath.startsWith("project://") || !existsSync(imagePath)) return undefined;
try {
return createHash("sha256").update(await readFile(imagePath)).digest("hex");
} catch {
return undefined;
}
}
async function populateImageContentHashes(generations: GenerationMetadata[]): Promise<void> {
const projectThumbnails = new Map<string, number[]>();
for (const generation of generations) {
if (generation.contentHash) continue;
const imageRef = generation.imagePaths?.find((candidate) => candidate.startsWith("project://"));
const parsed = imageRef ? parseProjectUri(imageRef) : null;
if (!parsed) continue;
const thumbnailIds = projectThumbnails.get(parsed.projectPath) ?? [];
thumbnailIds.push(parsed.thumbnailId);
projectThumbnails.set(parsed.projectPath, thumbnailIds);
}
const hashesByImageRef = new Map<string, string>();
for (const [projectPath, thumbnailIds] of projectThumbnails) {
const thumbnails = await getThumbnailsFromProject(projectPath, thumbnailIds);
for (const [thumbnailId, bytes] of thumbnails) {
hashesByImageRef.set(
`project://${projectPath}#${thumbnailId}`,
createHash("sha256").update(bytes).digest("hex"),
);
}
}
for (const generation of generations) {
const imageRef = generation.imagePaths?.find((candidate) => candidate.startsWith("project://"));
if (imageRef && !generation.contentHash) generation.contentHash = hashesByImageRef.get(imageRef);
if (!generation.contentHash) {
const imagePath = generation.imagePaths?.find((candidate) => !candidate.startsWith("project://"));
if (imagePath) generation.contentHash = await cachedImageContentHash(imagePath);
}
}
}
export async function expandCachedImagePathsByContentIdentity(imagePaths: string[]): Promise<string[]> {
const targetPaths = new Set(imagePaths);
const targetHashes = new Set<string>();
const cache = loadGenerationIndexCache();
let cacheChanged = false;
for (const entry of Object.values(cache.entries)) {
for (const generation of entry.generations) {
for (const imagePath of generation.imagePaths ?? []) {
if (!targetPaths.has(imagePath)) continue;
if (!generation.contentHash) {
generation.contentHash = await cachedImageContentHash(imagePath);
cacheChanged ||= Boolean(generation.contentHash);
}
if (generation.contentHash) targetHashes.add(generation.contentHash);
}
}
}
if (targetHashes.size === 0) return imagePaths;
const expandedPaths = new Set(imagePaths);
for (const entry of Object.values(cache.entries)) {
for (const generation of entry.generations) {
const generationPaths = generation.imagePaths ?? [];
if (generationPaths.length === 0 || generationPaths.every((imagePath) => imagePath.startsWith("project://"))) continue;
if (!generation.contentHash) {
const imagePath = generationPaths.find((candidate) => !candidate.startsWith("project://"));
if (imagePath) {
generation.contentHash = await cachedImageContentHash(imagePath);
cacheChanged ||= Boolean(generation.contentHash);
}
}
if (generation.contentHash && targetHashes.has(generation.contentHash)) {
for (const imagePath of generationPaths) expandedPaths.add(imagePath);
}
}
}
if (cacheChanged) saveGenerationIndexCache();
return [...expandedPaths];
}
export function updateGenerationTags(
imagePaths: string[],
action: "add_tag" | "remove_tag" | "remove_all_tags",
tags: string[],
): GenerationMetadata[] {
const targetPaths = new Set(imagePaths);
const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
const changed: GenerationMetadata[] = [];
const visited = new Set<GenerationMetadata>();
const update = (generation: GenerationMetadata): void => {
if (visited.has(generation)) return;
visited.add(generation);
if (!(generation.imagePaths ?? []).some((imagePath) => targetPaths.has(imagePath))) return;
const byNormalizedTag = new Map((generation.tags ?? []).map((tag) => [tag.toLocaleLowerCase(), tag]));
if (action === "add_tag") {
for (const tag of normalizedTags) byNormalizedTag.set(tag.toLocaleLowerCase(), tag);
} else if (action === "remove_tag") {
for (const tag of normalizedTags) byNormalizedTag.delete(tag.toLocaleLowerCase());
} else {
byNormalizedTag.clear();
}
generation.tags = [...byNormalizedTag.values()].sort((left, right) => left.localeCompare(right));
changed.push(generation);
};
for (const entry of Object.values(loadGenerationIndexCache().entries)) {
for (const generation of entry.generations) update(generation);
}
if (changed.length) saveGenerationIndexCache();
return changed;
}
function sourceTypeForGeneration(gen: GenerationMetadata): string | undefined {
const sourceInfo = gen.sourceInfo;
if (!sourceInfo) return undefined;
if (sourceInfo.type === "variant") return "variant";
if (sourceInfo.type === "image") return "image";
if (sourceInfo.type === "picture") return "picture";
if (sourceInfo.type === "attachment") return "attachment";
if (sourceInfo.type === "saved_image") return "saved_image";
if (sourceInfo.type === "draw_things_project") return "draw_things_project";
return undefined;
}
/**
* Get cached generation count (for status display)
*/
export function getCachedCount(): number {
return cachedGenerations?.length || 0;
}
/**
* Force cache invalidation (called by FileWatchers)
*/
export function invalidateCache(): void {
cacheRevision++;
cachedGenerations = null;
console.log("[Index] Cache invalidated");
}
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
async function findFiles(dir: string, pattern: string): Promise<string[]> {
try {
return await glob(path.join(dir, pattern), {
nodir: true,
absolute: true,
});
} catch {
return [];
}
}
function parsedImageToMetadata(
meta: ParsedImageMetadata,
filePath: string,
sourceInfo: SourceInfo
): GenerationMetadata {
return {
timestamp: meta.timestamp,
prompt: meta.prompt || '',
negativePrompt: meta.negativePrompt,
model: meta.model || 'unknown',
loras: meta.loras,
sampler: meta.sampler,
steps: meta.steps,
cfgScale: meta.cfgScale,
seed: meta.seed,
seedMode: meta.seedMode,
shift: meta.shift,
strength: meta.strength,
mode: meta.mode,
width: meta.width,
height: meta.height,
imagePaths: [filePath],
sourceInfo,
};
}
function imageTypeForPngMetadata(metadata: ParsedImageMetadata): string | undefined {
return metadata.creatorTool
?? (metadata.rawType === 'comfyui'
? 'ComfyUI'
: metadata.rawType === 'drawthings'
? 'Draw Things'
: undefined);
}
async function imageFileToMetadata(filePath: string, sourceInfo: SourceInfo): Promise<GenerationMetadata> {
const metadata = await readCameraImageMetadata(filePath);
const hasCameraMetadata = Boolean(metadata.camera || metadata.capturedAt || metadata.latitude !== undefined || metadata.longitude !== undefined);
const cameraSourceInfo = hasCameraMetadata && sourceInfo.type !== "draw_things_project"
? { ...sourceInfo, imageType: "Camera photo" } as SourceInfo
: sourceInfo;
const fallbackName = sourceInfo.type === "attachment" ? sourceInfo.originalName : path.basename(filePath);
return {
prompt: exifMetadataPrompt(metadata, fallbackName),
model: metadata.camera ?? 'unknown',
timestamp: metadata.capturedAt,
latitude: metadata.latitude,
longitude: metadata.longitude,
lensModel: metadata.lensModel,
exposureTime: metadata.exposureTime,
fNumber: metadata.fNumber,
iso: metadata.iso,
exposureCompensation: metadata.exposureCompensation,
focalLength: metadata.focalLength,
focalLength35mm: metadata.focalLength35mm,
exposureProgram: metadata.exposureProgram,
meteringMode: metadata.meteringMode,
whiteBalance: metadata.whiteBalance,
flash: metadata.flash,
orientation: metadata.orientation,
exposureMode: metadata.exposureMode,
width: metadata.width,
height: metadata.height,
imagePaths: [filePath],
sourceInfo: cameraSourceInfo,
};
}
function exifMetadataPrompt(metadata: Awaited<ReturnType<typeof readCameraImageMetadata>>, fallbackName: string): string {
const exposure = [
metadata.exposureTime ? `${metadata.exposureTime} s` : undefined,
metadata.fNumber !== undefined ? `f/${metadata.fNumber}` : undefined,
metadata.iso !== undefined ? `ISO ${metadata.iso}` : undefined,
metadata.exposureCompensation !== undefined ? `${metadata.exposureCompensation >= 0 ? "+" : ""}${metadata.exposureCompensation} EV` : undefined,
].filter((value): value is string => Boolean(value));
const program = [
metadata.exposureProgram ? `Program: ${metadata.exposureProgram}` : undefined,
metadata.meteringMode ? `metering: ${metadata.meteringMode}` : undefined,
metadata.whiteBalance ? `white balance: ${metadata.whiteBalance}` : undefined,
metadata.exposureMode ? `exposure mode: ${metadata.exposureMode}` : undefined,
].filter((value): value is string => Boolean(value));
const fields = [
metadata.capturedAt ? `Captured: ${metadata.capturedAt}` : undefined,
metadata.camera ? `Camera: ${metadata.camera}` : undefined,
metadata.lensModel ? `Lens: ${metadata.lensModel}` : undefined,
exposure.length ? `Exposure: ${exposure.join(", ")}` : undefined,
metadata.focalLength !== undefined ? `Focal length: ${metadata.focalLength} mm${metadata.focalLength35mm === undefined ? "" : ` (${metadata.focalLength35mm} mm equivalent)`}` : undefined,
program.length ? program.join("; ") : undefined,
metadata.flash ? `Flash: ${metadata.flash}` : undefined,
metadata.orientation ? `Orientation: ${metadata.orientation}` : undefined,
metadata.latitude !== undefined && metadata.longitude !== undefined ? `GPS: ${metadata.latitude}, ${metadata.longitude}` : undefined,
].filter((value): value is string => Boolean(value));
return fields.length ? ["EXIF photograph", ...fields].join("\n") : fallbackName;
}
/**
* Get original filename from LM Studio attachment metadata
* Format: <file>.metadata.json contains { originalName: "..." }
*/
async function getOriginalName(pngPath: string): Promise<string | undefined> {
try {
const metadataPath = `${pngPath}.metadata.json`;
if (existsSync(metadataPath)) {
const content = readFileSync(metadataPath, 'utf-8');
const metadata = JSON.parse(content);
return metadata.originalName;
}
} catch (e) {
// Ignore errors
}
return undefined;
}