Project Files
src / tools / read_doc.ts
/**
* read_doc — return the full (or partial) content of a notes document by filename.
*/
import { tool, type Tool, type ToolsProviderController, type ToolCallContext } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext; runtime is fine
import { z } from "zod";
import path from "node:path";
import fs from "node:fs";
import { globalConfigSchematics } from "../config.js";
import { formatToolMetaBlock } from "../core-bundle.mjs";
import { configSchematics } from "../config.js";
import { DocumentLoader } from "../documents/loader.js";
import {
registerDocumentImagesFromMarkdown,
sanitizeEmbeddedImagePayloads,
} from "../helpers/documentImages.js";
import {
isLmStudioConversationSource,
loadConversationFile,
} from "../sources/adapters/lmStudioConversationSourceAdapter.js";
import { defaultLmStudioHome } from "../sources/lmStudioConversationMarkdown.js";
function generateReadId(): string {
return Math.random().toString(16).slice(2, 8);
}
export function createReadDocTool(ctl: ToolsProviderController): Tool {
return tool({
name: "read_doc",
description: `Read the content of a note from the notes directory or a configured LM Studio conversation source.
Use this tool after find_doc has returned a relevant hit and you need the actual text. When the document contains images, read_doc registers all local or embedded images as pN entries automatically.
Use this tool when:
- find_doc returned one or more matching documents and you need the full text
- The user asks you to quote, expand on, or summarise something from the notes
Parameters:
- filename: The filename/source returned by find_doc. Local filenames are resolved against notesDirectory and contentDirectories. For LM Studio conversations, prefer the short read handle "l1769436015776" returned by find_doc. Also supported: "1769436015776.conversation.json", an absolute .conversation.json path, or "lmstudio-conversation://1769436015776". Do not invent .md names for LM Studio conversations.
- maxChars: Maximum characters to return (default: 8000). Use a higher value for long documents.
- fromEnd: When true, returns the last maxChars characters instead of the first. Useful for exported chats where the most recent content is at the end.
- show_images: When true (default), registers all images from the document as pN entries. When false, returns text only.
Returns:
- A read_id marker (<!-- read_id: XXXXXX -->) followed by the document content.
Pass the read_id to skip_doc to remove this result from the API context when no longer needed.
${formatToolMetaBlock()}`,
parameters: {
filename: z
.string()
.describe(
"The filename/source returned by find_doc. For LM Studio chats, prefer the short handle like 'l1769436015776'."
),
maxChars: z
.number()
.int()
.min(1)
.default(8000)
.describe(
"Maximum number of characters to return. Default: 8000. Increase for large documents."
),
fromEnd: z
.boolean()
.default(false)
.describe(
"When true, returns the last maxChars characters of the document instead of the first. Default: false."
),
show_images: z
.boolean()
.default(true)
.describe(
"When true (default), registers all images found in the document as pN entries. Set false for text-only reads."
),
},
implementation: async (args, ctx: ToolCallContext) => {
const getter: any =
(ctl as any).getGlobalPluginConfig || (ctl as any).getGlobalConfig;
const gcfg = getter ? getter.call(ctl, globalConfigSchematics) : null;
const pluginConfig = ctl.getPluginConfig(configSchematics);
const requestedFilename = String(args.filename ?? "").trim();
if (/^\d{13}\.md$/i.test(requestedFilename)) {
return `Error: LM Studio conversations are not Markdown files. Use "${requestedFilename.replace(/\.md$/i, ".conversation.json")}" or "lmstudio-conversation://${requestedFilename.replace(/\.md$/i, "")}".`;
}
const conversationPath = resolveConversationReadPath(requestedFilename);
let content: string;
let imageDocumentPath: string | null = null;
let imageSourceKind = "file";
let imageMetadata: Record<string, unknown> | undefined;
if (conversationPath) {
try {
const doc = await loadConversationFile(conversationPath, inferLmStudioHomeFromConversationFile(conversationPath), {
includeThinking: false,
includeToolCalls: false,
});
content = doc.rawContent;
imageDocumentPath = conversationPath;
imageSourceKind = "conversation";
imageMetadata = {
...(doc.metadata ?? {}),
imageRefs: (doc as any).imageRefs,
};
} catch (err) {
return `Error: Conversation file not found or unreadable — "${requestedFilename}". Use the real .conversation.json filename returned by find_doc.`;
}
} else {
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 `Error: File not found — "${args.filename}". Use find_doc to search available documents.`;
}
try {
content = fs.readFileSync(filePath, "utf8");
imageDocumentPath = filePath;
} catch {
return `Error: File not found — "${args.filename}". Use find_doc to search available documents.`;
}
}
const chatWd = safeWorkingDirectory(ctl);
let imageHint = "";
const showImages: boolean = args?.show_images !== false;
if (showImages && chatWd && imageDocumentPath) {
const previewMaxSum: number = pluginConfig.get("imagePreviewMaxSum") ?? 3072;
const previewQuality: number = pluginConfig.get("imagePreviewQuality") ?? 85;
const remoteFetchTimeoutMs: number = gcfg?.get("remoteFetchTimeoutMs") ?? 10000;
const remoteMaxBytes: number = gcfg?.get("remoteMaxBytes") ?? 15728640;
const registration = await registerDocumentImagesFromMarkdown({
markdown: content,
documentPath: imageDocumentPath,
chatWd,
sourceTool: "read_doc",
sourceKind: imageSourceKind,
chunkMetadata: imageMetadata,
previewMaxSum,
previewQuality,
remoteFetchTimeoutMs,
remoteMaxBytes,
onStatus: (message) => { try { ctx.status(message); } catch {} },
});
if (registration.assignedKeys.length > 0) {
const keyList = registration.assignedKeys.map((key) => `"${key}"`).join(", ");
imageHint = `\n\n[Images registered: ${registration.assignedKeys.join(", ")}. Call: review_image({"targets":[${keyList}]})]`;
} else if (registration.count > 0) {
imageHint = `\n\n[Images registered from this document (${registration.count}); p-index unavailable.]`;
}
const remoteSkipEntries = Object.entries(registration.remoteSkips);
if (remoteSkipEntries.length > 0) {
imageHint += `\n[Remote image candidates skipped: ${remoteSkipEntries
.map(([reason, count]) => `${count} (${reason})`)
.join(", ")}.]`;
}
}
const maxChars = args.maxChars ?? 8000;
const fromEnd = args.fromEnd ?? false;
const sanitizedContent = sanitizeEmbeddedImagePayloads(
content,
"[embedded image -- registered by read_doc]"
);
let result: string;
let truncated = false;
if (sanitizedContent.length > maxChars) {
truncated = true;
result = fromEnd ? sanitizedContent.slice(-maxChars) : sanitizedContent.slice(0, maxChars);
} else {
result = sanitizedContent;
}
const readId = generateReadId();
const truncationNote = truncated
? `\n\n[Truncated: showing ${fromEnd ? "last" : "first"} ${maxChars} of ${sanitizedContent.length} text characters after embedded image payload removal. Call read_doc again with fromEnd=${!fromEnd} to see the ${fromEnd ? "beginning" : "end"}, or increase maxChars.]`
: "";
return `<!-- read_id: ${readId} -->\n${result}${imageHint}${truncationNote}`;
},
});
}
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)) {
return fs.existsSync(requested) && DocumentLoader.isSupported(requested) ? requested : 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)) {
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)) {
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 resolveConversationReadPath(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 (/^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 inferLmStudioHomeFromConversationFile(filePath: string): string {
const conversationsDir = path.dirname(filePath);
return path.basename(conversationsDir) === "conversations"
? path.dirname(conversationsDir)
: defaultLmStudioHome();
}