Project Files
src / tools / fetch_image.ts
/**
* fetch_image — deterministically register all images from a document or LM Studio conversation.
*/
import { tool, type Tool, type ToolCallContext, type ToolsProviderController } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext; runtime is fine
import { z } from "zod";
import fs from "node:fs";
import path from "node:path";
import { globalConfigSchematics, configSchematics } from "../config.js";
import { formatToolMetaBlock, readState } from "../core-bundle.mjs";
import { DocumentLoader } from "../documents/loader.js";
import {
registerDocumentImageFiles,
registerDocumentImagesFromMarkdown,
} from "../helpers/documentImages.js";
import {
defaultLmStudioHome,
} from "../sources/lmStudioConversationMarkdown.js";
import { isLmStudioConversationSource } from "../sources/adapters/lmStudioConversationSourceAdapter.js";
export function createFetchImageTool(ctl: ToolsProviderController): Tool {
return tool({
name: "fetch_image",
description: `Register all images from a local document or LM Studio conversation as pN entries.
Use this tool when the user asks for the images contained in a specific filename/source. It does not search, does not chunk text, and does not return document content.
For LM Studio conversations, fetch_image uses chat_media_state.json from the source chat working directory as the single source of truth. It never renders conversation Markdown.
Parameters:
- filename: A local document filename/path resolved against notesDirectory and contentDirectories. Bare filenames are supported and are resolved recursively by basename, the same way read_doc resolves them. Also accepts an LM Studio chat ID or conversation handle: 1769436015776, l1769436015776, 1769436015776.conversation.json, an absolute .conversation.json path, or lmstudio-conversation://1769436015776.
${formatToolMetaBlock()}`,
parameters: {
filename: z
.string()
.describe(
"Document filename/path resolved against notesDirectory/contentDirectories, or LM Studio chat ID/conversation handle to fetch all images from."
),
},
implementation: async (args, ctx: ToolCallContext) => {
const requestedFilename = String(args.filename ?? "").trim();
if (!requestedFilename) return "fetch_image: filename is required.";
const chatWd = safeWorkingDirectory(ctl);
if (!chatWd) return "fetch_image failed: could not resolve LM Studio chat working directory.";
const pluginConfig = ctl.getPluginConfig(configSchematics);
const previewMaxSum: number = pluginConfig.get("imagePreviewMaxSum") ?? 3072;
const previewQuality: number = pluginConfig.get("imagePreviewQuality") ?? 85;
const conversation = resolveConversationFetchSource(requestedFilename);
if (conversation) {
return fetchConversationImages(conversation, chatWd, previewMaxSum, previewQuality, ctx);
}
const getter: any = (ctl as any).getGlobalPluginConfig || (ctl as any).getGlobalConfig;
const gcfg = getter ? getter.call(ctl, globalConfigSchematics) : null;
const notesDirectory: string = gcfg?.get("notesDirectory") ?? "";
if (!notesDirectory) {
return "Error: notesDirectory is not configured. Set it in the plugin global settings.";
}
const contentDirectories: string[] = Array.isArray(gcfg?.get("contentDirectories"))
? gcfg.get("contentDirectories")
: [];
const filePath = resolveLocalReadPath(requestedFilename, [notesDirectory, ...contentDirectories]);
if (!filePath) {
return `fetch_image: file not found — "${requestedFilename}".`;
}
if (isImageFile(filePath)) {
const registration = await registerDocumentImageFiles({
imagePaths: [filePath],
chatWd,
sourceTool: "fetch_image",
previewMaxSum,
previewQuality,
onStatus: (message) => { try { ctx.status(message); } catch {} },
});
return formatRegistrationResult(registration, requestedFilename);
}
let content: string;
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
return `fetch_image: file not readable as text — "${requestedFilename}".`;
}
const remoteFetchTimeoutMs: number = gcfg?.get("remoteFetchTimeoutMs") ?? 10000;
const remoteMaxBytes: number = gcfg?.get("remoteMaxBytes") ?? 15728640;
const registration = await registerDocumentImagesFromMarkdown({
markdown: content,
documentPath: filePath,
chatWd,
sourceTool: "fetch_image",
sourceKind: "file",
previewMaxSum,
previewQuality,
remoteFetchTimeoutMs,
remoteMaxBytes,
onStatus: (message) => { try { ctx.status(message); } catch {} },
});
return formatRegistrationResult(registration, requestedFilename);
},
});
}
async function fetchConversationImages(
source: { chatId: string; conversationPath: string; sourceChatWd: string },
targetChatWd: string,
previewMaxSum: number,
previewQuality: number,
ctx: ToolCallContext
): Promise<string> {
const statePath = path.join(source.sourceChatWd, "chat_media_state.json");
if (!fs.existsSync(statePath)) {
return `fetch_image: chat_media_state.json not found for conversation ${source.chatId}.`;
}
const state = await readState(source.sourceChatWd);
const imagePaths = collectImagePathsFromState(state, source.sourceChatWd);
if (imagePaths.length === 0) {
return `fetch_image: no images found in chat_media_state.json for conversation ${source.chatId}.`;
}
const registration = await registerDocumentImageFiles({
imagePaths,
chatWd: targetChatWd,
sourceTool: "fetch_image",
previewMaxSum,
previewQuality,
onStatus: (message) => { try { ctx.status(message); } catch {} },
});
return formatRegistrationResult(registration, `conversation ${source.chatId}`);
}
function collectImagePathsFromState(state: any, sourceChatWd: string): string[] {
const refs: string[] = [];
const seen = new Set<string>();
function add(raw: unknown): void {
if (typeof raw !== "string" || !raw.trim()) return;
const resolved = resolveStateFilePath(raw, sourceChatWd);
if (!resolved || !fs.existsSync(resolved) || !isImageFile(resolved)) return;
let key = resolved;
try { key = fs.realpathSync(resolved); } catch {}
if (seen.has(key)) return;
seen.add(key);
refs.push(resolved);
}
for (const rec of Array.isArray(state?.pictures) ? state.pictures : []) add(rec?.filename);
for (const rec of Array.isArray(state?.images) ? state.images : []) add(rec?.filename);
for (const rec of Array.isArray(state?.variants) ? state.variants : []) add(rec?.filename);
for (const rec of Array.isArray(state?.attachments) ? state.attachments : []) {
add(rec?.originAbs);
if (!rec?.originAbs) add(rec?.filename);
}
return refs;
}
function resolveStateFilePath(value: string, sourceChatWd: string): string | null {
const raw = value.trim();
if (!raw) return null;
if (/^file:\/\//i.test(raw)) {
try { return new URL(raw).pathname; } catch { return null; }
}
return path.isAbsolute(raw) ? raw : path.join(sourceChatWd, raw);
}
function formatRegistrationResult(registration: { count: number; assignedKeys: string[]; remoteSkips: Record<string, number> }, sourceLabel: string): string {
const remoteSkipEntries = Object.entries(registration.remoteSkips ?? {});
const suffix = remoteSkipEntries.length > 0
? `\nRemote image candidates skipped: ${remoteSkipEntries.map(([reason, count]) => `${count} (${reason})`).join(", ")}.`
: "";
if (registration.assignedKeys.length > 0) {
const keyList = registration.assignedKeys.map((key) => `"${key}"`).join(", ");
return `fetch_image: registered ${registration.assignedKeys.length} image${registration.assignedKeys.length > 1 ? "s" : ""} from ${sourceLabel} as ${registration.assignedKeys.join(", ")}. Call: review_image({"targets":[${keyList}]})${suffix}`;
}
if (registration.count > 0) {
return `fetch_image: registered ${registration.count} image${registration.count > 1 ? "s" : ""} from ${sourceLabel}; p-index unavailable.${suffix}`;
}
return `fetch_image: no images found in ${sourceLabel}.${suffix}`;
}
function resolveConversationFetchSource(filename: string): { chatId: string; conversationPath: string; sourceChatWd: string } | null {
const conversationPath = resolveConversationPath(filename);
if (!conversationPath) return null;
const base = path.basename(conversationPath);
const match = base.match(/^(\d{13})\.conversation\.json$/i);
if (!match) return null;
const chatId = match[1];
return {
chatId,
conversationPath,
sourceChatWd: path.join(defaultLmStudioHome(), "working-directories", chatId),
};
}
function resolveConversationPath(filename: string): string | null {
if (!filename) return null;
if (/^l\d{13}$/i.test(filename)) {
const chatId = filename.slice(1);
return path.join(defaultLmStudioHome(), "conversations", `${chatId}.conversation.json`);
}
if (/^\d{13}$/i.test(filename)) {
return path.join(defaultLmStudioHome(), "conversations", `${filename}.conversation.json`);
}
if (/^lmstudio-conversation:\/\/\d{13}$/i.test(filename)) {
const chatId = filename.replace(/^lmstudio-conversation:\/\//i, "");
return path.join(defaultLmStudioHome(), "conversations", `${chatId}.conversation.json`);
}
if (/^lmstudio-conversations:\/\/\d{13}$/i.test(filename)) {
const chatId = filename.replace(/^lmstudio-conversations:\/\//i, "");
return path.join(defaultLmStudioHome(), "conversations", `${chatId}.conversation.json`);
}
if (/^\d{13}\.conversation\.json$/i.test(filename)) {
return path.join(defaultLmStudioHome(), "conversations", filename);
}
if (path.isAbsolute(filename) && isLmStudioConversationSource(filename)) return filename;
return null;
}
function safeWorkingDirectory(ctl: ToolsProviderController): string | null {
try {
const workingDir = ctl.getWorkingDirectory();
return typeof workingDir === "string" && workingDir.trim() ? workingDir : null;
} catch {
return null;
}
}
function resolveLocalReadPath(filename: string, roots: string[]): string | null {
const requested = filename.trim();
if (!requested) return null;
if (path.isAbsolute(requested)) {
if (fs.existsSync(requested) && (DocumentLoader.isSupported(requested) || isImageFile(requested))) return requested;
return null;
}
const normalized = requested.replace(/\\/g, "/");
const uniqueRoots = Array.from(new Set(roots.filter((root) => typeof root === "string" && root.trim())));
for (const root of uniqueRoots) {
const directPath = path.resolve(root, normalized);
if (isInsideDirectory(directPath, root) && fs.existsSync(directPath) && (DocumentLoader.isSupported(directPath) || isImageFile(directPath))) {
return directPath;
}
}
const requestedBase = path.basename(normalized).toLowerCase();
if (!requestedBase) return null;
for (const root of uniqueRoots) {
const found = findFileByBasename(root, requestedBase);
if (found) return found;
}
return null;
}
function findFileByBasename(root: string, requestedBase: string): string | null {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return null;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
const childPath = path.join(root, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === requestedBase && (DocumentLoader.isSupported(childPath) || isImageFile(childPath))) {
return childPath;
}
if (entry.isDirectory()) {
const found = findFileByBasename(childPath, requestedBase);
if (found) return found;
}
}
return null;
}
function isInsideDirectory(filePath: string, root: string): boolean {
const relative = path.relative(path.resolve(root), filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isImageFile(filePath: string): boolean {
return /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(filePath);
}