src / messages.ts
src / messages.ts
import type { Chat, LMStudioClient } from "@lmstudio/sdk";
import { imageMime, readAttachment, readImage, TOTAL_IMAGE_LIMIT } from "./images";
import { parseImageCommands } from "./preprocess";
import { ImageSnapshots } from "./imageSnapshots";
export type Message = { role: string; content: unknown; tool_calls?: unknown[]; tool_call_id?: string };
export async function toMessages(history: Chat, client: LMStudioClient, signal: AbortSignal,
options: { getSnapshotRoot?: () => string; warn?: (message: string) => void } = {},
): Promise<Message[]> {
const messages: Message[] = [];
const snapshots = options.getSnapshotRoot ? new ImageSnapshots(options.getSnapshotRoot) : undefined;
const warn = options.warn ?? (() => {});
const entries = [...history];
const lastUserIndex = entries.reduce((last, message, index) => message.getRole() === "user" ? index : last, -1);
let total = 0;
for (const [index, message] of entries.entries()) {
signal.throwIfAborted();
const role = message.getRole();
const historical = index < lastUserIndex || entries.slice(index + 1).some(entry => entry.getRole() === "assistant");
const originalText = message.getText();
// Generator selection does not automatically enable the plugin's preprocessor.
// Handle explicit user commands here too, so no separate integration is required.
const command = role === "user" ? parseImageCommands(originalText) : { text: originalText, paths: [] };
const text = command.paths.length ? command.text || "Describe the attached images." : originalText;
if (role === "tool") {
for (const result of message.getToolCallResults()) {
if (!result.toolCallId) throw new Error("Tool result has no call ID; cannot forward it to Strata.");
messages.push({ role, tool_call_id: result.toolCallId, content: result.content });
}
continue;
}
const files = message.getFiles(client);
if (files.length && role !== "user") throw new Error("Image attachments must be on a user message.");
const content: unknown[] = text ? [{ type: "text", text }] : [];
const load = async (identity: string, name: string, read: () => Promise<Buffer>): Promise<Buffer | undefined> => {
try {
const bytes = await read();
if (snapshots) await snapshots.remember(identity, bytes).catch(() => warn("Could not retain an image snapshot; the live image will still be sent."));
return bytes;
} catch (error) {
signal.throwIfAborted();
if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
const cached = await snapshots?.recover(identity, signal).catch(() => undefined);
signal.throwIfAborted();
if (cached) return cached;
if (!historical) throw new Error(`Image attachment "${name}" is no longer available. Please attach it again.`, { cause: error });
warn(`An earlier image is no longer available: ${name}`);
content.push({ type: "text", text: `[Earlier image attachment unavailable: ${name}. Its image data is not included in this request. Do not claim to inspect it; ask for reattachment if its contents are needed.]` });
return;
}
};
for (const file of files) {
if (!file.isImage()) throw new Error(`Unsupported attachment: ${file.name}. This adapter forwards images only.`);
const bytes = await load(JSON.stringify([file.identifier, file.name, file.sizeBytes]), file.name, () => readAttachment(file, signal));
if (!bytes) continue;
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Conversation images exceed 64 MiB. Start a new chat or remove old images.");
content.push({ type: "image_url", image_url: { url: `data:${imageMime(bytes)};base64,${bytes.toString("base64")}` } });
}
for (const path of command.paths) {
const bytes = await load(`command:${path}`, path, () => readImage(path, signal));
if (!bytes) continue;
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Conversation images exceed 64 MiB. Start a new chat or remove old images.");
content.push({ type: "image_url", image_url: { url: `data:${imageMime(bytes)};base64,${bytes.toString("base64")}` } });
}
const calls = role === "assistant" ? message.getToolCallRequests().map(call => {
if (!call.id) throw new Error("Tool call has no ID; cannot preserve tool history.");
return { id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments ?? {}) } };
}) : [];
messages.push({ role, content: files.length || command.paths.length ? content : text, ...(calls.length ? { tool_calls: calls } : {}) });
}
return messages;
}
import type { Chat, LMStudioClient } from "@lmstudio/sdk";
import { imageMime, readAttachment, readImage, TOTAL_IMAGE_LIMIT } from "./images";
import { parseImageCommands } from "./preprocess";
import { ImageSnapshots } from "./imageSnapshots";
export type Message = { role: string; content: unknown; tool_calls?: unknown[]; tool_call_id?: string };
export async function toMessages(history: Chat, client: LMStudioClient, signal: AbortSignal,
options: { getSnapshotRoot?: () => string; warn?: (message: string) => void } = {},
): Promise<Message[]> {
const messages: Message[] = [];
const snapshots = options.getSnapshotRoot ? new ImageSnapshots(options.getSnapshotRoot) : undefined;
const warn = options.warn ?? (() => {});
const entries = [...history];
const lastUserIndex = entries.reduce((last, message, index) => message.getRole() === "user" ? index : last, -1);
let total = 0;
for (const [index, message] of entries.entries()) {
signal.throwIfAborted();
const role = message.getRole();
const historical = index < lastUserIndex || entries.slice(index + 1).some(entry => entry.getRole() === "assistant");
const originalText = message.getText();
// Generator selection does not automatically enable the plugin's preprocessor.
// Handle explicit user commands here too, so no separate integration is required.
const command = role === "user" ? parseImageCommands(originalText) : { text: originalText, paths: [] };
const text = command.paths.length ? command.text || "Describe the attached images." : originalText;
if (role === "tool") {
for (const result of message.getToolCallResults()) {
if (!result.toolCallId) throw new Error("Tool result has no call ID; cannot forward it to Strata.");
messages.push({ role, tool_call_id: result.toolCallId, content: result.content });
}
continue;
}
const files = message.getFiles(client);
if (files.length && role !== "user") throw new Error("Image attachments must be on a user message.");
const content: unknown[] = text ? [{ type: "text", text }] : [];
const load = async (identity: string, name: string, read: () => Promise<Buffer>): Promise<Buffer | undefined> => {
try {
const bytes = await read();
if (snapshots) await snapshots.remember(identity, bytes).catch(() => warn("Could not retain an image snapshot; the live image will still be sent."));
return bytes;
} catch (error) {
signal.throwIfAborted();
if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
const cached = await snapshots?.recover(identity, signal).catch(() => undefined);
signal.throwIfAborted();
if (cached) return cached;
if (!historical) throw new Error(`Image attachment "${name}" is no longer available. Please attach it again.`, { cause: error });
warn(`An earlier image is no longer available: ${name}`);
content.push({ type: "text", text: `[Earlier image attachment unavailable: ${name}. Its image data is not included in this request. Do not claim to inspect it; ask for reattachment if its contents are needed.]` });
return;
}
};
for (const file of files) {
if (!file.isImage()) throw new Error(`Unsupported attachment: ${file.name}. This adapter forwards images only.`);
const bytes = await load(JSON.stringify([file.identifier, file.name, file.sizeBytes]), file.name, () => readAttachment(file, signal));
if (!bytes) continue;
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Conversation images exceed 64 MiB. Start a new chat or remove old images.");
content.push({ type: "image_url", image_url: { url: `data:${imageMime(bytes)};base64,${bytes.toString("base64")}` } });
}
for (const path of command.paths) {
const bytes = await load(`command:${path}`, path, () => readImage(path, signal));
if (!bytes) continue;
total += bytes.length;
if (total > TOTAL_IMAGE_LIMIT) throw new Error("Conversation images exceed 64 MiB. Start a new chat or remove old images.");
content.push({ type: "image_url", image_url: { url: `data:${imageMime(bytes)};base64,${bytes.toString("base64")}` } });
}
const calls = role === "assistant" ? message.getToolCallRequests().map(call => {
if (!call.id) throw new Error("Tool call has no ID; cannot preserve tool history.");
return { id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments ?? {}) } };
}) : [];
messages.push({ role, content: files.length || command.paths.length ? content : text, ...(calls.length ? { tool_calls: calls } : {}) });
}
return messages;
}