Forked from vadimfedenko/analyze-images
Forked from vadimfedenko/analyze-images
dist / toolsProvider.js
dist / toolsProvider.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.toolsProvider = toolsProvider;
const sdk_1 = require("@lmstudio/sdk");
const promises_1 = require("fs/promises");
const compatibleImage_1 = require("./compatibleImage");
const path_1 = require("path");
const zod_1 = require("zod");
const attachmentIdentity_1 = require("./attachmentIdentity");
const config_1 = require("./config");
const strataAnalysis_1 = require("./strataAnalysis");
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;
async function toolsProvider(ctl) {
const listLocalImagesTool = (0, sdk_1.tool)({
name: "List Local Images Advanced",
description: "Lists local image files in the working directory, including sidecar metadata such as captions and source URLs when available. Use this before choosing images to analyze; do not infer image identity from local file numbering.",
parameters: {
recursive: zod_1.z.boolean().optional().default(false).describe("Whether to search subdirectories."),
maxResults: zod_1.z.number().int().min(1).max(200).optional().default(50).describe("Maximum number of image files to return."),
},
implementation: async ({ recursive, maxResults }, { status, warn }) => {
status("Listing local images...");
const workingDirectory = ctl.getWorkingDirectory();
const images = await collectImages(workingDirectory, recursive ?? false, maxResults ?? 50, warn);
return {
count: images.length,
images: images.map((image) => ({
name: image.relativePath,
sizeBytes: image.sizeBytes,
modifiedAt: image.modifiedAt,
...(image.metadataPath ? { metadataPath: image.metadataPath } : {}),
...(image.caption ? { caption: image.caption } : {}),
...(image.sourcePageUrl ? { sourcePageUrl: image.sourcePageUrl } : {}),
...(image.downloadedAt ? { downloadedAt: image.downloadedAt } : {}),
})),
hint: images.length
? "If the user is asking for a described image, such as a caption or subject, use Search Local Images Advanced before Analyze Local Image Advanced. Use exact image names from the search result. Do not infer image identity from local file numbering or JSON-LD ordering."
: "No local images were found in the working directory.",
};
},
});
const searchLocalImagesTool = (0, sdk_1.tool)({
name: "Search Local Images Advanced",
description: "Searches local images by sidecar metadata such as caption, alt text, source URL, original image URL, and file name. Use this tool before Analyze Local Image Advanced whenever the user refers to an image by description, caption, topic, source, or page context instead of an exact local file name. This prevents confusing local file numbering with JSON-LD image ordering.",
parameters: {
query: zod_1.z
.string()
.describe("Search text such as a caption, subject, source page, or phrase to find matching local images."),
recursive: zod_1.z
.boolean()
.optional()
.default(false)
.describe("Whether to search subdirectories."),
maxResults: zod_1.z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Maximum number of matching images to return."),
},
implementation: async ({ query, recursive, maxResults }, { status, warn }) => {
status("Searching local images...");
const workingDirectory = ctl.getWorkingDirectory();
const images = await collectImages(workingDirectory, recursive ?? false, 200, warn);
const terms = query
.toLowerCase()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean);
if (terms.length === 0) {
return {
count: 0,
images: [],
hint: "Search query was empty.",
};
}
const scored = images
.map((image) => {
const haystack = [
image.relativePath,
image.metadataPath,
image.caption,
image.sourcePageUrl,
image.downloadedAt,
]
.filter(Boolean)
.join("\n")
.toLowerCase();
const score = terms.reduce((total, term) => {
if (!haystack.includes(term))
return total;
let bonus = 1;
if (image.caption?.toLowerCase().includes(term))
bonus += 5;
if (image.sourcePageUrl?.toLowerCase().includes(term))
bonus += 2;
if (image.relativePath.toLowerCase().includes(term))
bonus += 1;
return total + bonus;
}, 0);
return { image, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults ?? 10);
return {
count: scored.length,
images: scored.map(({ image, score }) => ({
name: image.relativePath,
score,
sizeBytes: image.sizeBytes,
modifiedAt: image.modifiedAt,
...(image.metadataPath ? { metadataPath: image.metadataPath } : {}),
...(image.caption ? { caption: image.caption } : {}),
...(image.sourcePageUrl ? { sourcePageUrl: image.sourcePageUrl } : {}),
...(image.downloadedAt ? { downloadedAt: image.downloadedAt } : {}),
})),
hint: scored.length
? "Use these exact image names with Analyze Local Image Advanced. Do not infer image identity from local file numbering or JSON-LD ordering."
: "No matching local images were found. Try List Local Images Advanced to inspect available files.",
};
},
});
const analyzeLocalImageTool = (0, sdk_1.tool)({
name: "Analyze Local Image Advanced",
description: "Analyzes one exact local image from the working directory and returns the completed visual findings as a tool result. With Strata, waits for a separate image-analysis request; otherwise uses the loaded vision model. " +
"IMPORTANT: Before using this tool, first use Search Local Images Advanced or List Local Images Advanced and confirm the exact local file name together with its caption, source page, or metadata. " +
"Do not call this tool directly based only on local numbering, JSON-LD image order, or guessed file identity. " +
"If the image identity has not been confirmed from list/search metadata, use Search Local Images Advanced first.",
parameters: {
imageName: zod_1.z
.string()
.describe("Exact image file name confirmed by Search Local Images Advanced or List Local Images Advanced. " +
"Do not guess the file name from numbering or JSON-LD ordering."),
prompt: zod_1.z
.string()
.describe("Required analysis task/question. Keep it clear and specific."),
context: zod_1.z
.string()
.optional()
.default("")
.describe("Optional known context for this image and task, such as source, intent, constraints, known facts, or prior findings."),
},
implementation: async ({ imageName, prompt, context }, { status, warn, signal }) => {
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 backend = ctl.getPluginConfig(config_1.configSchematics).get("analysisBackend");
const model = backend === "strata" ? undefined : await ctl.client.llm.model();
const isStrataHelper = model?.identifier === "strata-vision-primer"
|| model?.path === "ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf";
const useStrata = backend === "strata" || (backend === "auto" && isStrataHelper);
if (isStrataHelper && !useStrata)
return "Error: the Strata UI helper cannot analyze images. Select Auto or Strata as Image analysis backend.";
if (!useStrata && !model?.vision) {
return "Error: currently loaded model does not support vision. Load a vision model and retry.";
}
const sidecarMetadata = await readImageSidecarMetadata(resolvedImagePath.absolutePath, resolvedImagePath.relativePath);
if (!sidecarMetadata) {
return {
error: "image_identity_not_verified",
imageName: resolvedImagePath.relativePath,
message: "No sidecar metadata was found for this image. " +
"Use Search Local Images Advanced or List Local Images Advanced first to confirm the correct image before analysis.",
};
}
if (sidecarMetadata?.localMetadata) {
status(`Loaded image metadata: ${sidecarMetadata.localMetadata}`);
}
const userPrompt = prompt.trim();
const userContext = (context || "").trim();
const metadataContext = sidecarMetadata
? buildImageMetadataContext(sidecarMetadata)
: "";
const contextParts = [
metadataContext,
userContext ? `User-provided context:\n${userContext}` : "",
].filter(Boolean);
const contextBlock = contextParts.length
? `Known context:\n${contextParts.join("\n\n")}\n\n`
: "";
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` +
contextBlock +
`User request:\n${userPrompt}`;
const effectiveMaxTokens = MAX_ANALYSIS_TOKENS_HARD_CAP;
status("Running multimodal analysis...");
const candidate = await (0, attachmentIdentity_1.makeCandidate)(workingDirectory, resolvedImagePath.relativePath).catch(() => undefined);
if (useStrata) {
if (!candidate)
return { error: "image_handoff_failed", message: "Could not verify the image for Strata. The UI helper cannot perform analysis." };
status("Analyzing the verified image with Strata in a separate request...");
const analysis = await (0, strataAnalysis_1.analyzeWithStrata)(ctl, resolvedImagePath.relativePath, analysisPrompt, signal);
status("Strata image analysis completed.");
return { analysis, _aiaImageCandidate: candidate };
}
let fileHandle;
try {
fileHandle = await prepareImageForModel(ctl, resolvedImagePath.absolutePath);
}
catch (error) {
warn(`Failed to prepare image: ${error?.message || String(error)}`);
return { error: "image_prepare_failed", imageName: resolvedImagePath.relativePath, message: `Failed to prepare the image: ${error?.message || String(error)}` };
}
const result = await model.respond([
{
role: "user",
content: analysisPrompt,
images: [fileHandle],
},
], {
maxTokens: effectiveMaxTokens,
});
const analysis = cleanModelResponse(result.content);
return candidate ? { analysis, _aiaImageCandidate: candidate } : analysis;
},
});
const multiAnalyzeTool = (0, sdk_1.tool)({
name: "Multi Analyze Local Images Advanced",
description: "Compare or analyze 2 to 4 exact local images together in ONE vision inference, preserving the requested order. First use List/Search to verify filenames and source metadata. Do not infer image identity from numbering. Returns completed joint analysis and image-to-source mapping. Use the single-image Analyze tool for one image.",
parameters: {
imageNames: zod_1.z.array(zod_1.z.string().min(1)).min(2).max(4).describe("Exact image paths confirmed by List/Search, in comparison order. Identical image bytes are deduplicated."),
prompt: zod_1.z.string().trim().min(1).describe("Joint analysis or comparison task for all images."),
context: zod_1.z.string().optional().default("").describe("Optional shared context; source metadata is included for each image."),
},
implementation: async ({ imageNames, prompt, context }, { status, warn, signal }) => {
signal.throwIfAborted();
const root = ctl.getWorkingDirectory();
const images = [];
let sourceBytes = 0;
status("Verifying comparison images and source metadata...");
for (const requested of imageNames) {
signal.throwIfAborted();
const safe = sanitizeRelativeInput(requested);
if (!safe)
return { error: "invalid_image_path", imageName: requested };
const resolved = await resolveImagePathByName(root, safe, warn);
if (!resolved)
return { error: "image_not_found", imageName: safe };
try {
const input = await (0, attachmentIdentity_1.readCandidate)(root, resolved.relativePath);
const duplicate = images.find(image => image.candidate.sha256 === input.candidate.sha256);
if (duplicate) {
duplicate.requestedNames.push(requested);
continue;
}
const metadata = await readImageSidecarMetadata(resolved.absolutePath, resolved.relativePath);
if (!metadata)
return { error: "image_identity_not_verified", imageName: resolved.relativePath, message: "Source metadata is required for every comparison image." };
sourceBytes += input.bytes.length;
if (sourceBytes > 16 * 1024 * 1024)
return { error: "image_size_limit", message: "Comparison images exceed 16 MiB total." };
images.push({ ...input, metadata, requestedNames: [requested] });
}
catch (error) {
return { error: "image_prepare_failed", imageName: requested, message: String(error) };
}
}
if (images.length < 2)
return { error: "not_enough_distinct_images", message: "Comparison requires at least two different images after deduplication." };
const mapping = images.map((image, index) => ({ index: index + 1, imageName: image.candidate.relativePath, requestedNames: image.requestedNames,
...(typeof image.metadata.sourcePageUrl === "string" ? { sourcePageUrl: image.metadata.sourcePageUrl } : {}) }));
const task = `Analyze all ${images.length} attached images TOGETHER in this one request. Refer to them as Image 1, Image 2, etc. in attachment order. Compare visual evidence, distinguish observations from source metadata, and state uncertainty. Give a concise final answer, without hidden reasoning.\n\n`
+ images.map((image, index) => `Image ${index + 1}: ${image.candidate.relativePath}\n${buildImageMetadataContext(image.metadata)}`).join("\n\n")
+ (context?.trim() ? `\n\nShared user context:\n${context.trim()}` : "") + `\n\nUser request:\n${prompt}`;
const backend = ctl.getPluginConfig(config_1.configSchematics).get("analysisBackend");
const model = backend === "strata" ? undefined : await ctl.client.llm.model();
const helper = model?.identifier === "strata-vision-primer" || model?.path === "ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf";
const strata = backend === "strata" || (backend === "auto" && helper);
if (!strata && (helper || !model?.vision))
return { error: "vision_model_required", message: "Select a vision model or the Strata analysis backend." };
signal.throwIfAborted();
status(`Comparing ${images.length} images in one ${strata ? "Strata" : "local"} inference...`);
let analysis;
if (strata) {
analysis = await (0, strataAnalysis_1.analyzeImagesWithStrata)(ctl, images, task, signal);
}
else {
const files = [];
let preparedBytes = 0;
for (const image of images) {
signal.throwIfAborted();
const file = await (0, compatibleImage_1.compatibleImage)(ctl.client, (0, path_1.basename)(image.candidate.relativePath), image.bytes);
preparedBytes += file.sizeBytes;
if (preparedBytes > 16 * 1024 * 1024)
return { error: "image_size_limit", message: "Converted comparison images exceed 16 MiB total." };
files.push(file);
}
const result = await model.respond([{ role: "user", content: task, images: files }], { maxTokens: MAX_ANALYSIS_TOKENS_HARD_CAP, signal });
analysis = cleanModelResponse(result.content);
}
signal.throwIfAborted();
return { analysis, images: mapping, duplicatesRemoved: imageNames.length - images.length, _aiaImageCandidates: images.map(image => image.candidate) };
},
});
return [listLocalImagesTool, searchLocalImagesTool, analyzeLocalImageTool, multiAnalyzeTool];
}
async function collectImages(directoryPath, recursive, maxResults, warn) {
const found = [];
const queue = [directoryPath];
const root = directoryPath;
while (queue.length > 0 && found.length < maxResults) {
const current = queue.shift();
let entries;
try {
entries = await (0, promises_1.readdir)(current, { withFileTypes: true });
}
catch (error) {
warn(`Cannot read directory '${current}': ${error?.message || String(error)}`);
continue;
}
for (const entry of entries) {
if (found.length >= maxResults)
break;
const absolutePath = (0, path_1.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 (0, promises_1.stat)(absolutePath);
const relativePath = (0, path_1.normalize)((0, path_1.relative)(root, absolutePath)).replace(/\\/g, "/");
const sidecarMetadata = await readImageSidecarMetadata(absolutePath, relativePath);
found.push({
absolutePath,
relativePath,
sizeBytes: metadata.size,
modifiedAt: metadata.mtime.toISOString(),
...(sidecarMetadata?.localMetadata ? { metadataPath: sidecarMetadata.localMetadata } : {}),
...(sidecarMetadata?.caption ? { caption: sidecarMetadata.caption } : {}),
...(sidecarMetadata?.alt && !sidecarMetadata?.caption ? { caption: sidecarMetadata.alt } : {}),
...(sidecarMetadata?.sourcePageUrl ? { sourcePageUrl: sidecarMetadata.sourcePageUrl } : {}),
...(sidecarMetadata?.downloadedAt ? { downloadedAt: sidecarMetadata.downloadedAt } : {}),
});
}
catch (error) {
warn(`Cannot stat file '${absolutePath}': ${error?.message || String(error)}`);
}
}
}
return found;
}
function isImagePath(value) {
return IMAGE_EXTENSIONS.has((0, path_1.extname)(value).toLowerCase());
}
function sanitizeRelativeInput(input) {
if (!input)
return null;
const trimmed = input.trim();
if (!trimmed)
return null;
if ((0, path_1.isAbsolute)(trimmed))
return null;
const normalized = (0, path_1.normalize)(trimmed).replace(/\\/g, "/").replace(/^\.\/+/, "");
if (!normalized || normalized.startsWith("../") || normalized.includes("/../"))
return null;
return normalized;
}
async function resolveImagePathByName(workingDirectory, imageName, warn) {
// 1) Try working-directory root first: <workingDirectory>/<imageName>
const directAbsolutePath = (0, path_1.join)(workingDirectory, imageName);
const directStats = await (0, promises_1.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 = (0, path_1.basename)(imageName).toLowerCase();
const matched = allImages.find((item) => (0, path_1.basename)(item.relativePath).toLowerCase() === targetBasename);
if (!matched)
return null;
return await preferFullImageIfThumb(matched.absolutePath, matched.relativePath);
}
async function preferFullImageIfThumb(absolutePath, relativePath) {
const fileName = (0, path_1.basename)(relativePath);
const thumbMatch = fileName.match(/^(.*)-thumb\.webp$/i);
if (!thumbMatch) {
return { absolutePath, relativePath };
}
const baseNameWithoutThumb = thumbMatch[1];
const parentDirAbsolute = (0, path_1.dirname)(absolutePath);
const parentDirRelative = (0, path_1.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 = (0, path_1.join)(parentDirAbsolute, candidateFileName);
const candidateStats = await (0, promises_1.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 };
}
async function readImageSidecarMetadata(absoluteImagePath, relativeImagePath) {
const candidates = getSidecarMetadataCandidates(absoluteImagePath, relativeImagePath);
for (const candidate of candidates) {
try {
const raw = await (0, promises_1.readFile)(candidate.absolutePath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object")
continue;
return parsed;
}
catch {
// Try next candidate
}
}
return null;
}
function getSidecarMetadataCandidates(absoluteImagePath, relativeImagePath) {
const absoluteDir = (0, path_1.dirname)(absoluteImagePath);
const relativeDir = (0, path_1.dirname)(relativeImagePath).replace(/\\/g, "/");
const imageFileName = (0, path_1.basename)(relativeImagePath);
const withoutThumb = imageFileName.replace(/-thumb\.webp$/i, "");
const withoutExtension = withoutThumb.replace(/\.[^.]+$/i, "");
const metadataFileName = `${withoutExtension}.json`;
const relativeMetadataPath = relativeDir === "." ? metadataFileName : `${relativeDir}/${metadataFileName}`;
return [
{
absolutePath: (0, path_1.join)(absoluteDir, metadataFileName),
relativePath: relativeMetadataPath,
},
];
}
function buildImageMetadataContext(metadata) {
const lines = [];
lines.push("Image metadata from sidecar JSON:");
if (typeof metadata.caption === "string" && metadata.caption.trim()) {
lines.push(`- caption: ${metadata.caption.trim()}`);
}
else if (typeof metadata.alt === "string" && metadata.alt.trim()) {
lines.push(`- alt: ${metadata.alt.trim()}`);
}
if (typeof metadata.sourcePageUrl === "string" && metadata.sourcePageUrl.trim()) {
lines.push(`- source page: ${metadata.sourcePageUrl.trim()}`);
}
if (typeof metadata.url === "string" && metadata.url.trim()) {
lines.push(`- original image URL: ${metadata.url.trim()}`);
}
if (typeof metadata.imageModifiedAt === "string" && metadata.imageModifiedAt.trim()) {
lines.push(`- image modified at: ${metadata.imageModifiedAt.trim()}`);
}
if (typeof metadata.imagePublishedAt === "string" && metadata.imagePublishedAt.trim()) {
lines.push(`- image published at: ${metadata.imagePublishedAt.trim()}`);
}
if (typeof metadata.downloadedAt === "string" && metadata.downloadedAt.trim()) {
lines.push(`- downloaded at: ${metadata.downloadedAt.trim()}`);
}
if (typeof metadata.source === "string" && metadata.source.trim()) {
lines.push(`- metadata source: ${metadata.source.trim()}`);
}
lines.push("Use this metadata only as contextual information. Do not treat alt text, captions, or timestamps as visual proof unless the image itself supports it.");
return lines.join("\n");
}
async function prepareImageForModel(ctl, absoluteImagePath) {
const inputBytes = await (0, promises_1.readFile)(absoluteImagePath);
return await (0, compatibleImage_1.compatibleImage)(ctl.client, (0, path_1.basename)(absoluteImagePath), inputBytes);
}
function cleanModelResponse(content) {
const markerPattern = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[A-Za-z0-9_]+__/;
const parts = content.split(markerPattern);
if (parts.length > 1) {
return parts.slice(1).join("").trim();
}
return content.trim();
}
//# sourceMappingURL=toolsProvider.js.map"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.toolsProvider = toolsProvider;
const sdk_1 = require("@lmstudio/sdk");
const promises_1 = require("fs/promises");
const compatibleImage_1 = require("./compatibleImage");
const path_1 = require("path");
const zod_1 = require("zod");
const attachmentIdentity_1 = require("./attachmentIdentity");
const config_1 = require("./config");
const strataAnalysis_1 = require("./strataAnalysis");
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;
async function toolsProvider(ctl) {
const listLocalImagesTool = (0, sdk_1.tool)({
name: "List Local Images Advanced",
description: "Lists local image files in the working directory, including sidecar metadata such as captions and source URLs when available. Use this before choosing images to analyze; do not infer image identity from local file numbering.",
parameters: {
recursive: zod_1.z.boolean().optional().default(false).describe("Whether to search subdirectories."),
maxResults: zod_1.z.number().int().min(1).max(200).optional().default(50).describe("Maximum number of image files to return."),
},
implementation: async ({ recursive, maxResults }, { status, warn }) => {
status("Listing local images...");
const workingDirectory = ctl.getWorkingDirectory();
const images = await collectImages(workingDirectory, recursive ?? false, maxResults ?? 50, warn);
return {
count: images.length,
images: images.map((image) => ({
name: image.relativePath,
sizeBytes: image.sizeBytes,
modifiedAt: image.modifiedAt,
...(image.metadataPath ? { metadataPath: image.metadataPath } : {}),
...(image.caption ? { caption: image.caption } : {}),
...(image.sourcePageUrl ? { sourcePageUrl: image.sourcePageUrl } : {}),
...(image.downloadedAt ? { downloadedAt: image.downloadedAt } : {}),
})),
hint: images.length
? "If the user is asking for a described image, such as a caption or subject, use Search Local Images Advanced before Analyze Local Image Advanced. Use exact image names from the search result. Do not infer image identity from local file numbering or JSON-LD ordering."
: "No local images were found in the working directory.",
};
},
});
const searchLocalImagesTool = (0, sdk_1.tool)({
name: "Search Local Images Advanced",
description: "Searches local images by sidecar metadata such as caption, alt text, source URL, original image URL, and file name. Use this tool before Analyze Local Image Advanced whenever the user refers to an image by description, caption, topic, source, or page context instead of an exact local file name. This prevents confusing local file numbering with JSON-LD image ordering.",
parameters: {
query: zod_1.z
.string()
.describe("Search text such as a caption, subject, source page, or phrase to find matching local images."),
recursive: zod_1.z
.boolean()
.optional()
.default(false)
.describe("Whether to search subdirectories."),
maxResults: zod_1.z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Maximum number of matching images to return."),
},
implementation: async ({ query, recursive, maxResults }, { status, warn }) => {
status("Searching local images...");
const workingDirectory = ctl.getWorkingDirectory();
const images = await collectImages(workingDirectory, recursive ?? false, 200, warn);
const terms = query
.toLowerCase()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean);
if (terms.length === 0) {
return {
count: 0,
images: [],
hint: "Search query was empty.",
};
}
const scored = images
.map((image) => {
const haystack = [
image.relativePath,
image.metadataPath,
image.caption,
image.sourcePageUrl,
image.downloadedAt,
]
.filter(Boolean)
.join("\n")
.toLowerCase();
const score = terms.reduce((total, term) => {
if (!haystack.includes(term))
return total;
let bonus = 1;
if (image.caption?.toLowerCase().includes(term))
bonus += 5;
if (image.sourcePageUrl?.toLowerCase().includes(term))
bonus += 2;
if (image.relativePath.toLowerCase().includes(term))
bonus += 1;
return total + bonus;
}, 0);
return { image, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults ?? 10);
return {
count: scored.length,
images: scored.map(({ image, score }) => ({
name: image.relativePath,
score,
sizeBytes: image.sizeBytes,
modifiedAt: image.modifiedAt,
...(image.metadataPath ? { metadataPath: image.metadataPath } : {}),
...(image.caption ? { caption: image.caption } : {}),
...(image.sourcePageUrl ? { sourcePageUrl: image.sourcePageUrl } : {}),
...(image.downloadedAt ? { downloadedAt: image.downloadedAt } : {}),
})),
hint: scored.length
? "Use these exact image names with Analyze Local Image Advanced. Do not infer image identity from local file numbering or JSON-LD ordering."
: "No matching local images were found. Try List Local Images Advanced to inspect available files.",
};
},
});
const analyzeLocalImageTool = (0, sdk_1.tool)({
name: "Analyze Local Image Advanced",
description: "Analyzes one exact local image from the working directory and returns the completed visual findings as a tool result. With Strata, waits for a separate image-analysis request; otherwise uses the loaded vision model. " +
"IMPORTANT: Before using this tool, first use Search Local Images Advanced or List Local Images Advanced and confirm the exact local file name together with its caption, source page, or metadata. " +
"Do not call this tool directly based only on local numbering, JSON-LD image order, or guessed file identity. " +
"If the image identity has not been confirmed from list/search metadata, use Search Local Images Advanced first.",
parameters: {
imageName: zod_1.z
.string()
.describe("Exact image file name confirmed by Search Local Images Advanced or List Local Images Advanced. " +
"Do not guess the file name from numbering or JSON-LD ordering."),
prompt: zod_1.z
.string()
.describe("Required analysis task/question. Keep it clear and specific."),
context: zod_1.z
.string()
.optional()
.default("")
.describe("Optional known context for this image and task, such as source, intent, constraints, known facts, or prior findings."),
},
implementation: async ({ imageName, prompt, context }, { status, warn, signal }) => {
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 backend = ctl.getPluginConfig(config_1.configSchematics).get("analysisBackend");
const model = backend === "strata" ? undefined : await ctl.client.llm.model();
const isStrataHelper = model?.identifier === "strata-vision-primer"
|| model?.path === "ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf";
const useStrata = backend === "strata" || (backend === "auto" && isStrataHelper);
if (isStrataHelper && !useStrata)
return "Error: the Strata UI helper cannot analyze images. Select Auto or Strata as Image analysis backend.";
if (!useStrata && !model?.vision) {
return "Error: currently loaded model does not support vision. Load a vision model and retry.";
}
const sidecarMetadata = await readImageSidecarMetadata(resolvedImagePath.absolutePath, resolvedImagePath.relativePath);
if (!sidecarMetadata) {
return {
error: "image_identity_not_verified",
imageName: resolvedImagePath.relativePath,
message: "No sidecar metadata was found for this image. " +
"Use Search Local Images Advanced or List Local Images Advanced first to confirm the correct image before analysis.",
};
}
if (sidecarMetadata?.localMetadata) {
status(`Loaded image metadata: ${sidecarMetadata.localMetadata}`);
}
const userPrompt = prompt.trim();
const userContext = (context || "").trim();
const metadataContext = sidecarMetadata
? buildImageMetadataContext(sidecarMetadata)
: "";
const contextParts = [
metadataContext,
userContext ? `User-provided context:\n${userContext}` : "",
].filter(Boolean);
const contextBlock = contextParts.length
? `Known context:\n${contextParts.join("\n\n")}\n\n`
: "";
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` +
contextBlock +
`User request:\n${userPrompt}`;
const effectiveMaxTokens = MAX_ANALYSIS_TOKENS_HARD_CAP;
status("Running multimodal analysis...");
const candidate = await (0, attachmentIdentity_1.makeCandidate)(workingDirectory, resolvedImagePath.relativePath).catch(() => undefined);
if (useStrata) {
if (!candidate)
return { error: "image_handoff_failed", message: "Could not verify the image for Strata. The UI helper cannot perform analysis." };
status("Analyzing the verified image with Strata in a separate request...");
const analysis = await (0, strataAnalysis_1.analyzeWithStrata)(ctl, resolvedImagePath.relativePath, analysisPrompt, signal);
status("Strata image analysis completed.");
return { analysis, _aiaImageCandidate: candidate };
}
let fileHandle;
try {
fileHandle = await prepareImageForModel(ctl, resolvedImagePath.absolutePath);
}
catch (error) {
warn(`Failed to prepare image: ${error?.message || String(error)}`);
return { error: "image_prepare_failed", imageName: resolvedImagePath.relativePath, message: `Failed to prepare the image: ${error?.message || String(error)}` };
}
const result = await model.respond([
{
role: "user",
content: analysisPrompt,
images: [fileHandle],
},
], {
maxTokens: effectiveMaxTokens,
});
const analysis = cleanModelResponse(result.content);
return candidate ? { analysis, _aiaImageCandidate: candidate } : analysis;
},
});
const multiAnalyzeTool = (0, sdk_1.tool)({
name: "Multi Analyze Local Images Advanced",
description: "Compare or analyze 2 to 4 exact local images together in ONE vision inference, preserving the requested order. First use List/Search to verify filenames and source metadata. Do not infer image identity from numbering. Returns completed joint analysis and image-to-source mapping. Use the single-image Analyze tool for one image.",
parameters: {
imageNames: zod_1.z.array(zod_1.z.string().min(1)).min(2).max(4).describe("Exact image paths confirmed by List/Search, in comparison order. Identical image bytes are deduplicated."),
prompt: zod_1.z.string().trim().min(1).describe("Joint analysis or comparison task for all images."),
context: zod_1.z.string().optional().default("").describe("Optional shared context; source metadata is included for each image."),
},
implementation: async ({ imageNames, prompt, context }, { status, warn, signal }) => {
signal.throwIfAborted();
const root = ctl.getWorkingDirectory();
const images = [];
let sourceBytes = 0;
status("Verifying comparison images and source metadata...");
for (const requested of imageNames) {
signal.throwIfAborted();
const safe = sanitizeRelativeInput(requested);
if (!safe)
return { error: "invalid_image_path", imageName: requested };
const resolved = await resolveImagePathByName(root, safe, warn);
if (!resolved)
return { error: "image_not_found", imageName: safe };
try {
const input = await (0, attachmentIdentity_1.readCandidate)(root, resolved.relativePath);
const duplicate = images.find(image => image.candidate.sha256 === input.candidate.sha256);
if (duplicate) {
duplicate.requestedNames.push(requested);
continue;
}
const metadata = await readImageSidecarMetadata(resolved.absolutePath, resolved.relativePath);
if (!metadata)
return { error: "image_identity_not_verified", imageName: resolved.relativePath, message: "Source metadata is required for every comparison image." };
sourceBytes += input.bytes.length;
if (sourceBytes > 16 * 1024 * 1024)
return { error: "image_size_limit", message: "Comparison images exceed 16 MiB total." };
images.push({ ...input, metadata, requestedNames: [requested] });
}
catch (error) {
return { error: "image_prepare_failed", imageName: requested, message: String(error) };
}
}
if (images.length < 2)
return { error: "not_enough_distinct_images", message: "Comparison requires at least two different images after deduplication." };
const mapping = images.map((image, index) => ({ index: index + 1, imageName: image.candidate.relativePath, requestedNames: image.requestedNames,
...(typeof image.metadata.sourcePageUrl === "string" ? { sourcePageUrl: image.metadata.sourcePageUrl } : {}) }));
const task = `Analyze all ${images.length} attached images TOGETHER in this one request. Refer to them as Image 1, Image 2, etc. in attachment order. Compare visual evidence, distinguish observations from source metadata, and state uncertainty. Give a concise final answer, without hidden reasoning.\n\n`
+ images.map((image, index) => `Image ${index + 1}: ${image.candidate.relativePath}\n${buildImageMetadataContext(image.metadata)}`).join("\n\n")
+ (context?.trim() ? `\n\nShared user context:\n${context.trim()}` : "") + `\n\nUser request:\n${prompt}`;
const backend = ctl.getPluginConfig(config_1.configSchematics).get("analysisBackend");
const model = backend === "strata" ? undefined : await ctl.client.llm.model();
const helper = model?.identifier === "strata-vision-primer" || model?.path === "ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf";
const strata = backend === "strata" || (backend === "auto" && helper);
if (!strata && (helper || !model?.vision))
return { error: "vision_model_required", message: "Select a vision model or the Strata analysis backend." };
signal.throwIfAborted();
status(`Comparing ${images.length} images in one ${strata ? "Strata" : "local"} inference...`);
let analysis;
if (strata) {
analysis = await (0, strataAnalysis_1.analyzeImagesWithStrata)(ctl, images, task, signal);
}
else {
const files = [];
let preparedBytes = 0;
for (const image of images) {
signal.throwIfAborted();
const file = await (0, compatibleImage_1.compatibleImage)(ctl.client, (0, path_1.basename)(image.candidate.relativePath), image.bytes);
preparedBytes += file.sizeBytes;
if (preparedBytes > 16 * 1024 * 1024)
return { error: "image_size_limit", message: "Converted comparison images exceed 16 MiB total." };
files.push(file);
}
const result = await model.respond([{ role: "user", content: task, images: files }], { maxTokens: MAX_ANALYSIS_TOKENS_HARD_CAP, signal });
analysis = cleanModelResponse(result.content);
}
signal.throwIfAborted();
return { analysis, images: mapping, duplicatesRemoved: imageNames.length - images.length, _aiaImageCandidates: images.map(image => image.candidate) };
},
});
return [listLocalImagesTool, searchLocalImagesTool, analyzeLocalImageTool, multiAnalyzeTool];
}
async function collectImages(directoryPath, recursive, maxResults, warn) {
const found = [];
const queue = [directoryPath];
const root = directoryPath;
while (queue.length > 0 && found.length < maxResults) {
const current = queue.shift();
let entries;
try {
entries = await (0, promises_1.readdir)(current, { withFileTypes: true });
}
catch (error) {
warn(`Cannot read directory '${current}': ${error?.message || String(error)}`);
continue;
}
for (const entry of entries) {
if (found.length >= maxResults)
break;
const absolutePath = (0, path_1.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 (0, promises_1.stat)(absolutePath);
const relativePath = (0, path_1.normalize)((0, path_1.relative)(root, absolutePath)).replace(/\\/g, "/");
const sidecarMetadata = await readImageSidecarMetadata(absolutePath, relativePath);
found.push({
absolutePath,
relativePath,
sizeBytes: metadata.size,
modifiedAt: metadata.mtime.toISOString(),
...(sidecarMetadata?.localMetadata ? { metadataPath: sidecarMetadata.localMetadata } : {}),
...(sidecarMetadata?.caption ? { caption: sidecarMetadata.caption } : {}),
...(sidecarMetadata?.alt && !sidecarMetadata?.caption ? { caption: sidecarMetadata.alt } : {}),
...(sidecarMetadata?.sourcePageUrl ? { sourcePageUrl: sidecarMetadata.sourcePageUrl } : {}),
...(sidecarMetadata?.downloadedAt ? { downloadedAt: sidecarMetadata.downloadedAt } : {}),
});
}
catch (error) {
warn(`Cannot stat file '${absolutePath}': ${error?.message || String(error)}`);
}
}
}
return found;
}
function isImagePath(value) {
return IMAGE_EXTENSIONS.has((0, path_1.extname)(value).toLowerCase());
}
function sanitizeRelativeInput(input) {
if (!input)
return null;
const trimmed = input.trim();
if (!trimmed)
return null;
if ((0, path_1.isAbsolute)(trimmed))
return null;
const normalized = (0, path_1.normalize)(trimmed).replace(/\\/g, "/").replace(/^\.\/+/, "");
if (!normalized || normalized.startsWith("../") || normalized.includes("/../"))
return null;
return normalized;
}
async function resolveImagePathByName(workingDirectory, imageName, warn) {
// 1) Try working-directory root first: <workingDirectory>/<imageName>
const directAbsolutePath = (0, path_1.join)(workingDirectory, imageName);
const directStats = await (0, promises_1.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 = (0, path_1.basename)(imageName).toLowerCase();
const matched = allImages.find((item) => (0, path_1.basename)(item.relativePath).toLowerCase() === targetBasename);
if (!matched)
return null;
return await preferFullImageIfThumb(matched.absolutePath, matched.relativePath);
}
async function preferFullImageIfThumb(absolutePath, relativePath) {
const fileName = (0, path_1.basename)(relativePath);
const thumbMatch = fileName.match(/^(.*)-thumb\.webp$/i);
if (!thumbMatch) {
return { absolutePath, relativePath };
}
const baseNameWithoutThumb = thumbMatch[1];
const parentDirAbsolute = (0, path_1.dirname)(absolutePath);
const parentDirRelative = (0, path_1.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 = (0, path_1.join)(parentDirAbsolute, candidateFileName);
const candidateStats = await (0, promises_1.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 };
}
async function readImageSidecarMetadata(absoluteImagePath, relativeImagePath) {
const candidates = getSidecarMetadataCandidates(absoluteImagePath, relativeImagePath);
for (const candidate of candidates) {
try {
const raw = await (0, promises_1.readFile)(candidate.absolutePath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object")
continue;
return parsed;
}
catch {
// Try next candidate
}
}
return null;
}
function getSidecarMetadataCandidates(absoluteImagePath, relativeImagePath) {
const absoluteDir = (0, path_1.dirname)(absoluteImagePath);
const relativeDir = (0, path_1.dirname)(relativeImagePath).replace(/\\/g, "/");
const imageFileName = (0, path_1.basename)(relativeImagePath);
const withoutThumb = imageFileName.replace(/-thumb\.webp$/i, "");
const withoutExtension = withoutThumb.replace(/\.[^.]+$/i, "");
const metadataFileName = `${withoutExtension}.json`;
const relativeMetadataPath = relativeDir === "." ? metadataFileName : `${relativeDir}/${metadataFileName}`;
return [
{
absolutePath: (0, path_1.join)(absoluteDir, metadataFileName),
relativePath: relativeMetadataPath,
},
];
}
function buildImageMetadataContext(metadata) {
const lines = [];
lines.push("Image metadata from sidecar JSON:");
if (typeof metadata.caption === "string" && metadata.caption.trim()) {
lines.push(`- caption: ${metadata.caption.trim()}`);
}
else if (typeof metadata.alt === "string" && metadata.alt.trim()) {
lines.push(`- alt: ${metadata.alt.trim()}`);
}
if (typeof metadata.sourcePageUrl === "string" && metadata.sourcePageUrl.trim()) {
lines.push(`- source page: ${metadata.sourcePageUrl.trim()}`);
}
if (typeof metadata.url === "string" && metadata.url.trim()) {
lines.push(`- original image URL: ${metadata.url.trim()}`);
}
if (typeof metadata.imageModifiedAt === "string" && metadata.imageModifiedAt.trim()) {
lines.push(`- image modified at: ${metadata.imageModifiedAt.trim()}`);
}
if (typeof metadata.imagePublishedAt === "string" && metadata.imagePublishedAt.trim()) {
lines.push(`- image published at: ${metadata.imagePublishedAt.trim()}`);
}
if (typeof metadata.downloadedAt === "string" && metadata.downloadedAt.trim()) {
lines.push(`- downloaded at: ${metadata.downloadedAt.trim()}`);
}
if (typeof metadata.source === "string" && metadata.source.trim()) {
lines.push(`- metadata source: ${metadata.source.trim()}`);
}
lines.push("Use this metadata only as contextual information. Do not treat alt text, captions, or timestamps as visual proof unless the image itself supports it.");
return lines.join("\n");
}
async function prepareImageForModel(ctl, absoluteImagePath) {
const inputBytes = await (0, promises_1.readFile)(absoluteImagePath);
return await (0, compatibleImage_1.compatibleImage)(ctl.client, (0, path_1.basename)(absoluteImagePath), inputBytes);
}
function cleanModelResponse(content) {
const markerPattern = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[A-Za-z0-9_]+__/;
const parts = content.split(markerPattern);
if (parts.length > 1) {
return parts.slice(1).join("").trim();
}
return content.trim();
}
//# sourceMappingURL=toolsProvider.js.map