src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { readdir, stat } from "fs/promises";
import { basename, dirname, extname, isAbsolute, join, normalize, relative } from "path";
import { z } from "zod";
const IMAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
]);
const MAX_ANALYSIS_TOKENS_HARD_CAP = 2048;
const ANALYSIS_TOKENS_SOFT_TARGET = 512;
type FoundImage = {
relativePath: string;
absolutePath: string;
sizeBytes: number;
modifiedAt: string;
};
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const analyzeLocalImageTool = tool({
name: "Analyze Local Image",
description:
"Allows you to analyze one local image from the working directory.",
parameters: {
imageName: z
.string()
.describe("Image file name from List Local Images, e.g. '1774334299591-9-thumb.webp'."),
prompt: z
.string()
.describe("Required analysis task/question. Keep it clear and specific."),
context: z
.string()
.describe("Required known context for this image and task (source, intent, constraints, known facts, prior findings)."),
},
implementation: async ({ imageName, prompt, context }, { status, warn }) => {
const workingDirectory = ctl.getWorkingDirectory();
const safeImageName = sanitizeRelativeInput(imageName);
if (!safeImageName) {
return "Error: imageName is empty or invalid.";
}
const resolvedImagePath = await resolveImagePathByName(workingDirectory, safeImageName, warn);
if (!resolvedImagePath) {
return `Error: image not found: ${safeImageName}`;
}
status("Preparing image for multimodal model...");
const model = await ctl.client.llm.model();
if (!model.vision) {
return "Error: currently loaded model does not support vision. Load a vision model and retry.";
}
const fileHandle = await ctl.client.files.prepareImage(resolvedImagePath.absolutePath);
const userPrompt = prompt.trim();
const userContext = context.trim();
const analysisPrompt =
`You are a vision assistant. Analyze the provided image and give a concise final answer. ` +
`Do not provide hidden reasoning or step-by-step chain-of-thought. ` +
`If uncertain, state uncertainty briefly. ` +
`Target up to ${ANALYSIS_TOKENS_SOFT_TARGET} tokens in the final answer.\n\n` +
`Known context:\n${userContext}\n\n` +
`User request:\n${userPrompt}`;
const effectiveMaxTokens = MAX_ANALYSIS_TOKENS_HARD_CAP;
status("Running multimodal analysis...");
const result = await model.respond(
[
{
role: "user",
content: analysisPrompt,
images: [fileHandle],
},
],
{
maxTokens: effectiveMaxTokens,
},
);
return result.content;
},
});
return [analyzeLocalImageTool];
}
async function collectImages(
directoryPath: string,
recursive: boolean,
maxResults: number,
warn: (text: string) => void,
): Promise<FoundImage[]> {
const found: FoundImage[] = [];
const queue: string[] = [directoryPath];
const root = directoryPath;
while (queue.length > 0 && found.length < maxResults) {
const current = queue.shift() as string;
let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>;
try {
entries = await readdir(current, { withFileTypes: true });
} catch (error: any) {
warn(`Cannot read directory '${current}': ${error?.message || String(error)}`);
continue;
}
for (const entry of entries) {
if (found.length >= maxResults) break;
const absolutePath = join(current, entry.name);
if (entry.isDirectory()) {
if (recursive) {
queue.push(absolutePath);
}
continue;
}
if (!entry.isFile()) continue;
if (!isImagePath(entry.name)) continue;
try {
const metadata = await stat(absolutePath);
found.push({
absolutePath,
relativePath: normalize(relative(root, absolutePath)).replace(/\\/g, "/"),
sizeBytes: metadata.size,
modifiedAt: metadata.mtime.toISOString(),
});
} catch (error: any) {
warn(`Cannot stat file '${absolutePath}': ${error?.message || String(error)}`);
}
}
}
return found;
}
function isImagePath(value: string): boolean {
return IMAGE_EXTENSIONS.has(extname(value).toLowerCase());
}
function sanitizeRelativeInput(input?: string): string | null {
if (!input) return null;
const trimmed = input.trim();
if (!trimmed) return null;
if (isAbsolute(trimmed)) return null;
const normalized = normalize(trimmed).replace(/\\/g, "/").replace(/^\.\/+/, "");
if (!normalized || normalized.startsWith("../") || normalized.includes("/../")) return null;
return normalized;
}
async function resolveImagePathByName(
workingDirectory: string,
imageName: string,
warn: (text: string) => void,
): Promise<{ absolutePath: string; relativePath: string } | null> {
// 1) Try working-directory root first: <workingDirectory>/<imageName>
const directAbsolutePath = join(workingDirectory, imageName);
const directStats = await stat(directAbsolutePath).catch(() => null);
if (directStats?.isFile() && isImagePath(imageName)) {
return await preferFullImageIfThumb(directAbsolutePath, imageName);
}
// 2) Fallback: recursive basename match across all images
const allImages = await collectImages(workingDirectory, true, 1000, warn);
const targetBasename = basename(imageName).toLowerCase();
const matched = allImages.find((item) => basename(item.relativePath).toLowerCase() === targetBasename);
if (!matched) return null;
return await preferFullImageIfThumb(matched.absolutePath, matched.relativePath);
}
async function preferFullImageIfThumb(
absolutePath: string,
relativePath: string,
): Promise<{ absolutePath: string; relativePath: string }> {
const fileName = basename(relativePath);
const thumbMatch = fileName.match(/^(.*)-thumb\.webp$/i);
if (!thumbMatch) {
return { absolutePath, relativePath };
}
const baseNameWithoutThumb = thumbMatch[1];
const parentDirAbsolute = dirname(absolutePath);
const parentDirRelative = dirname(relativePath).replace(/\\/g, "/");
const candidateExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".avif"];
for (const extension of candidateExtensions) {
const candidateFileName = `${baseNameWithoutThumb}${extension}`;
const candidateAbsolutePath = join(parentDirAbsolute, candidateFileName);
const candidateStats = await stat(candidateAbsolutePath).catch(() => null);
if (!candidateStats?.isFile()) continue;
if (!isImagePath(candidateFileName)) continue;
const candidateRelativePath =
parentDirRelative === "." ? candidateFileName : `${parentDirRelative}/${candidateFileName}`;
return {
absolutePath: candidateAbsolutePath,
relativePath: candidateRelativePath,
};
}
return { absolutePath, relativePath };
}