src / tools / common.ts
/* src/tools/common.ts */
import { mkdir, readFile, stat } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import { existsSync, readFileSync } from "fs";
export function validateChatId(id: string) {
if (!/^[0-9]{13}$/.test(id)) {
throw new Error("Invalid chat_id. Must be exactly 13 digits (0-9).");
}
}
export async function ensureDir(dir: string) {
try {
await mkdir(dir, { recursive: true });
} catch {
// ignore
}
}
export async function safeFileSize(path: string): Promise<number> {
try {
const s = await stat(path);
return s.size;
} catch {
return 0;
}
}
export async function readJson(path: string): Promise<unknown> {
const buf = await readFile(path);
return JSON.parse(buf.toString("utf-8"));
}
function renderRole(heading: string, content: any): string {
const texts = toTextArray(content);
if (texts.length === 0) return "";
return `### ${heading}\n${texts.join("\n")}\n\n`;
}
export function convertMessagesToMarkdown(
messages: any[],
opts: { includeThinking: boolean; includeToolCalls: boolean; embedImages: boolean }
): string {
let md = "";
// collect assistant steps globally for tool-call correlation
const allSteps: any[] = [];
for (const msg of messages) {
const ver = selectVersion(msg?.versions);
if (ver && Array.isArray(ver.steps)) {
allSteps.push(...ver.steps);
}
}
for (const msg of messages) {
const version = selectVersion(msg?.versions);
if (!version || !version.role) continue;
if (version.role === "assistant") {
const heading = getAssistantHeading(version, "Assistant");
md += renderAssistant(
version,
{ includeThinking: opts.includeThinking, includeToolCalls: opts.includeToolCalls },
heading,
allSteps
);
} else if (version.role === "user") {
md += renderUser(version, opts.embedImages);
} else if (version.role === "system") {
md += renderRole("System", version.content);
} else if (version.role === "tool") {
md += renderRole("Tool", version.content);
} else {
md += renderRole(String(version.role), version.content);
}
}
return md;
}
function selectVersion(versions: any[]): any | null {
if (!Array.isArray(versions) || versions.length === 0) return null;
return versions[versions.length - 1];
}
function toTextArray(value: any): string[] {
if (value == null) return [];
if (Array.isArray(value)) return value.map(v => v?.text ?? JSON.stringify(v)).filter(Boolean);
if (typeof value === "string") return [value];
if (typeof value === "object" && "text" in value) return [(value as any).text];
return [JSON.stringify(value)];
}
function stripAssistantMarkers(s: string): string {
return s
.replace(/<\|start\|>assistant<\|channel\|>final<\|message\|>/g, "")
.replace(/<\|channel\|>final<\|message\|>/g, "");
}
function getAssistantHeading(version: any, fallback = "Assistant"): string {
const candidate =
version?.senderInfo?.senderName ??
version?.modelId ??
version?.model?.id ??
version?.model ??
version?.provider ??
version?.meta?.modelId ??
null;
return typeof candidate === "string" && candidate.trim() ? candidate : fallback;
}
function parseToolCallsFromSteps(
steps: any[],
extraSteps?: any[]
): Array<{
callId?: string;
name?: string;
args?: any;
result?: any;
error?: any;
plugin?: string;
status?: string;
}> {
const byId: Record<string, any> = {};
const order: string[] = [];
const ensure = (id?: string) => {
if (!id) return undefined;
if (!byId[id]) {
byId[id] = { callId: id };
order.push(id);
}
return byId[id];
};
const asId = (v: any) => (v != null ? String(v) : undefined);
const combined = [
...(Array.isArray(steps) ? steps : []),
...(Array.isArray(extraSteps) ? extraSteps : []),
];
for (const step of combined) {
if (step?.type === "toolCallRequest") {
const id = asId(step.callId) ?? asId(step.toolCallRequestId);
const entry = ensure(id) ?? {};
entry.callId = entry.callId ?? id;
entry.name = step.name ?? entry.name;
entry.args = step.parameters ?? entry.args;
entry.plugin = step.pluginIdentifier ?? entry.plugin;
entry.status = entry.status ?? "requested";
continue;
}
if (step?.type === "contentBlock" && Array.isArray(step?.content)) {
for (const c of step.content) {
if (c?.type === "toolCallRequest") {
const id =
asId(c.callId) ??
asId(step.callId) ??
asId(c.toolCallRequestId) ??
asId(step.toolCallRequestId) ??
undefined;
const entry = ensure(id) ?? {};
entry.callId = entry.callId ?? id;
entry.name = c.name ?? entry.name;
entry.args = c.parameters ?? entry.args;
entry.plugin = step.pluginIdentifier ?? c.pluginIdentifier ?? entry.plugin;
entry.status = entry.status ?? "requested";
}
if (typeof c?.type === "string" && /tool.*(result|response|output)/i.test(c.type)) {
const id = asId(c.callId) ?? asId(step.callId) ?? asId(c.toolCallRequestId);
const entry = ensure(id);
if (entry) {
entry.result = c.result ?? c.output ?? c.data ?? c.content ?? c.text ?? c;
entry.status = entry.status ?? "succeeded";
}
}
}
}
if (typeof step?.type === "string" && /tool.*(result|response|output|call)/i.test(step.type)) {
const id = asId(step.callId) ?? asId(step.toolCallRequestId);
const entry = ensure(id);
if (entry) {
entry.result = step.result ?? step.output ?? step.data ?? step.content ?? step.text ?? step;
entry.status = entry.status ?? "succeeded";
}
}
if (step?.type === "toolStatus") {
const id = asId(step.callId);
const entry = ensure(id);
if (!entry) continue;
const st = step?.statusState?.status;
const t = st?.type;
if (t === "toolCallFailed") {
entry.status = "failed";
entry.error = st?.error ?? st;
} else if (t === "toolCallSucceeded") {
entry.status = "succeeded";
if (st?.result !== undefined) {
entry.result = st.result;
}
} else if (typeof t === "string") {
entry.status = t;
}
}
}
return order.map(id => byId[id]);
}
function extractCallIdsFromStep(step: any): string[] {
const ids: string[] = [];
const pushId = (v: any) => {
if (v != null) ids.push(String(v));
};
if (!step) return ids;
if (step.callId) pushId(step.callId);
if (step.toolCallRequestId) pushId(step.toolCallRequestId);
if (Array.isArray(step.content)) {
for (const c of step.content) {
if (!c) continue;
if ((c as any).callId) pushId((c as any).callId);
if ((c as any).toolCallRequestId) pushId((c as any).toolCallRequestId);
}
}
if (step.type === "toolCallRequest") {
if (step.callId) pushId(step.callId);
if (step.toolCallRequestId) pushId(step.toolCallRequestId);
}
return ids;
}
function gatherCallIdsFromSteps(steps: any[] | undefined): Set<string> {
const set = new Set<string>();
if (!Array.isArray(steps)) return set;
for (const s of steps) {
for (const id of extractCallIdsFromStep(s)) set.add(id);
}
return set;
}
function renderAssistant(
version: any,
opts: { includeThinking: boolean; includeToolCalls: boolean },
headingLabel = "Assistant",
globalSteps?: any[]
): string {
const contentTexts: string[] = [];
let thinkingBlock = "";
const toolCallBlocks: string[] = [];
if (Array.isArray(version?.steps)) {
for (const step of version.steps) {
if (step?.type === "contentBlock") {
if (step?.style?.type === "thinking" && opts.includeThinking) {
const title = step?.style?.title ?? "Thinking";
const think = Array.isArray(step?.content)
? step.content[0]?.text ?? ""
: step?.content?.text ?? "";
thinkingBlock += `<details>\n<summary>${title}</summary>\n\n${think}\n\n</details>\n\n`;
} else {
const texts = Array.isArray(step?.content)
? step.content.map((c: any) => c?.text).filter(Boolean)
: toTextArray(step?.content);
contentTexts.push(...texts);
}
} else if (step?.content?.text) {
contentTexts.push(step.content.text);
}
}
if (opts.includeToolCalls) {
const localCallIds = gatherCallIdsFromSteps(version.steps);
let extraFiltered: any[] | undefined = undefined;
if (localCallIds.size > 0 && Array.isArray(globalSteps)) {
extraFiltered = globalSteps.filter((s) => {
const ids = extractCallIdsFromStep(s);
for (const id of ids) {
if (localCallIds.has(id)) return true;
}
return false;
});
if (extraFiltered.length === 0) extraFiltered = undefined;
}
const calls = parseToolCallsFromSteps(version.steps, extraFiltered);
for (const c of calls) {
const title =
c.status === "failed"
? `Tool call failed for ${c.name ?? "Unknown tool"}`
: `${c.name ?? "Tool call"}`;
const argsMd =
c.args !== undefined
? "```json\n" + JSON.stringify(c.args, null, 2) + "\n```"
: "_no arguments_";
let inner = `- Arguments:\n\n${argsMd}\n`;
if (c.error !== undefined) {
const errMd =
typeof c.error === "string" ? c.error : JSON.stringify(c.error, null, 2);
inner += `\n- Errors:\n\n\`\`\`\n${errMd}\n\`\`\`\n`;
} else if (c.result !== undefined) {
let resContent = c.result;
if (resContent && typeof resContent === "object" && typeof resContent.content === "string") {
try {
const parsed = JSON.parse(resContent.content);
resContent = parsed;
} catch {
resContent = resContent.content;
}
}
const resMd =
typeof resContent === "string" ? resContent : JSON.stringify(resContent, null, 2);
if (typeof resContent === "string" && resContent.trim().startsWith("{")) {
inner += `\n- Result:\n\n\`\`\`\n${resContent}\n\`\`\`\n`;
} else {
inner += `\n- Result:\n\n\`\`\`json\n${resMd}\n\`\`\`\n`;
}
}
toolCallBlocks.push(`<details>\n<summary>${title}</summary>\n\n${inner}</details>`);
}
}
} else if (Array.isArray(version?.content)) {
contentTexts.push(...toTextArray(version.content));
}
if (opts.includeThinking && version?.thinking && typeof version.thinking === "string") {
thinkingBlock += `<details>\n<summary>Thinking</summary>\n\n${version.thinking}\n\n</details>\n\n`;
}
let body = "";
if (thinkingBlock) body += thinkingBlock;
if (contentTexts.length > 0) {
body += stripAssistantMarkers(contentTexts.join("\n")) + "\n\n";
}
if (opts.includeToolCalls && toolCallBlocks.length > 0) {
body += toolCallBlocks.join("\n\n") + "\n\n";
}
return body ? `### ${headingLabel}\n${body}` : "";
}
function renderUser(version: any, embedImages: boolean): string {
const { texts, images } = extractUserContent(version, embedImages);
let body = "";
if (texts.length > 0) {
body += texts.join("\n") + "\n\n";
}
if (images.length > 0) {
for (let i = 0; i < images.length; i++) {
const img = images[i];
const alt = (img.alt && String(img.alt).trim()) || `User image ${i + 1}`;
body += `\n\n`;
}
}
return body ? `### User\n${body}` : "";
}
function extractUserContent(version: any, embedImages: boolean): { texts: string[]; images: Array<{ url: string; alt?: string }> } {
const texts: string[] = [];
const images: Array<{ url: string; alt?: string }> = [];
const textSet = new Set<string>();
const imageSet = new Set<string>();
const pushText = (t: any) => {
let s: string | undefined;
if (typeof t === "string") s = t;
else if (t && typeof t === "object" && typeof (t as any).text === "string") s = (t as any).text;
if (typeof s === "string") {
const v = s.trim();
if (v && !textSet.has(v)) {
textSet.add(v);
texts.push(v);
}
}
};
const normalizeUrlForMd = (url: string) => {
if (/^(data:|https?:)/i.test(url)) return url;
if (/^(\/|[A-Za-z]:[\\/])/.test(url)) return url; // absolute path
return url; // relative path; <> wrapping handles spaces
};
const pushImage = (url: string, alt?: string) => {
const u = normalizeUrlForMd(url);
if (!imageSet.has(u)) {
imageSet.add(u);
images.push({ url: u, alt });
}
};
const candidateFilenameFromFile = (obj: any): string | undefined => {
const v =
obj.identifier ??
obj.fileIdentifier ??
obj.name ??
obj.filename ??
obj.url ??
obj.path ??
obj.filePath;
return typeof v === "string" && v.trim() ? v : undefined;
};
const pushImageFromFileItem = (obj: any) => {
if (!obj || typeof obj !== "object") return;
const t = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
const ft = typeof obj.fileType === "string" ? obj.fileType.toLowerCase() : "";
if (t === "file" || ft) {
if (ft === "image" || t === "file") {
const filename = candidateFilenameFromFile(obj);
if (filename) {
const embedded = embedImages ? buildDataUrlFromMetadata(filename) : undefined;
const url = embedded ?? join(homedir(), ".lmstudio", "user-files", filename);
const alt = obj.alt ?? obj.name ?? obj.filename ?? obj.identifier ?? obj.fileIdentifier;
pushImage(url, alt);
}
}
}
};
const pushImageFrom = (obj: any) => {
if (!obj || typeof obj !== "object") return;
const directUrl =
obj.url ??
obj.image_url ??
obj.imageURL ??
obj.source?.url ??
obj.image?.url ??
(typeof obj.image_url === "object" ? obj.image_url?.url : undefined);
if (typeof directUrl === "string" && directUrl.trim()) {
pushImage(directUrl, obj.alt ?? obj.image?.alt ?? obj.name ?? obj.filename);
return;
}
const base64 =
obj.data ??
obj.b64 ??
obj.base64 ??
obj.image_base64 ??
obj.image?.data ??
obj.source?.data;
if (typeof base64 === "string" && base64.trim()) {
const mime =
obj.mimeType ??
obj.mimetype ??
obj.contentType ??
obj.image?.mimeType ??
obj.source?.mimeType ??
"image/png";
const dataUrl = base64.startsWith("data:") ? base64 : `data:${mime};base64,${base64}`;
pushImage(dataUrl, obj.alt ?? obj.image?.alt ?? obj.name ?? obj.filename);
return;
}
if (obj.type === "file" || obj.fileType) {
pushImageFromFileItem(obj);
}
};
const scanContent = (content: any) => {
if (!content) return;
if (typeof content === "string") {
pushText(content);
return;
}
if (Array.isArray(content)) {
for (const part of content) {
if (!part) continue;
const t = typeof part.type === "string" ? part.type.toLowerCase() : "";
if (t.includes("text") || "text" in part) {
pushText(part.text ?? part.value ?? part.content ?? part);
continue;
}
if (t === "file" || (typeof (part as any).fileType === "string" && (part as any).fileType.toLowerCase() === "image")) {
pushImageFromFileItem(part);
continue;
}
if (t.includes("image") || "image_url" in (part as any) || "url" in (part as any)) {
pushImageFrom(part);
continue;
}
if (typeof part === "object") {
if (typeof (part as any).text === "string") pushText((part as any).text);
else pushImageFrom(part);
}
}
return;
}
if (typeof content === "object") {
const t = typeof (content as any).type === "string" ? (content as any).type.toLowerCase() : "";
if (t.includes("text") || typeof (content as any).text === "string") {
pushText((content as any).text ?? (content as any).content ?? content);
} else if (t === "file" || (typeof (content as any).fileType === "string" && (content as any).fileType.toLowerCase() === "image")) {
pushImageFromFileItem(content);
} else if (t.includes("image") || "image_url" in (content as any) || "url" in (content as any)) {
pushImageFrom(content);
} else if (Array.isArray((content as any).parts)) {
scanContent((content as any).parts);
}
}
};
const root = version?.preprocessed?.content ?? version?.content;
scanContent(root);
if (Array.isArray(version?.attachments)) {
for (const att of version.attachments) {
const t = typeof att?.type === "string" ? att.type.toLowerCase() : "";
if (t === "file" || (typeof att?.fileType === "string" && att.fileType.toLowerCase() === "image")) {
pushImageFromFileItem(att);
} else if (t.includes("image") || "url" in (att ?? {}) || "image_url" in (att ?? {})) {
pushImageFrom(att);
}
}
}
if (Array.isArray(version?.steps)) {
for (const step of version.steps) {
if (step?.type === "contentBlock") {
scanContent(step?.content);
}
}
}
return { texts, images };
}
function buildDataUrlFromMetadata(filename: string): string | undefined {
try {
const root = join(homedir(), ".lmstudio", "user-files");
const metaPath = join(root, `${filename}.metadata.json`);
if (!existsSync(metaPath)) return undefined;
const raw = readFileSync(metaPath, "utf-8");
const meta = JSON.parse(raw);
// Prefer preview.data if present (already a data: URL in your schema)
const previewData = typeof meta?.preview?.data === "string" ? meta.preview.data.trim() : undefined;
if (previewData) {
if (previewData.startsWith("data:")) return previewData;
const mimePreview =
meta?.preview?.mimeType ??
meta?.mimeType ??
meta?.mimetype ??
meta?.contentType ??
"image/png";
return `data:${mimePreview};base64,${previewData}`;
}
const mime =
meta?.mimeType ??
meta?.mimetype ??
meta?.contentType ??
meta?.type ??
meta?.image?.mimeType ??
meta?.image?.type ??
"image/png";
if (typeof meta?.content === "string") {
const s = meta.content.trim();
if (s.startsWith("data:")) return s;
}
let b64 =
meta?.base64 ??
meta?.b64 ??
meta?.data ??
meta?.image?.base64 ??
meta?.image?.b64 ??
meta?.image?.data ??
meta?.content?.base64 ??
meta?.content?.data ??
meta?.payload;
if (typeof b64 === "string" && b64.trim()) {
const s = b64.trim();
if (s.startsWith("data:")) return s;
return `data:${mime};base64,${s}`;
}
return undefined;
} catch {
return undefined;
}
}
function escapeMarkdownAlt(s: string): string {
return s.replace(/\]/g, "\\]");
}