Forked from arkantu/arkantu-core-tools
src / toolsProvider.ts
/**
* arkantu-core-tools — toolsProvider.ts
*
* Minimum set of tools for local agents in LM Studio.
* Designed for the setup:
* • Main Node : Khazad-dum (Intel Arc iGPU, 94 GB)
* • Remote Node : raspberrypi5-3-gpu via LMLink (RX 480, 8 GB)
*
* Active Tools (~18 tools vs 57+ in the previous plugin):
* Filesystem : list_dir, read_file, write_file, find_files,
* search_in_file, delete_path, make_dir, move_file
* Editing : replace_text, insert_at_line
* Git : git_status, git_diff, git_add, git_commit, git_log
* Web : web_search, fetch_page
* System : get_system_info, run_shell, run_python
* Remote Agent: ask_remote_agent (LMLink → RPi5/RX480)
* Utilities : save_memory, send_notification, change_dir
*/
import {
tool,
type Tool,
type ToolsProvider,
} from "@lmstudio/sdk";
import { readdir, readFile, writeFile, unlink, mkdir,
rename, stat, appendFile,
} from "fs/promises";
import { join, dirname } from "path";
import { z } from "zod";
import { pluginConfig as configSchematics } from "./config";
import { loadState, saveState, ensureDir, appendMemory } from "./state";
import { safePath, runShell, truncate } from "./utils";
import { searchZotero, getZoteroItem, getZoteroStats, readZoteroPdf } from "./zoteroManager";
// ─── Glob module (optional — fallback without it) ───────────────────────────────
async function globFiles(
pattern: string,
cwd: string
): Promise<string[]> {
try {
// Use find as a universal fallback (no extra dependency needed)
const { stdout } = await runShell(
`find . -type f -name "${pattern}" 2>/dev/null | head -100`,
cwd
);
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
// ─── DuckDuckGo Web Search (without API key) ───────────────────────────────
async function duckduckgoSearch(query: string): Promise<string> {
try {
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as any;
const results: string[] = [];
if (data.AbstractText) results.push(`📖 ${data.AbstractText}`);
if (data.RelatedTopics) {
for (const t of (data.RelatedTopics as any[]).slice(0, 5)) {
if (t.Text) results.push(`• ${t.Text}`);
}
}
return results.length > 0
? results.join("\n")
: "No relevant results found in DuckDuckGo.";
} catch (e) {
return `Web search error: ${e instanceof Error ? e.message : String(e)}`;
}
}
// ───────────────────────────────────────────────────────────────────────────
// TOOLS PROVIDER
// ───────────────────────────────────────────────────────────────────────────
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(configSchematics);
const workspacePath = cfg.get("workspacePath");
const enableFs = cfg.get("enableFilesystem");
const enableShellCfg = cfg.get("enableShell");
const enablePython = cfg.get("enablePython");
const enableGit = cfg.get("enableGit");
const enableWeb = cfg.get("enableWeb");
const enableLMLink = cfg.get("enableLMLink");
// LMLink routes remote models through the LOCAL endpoint.
// There is no separate endpoint — it is always localhost:1234.
const LMLINK_ENDPOINT = "http://localhost:1234/v1";
const enableSysInfo = cfg.get("enableSystemInfo");
const enableNotifs = cfg.get("enableNotifications");
const enableZotero = cfg.get("enableZotero");
const zoteroDbPath = cfg.get("zoteroDbPath");
const zoteroStoragePath = cfg.get("zoteroStoragePath");
// Persistent State (current working directory)
const state = await loadState(workspacePath);
await ensureDir(state.cwd);
// Mutable reference to the actual CWD
let cwd = state.cwd;
const tools: Tool[] = [];
// ══════════════════════════════════════════════════════════════
// FILESYSTEM TOOLS
// ══════════════════════════════════════════════════════════════
if (enableFs) {
// ── change_dir ─────────────────────────────────────────────
tools.push(tool({
name: "change_dir",
description: "Change the current working directory. Persists across messages.",
parameters: { path: z.string().describe("Absolute or relative path to the new directory.") },
implementation: async ({ path }) => {
const newCwd = safePath(workspacePath, path.startsWith("/") ? path.replace(workspacePath, "") : path);
const s = await stat(newCwd);
if (!s.isDirectory()) throw new Error(`Not a directory: ${newCwd}`);
cwd = newCwd;
await saveState({ cwd });
return { cwd };
},
}));
// ── list_dir ───────────────────────────────────────────────
tools.push(tool({
name: "list_dir",
description: "List files and folders in the current directory or a subdirectory.",
parameters: {
path: z.string().optional().describe("Relative subdirectory to list. Omit = current directory."),
},
implementation: async ({ path = "." }) => {
const target = safePath(cwd, path);
const entries = await readdir(target, { withFileTypes: true });
return entries.map(e => ({
name: e.name,
type: e.isDirectory() ? "dir" : "file",
}));
},
}));
// ── read_file ──────────────────────────────────────────────
tools.push(tool({
name: "read_file",
description: "Read the content of a text file. Use start_line/end_line for large files.",
parameters: {
path: z.string(),
start_line: z.number().int().min(1).optional().describe("Start line (1-indexed)."),
end_line: z.number().int().min(1).optional().describe("End line (1-indexed, inclusive)."),
},
implementation: async ({ path, start_line, end_line }) => {
const fpath = safePath(cwd, path);
const content = await readFile(fpath, "utf-8");
if (start_line !== undefined || end_line !== undefined) {
const lines = content.split("\n");
const s = (start_line ?? 1) - 1;
const e = (end_line ?? lines.length) - 1;
return { content: lines.slice(s, e + 1).join("\n"), total_lines: lines.length };
}
return { content: truncate(content), total_lines: content.split("\n").length };
},
}));
// ── write_file ─────────────────────────────────────────────
tools.push(tool({
name: "write_file",
description: "Create or overwrite a file with the given content.",
parameters: {
path: z.string(),
content: z.string(),
},
implementation: async ({ path, content }) => {
const fpath = safePath(cwd, path);
await mkdir(dirname(fpath), { recursive: true });
await writeFile(fpath, content, "utf-8");
return { ok: true, bytes: content.length, path: fpath };
},
}));
// ── replace_text ───────────────────────────────────────────
tools.push(tool({
name: "replace_text",
description: "Replace an exact string in a file. Fails if the text does not exist or there are multiple matches (use count=all to replace all).",
parameters: {
path: z.string(),
old_text: z.string().describe("Exact text to search for (must match including spaces)."),
new_text: z.string().describe("Replacement text."),
count: z.enum(["first", "all"]).optional().default("first"),
},
implementation: async ({ path, old_text, new_text, count }) => {
const fpath = safePath(cwd, path);
let content = await readFile(fpath, "utf-8");
const occurrences = content.split(old_text).length - 1;
if (occurrences === 0) throw new Error(`Text not found in '${path}'.`);
if (count === "first") {
content = content.replace(old_text, new_text);
} else {
content = content.split(old_text).join(new_text);
}
await writeFile(fpath, content, "utf-8");
return { ok: true, replacements: count === "all" ? occurrences : 1 };
},
}));
// ── insert_at_line ─────────────────────────────────────────
tools.push(tool({
name: "insert_at_line",
description: "Insert text at a specific line in a file. Existing content is shifted down.",
parameters: {
path: z.string(),
line: z.number().int().min(1).describe("Line number to insert at (1-indexed)."),
text: z.string().describe("Text to insert (added as new line(s))."),
},
implementation: async ({ path, line, text }) => {
const fpath = safePath(cwd, path);
const lines = (await readFile(fpath, "utf-8")).split("\n");
const idx = Math.min(line - 1, lines.length);
lines.splice(idx, 0, text);
await writeFile(fpath, lines.join("\n"), "utf-8");
return { ok: true, inserted_at: line, total_lines: lines.length };
},
}));
// ── search_in_file ─────────────────────────────────────────
tools.push(tool({
name: "search_in_file",
description: "Search for a pattern (text or regex) within a file. Returns lines with line numbers.",
parameters: {
path: z.string(),
pattern: z.string(),
is_regex: z.boolean().optional().default(false),
case_sensitive: z.boolean().optional().default(true),
max_results: z.number().int().optional().default(30),
},
implementation: async ({ path, pattern, is_regex, case_sensitive, max_results }) => {
const fpath = safePath(cwd, path);
const content = await readFile(fpath, "utf-8");
const lines = content.split("\n");
const flags = case_sensitive ? "" : "i";
const regex = is_regex
? new RegExp(pattern, flags)
: new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
const matches = lines
.map((l, i) => ({ line: i + 1, text: l }))
.filter(({ text }) => regex.test(text))
.slice(0, max_results as number);
return { matches, total_matches: matches.length };
},
}));
// ── find_files ─────────────────────────────────────────────
tools.push(tool({
name: "find_files",
description: "Search for files by name/glob pattern in the workspace (e.g: '*.py', 'README*', '**/*.json').",
parameters: {
pattern: z.string().describe("Name pattern. E.g.: '*.py', 'README*', '**/*.json'"),
path: z.string().optional().describe("Search subdirectory (relative to workspace)."),
},
implementation: async ({ pattern, path = "." }) => {
const searchDir = safePath(cwd, path);
// Convert simple glob to find
const findPattern = pattern.includes("/")
? pattern.split("/").pop()!
: pattern;
const results = await globFiles(findPattern, searchDir);
return { files: results, count: results.length };
},
}));
// ── make_dir ────────────────────────────────────────────────
tools.push(tool({
name: "make_dir",
description: "Create a directory (and intermediate subdirectories if necessary).",
parameters: { path: z.string() },
implementation: async ({ path }) => {
const fpath = safePath(cwd, path);
await mkdir(fpath, { recursive: true });
return { ok: true, path: fpath };
},
}));
// ── move_file ──────────────────────────────────────────────
tools.push(tool({
name: "move_file",
description: "Move or rename a file or directory.",
parameters: {
source: z.string(),
destination: z.string(),
},
implementation: async ({ source, destination }) => {
const src = safePath(cwd, source);
const dst = safePath(cwd, destination);
await rename(src, dst);
return { ok: true, from: src, to: dst };
},
}));
// ── delete_path ────────────────────────────────────────────
tools.push(tool({
name: "delete_path",
description: "⚠️ DESTRUCTIVE: Deletes a file. Use the shell for directories. Ask for user confirmation before calling.",
parameters: { path: z.string() },
implementation: async ({ path }) => {
const fpath = safePath(cwd, path);
await unlink(fpath);
return { ok: true, deleted: fpath };
},
}));
// ── save_memory ────────────────────────────────────────────
tools.push(tool({
name: "save_memory",
description: "Saves a fact or preference to memory.md of the workspace for future sessions.",
parameters: { fact: z.string() },
implementation: async ({ fact }) => {
await appendMemory(cwd, fact);
return { ok: true };
},
}));
} // end if enableFs
// ══════════════════════════════════════════════════════════════
// GIT TOOLS
// ══════════════════════════════════════════════════════════════
if (enableGit) {
const getGit = async () => {
const { simpleGit } = await import("simple-git");
return simpleGit(cwd);
};
tools.push(tool({
name: "git_status",
description: "Show the current state of the git repository (modified, staged, etc.).",
parameters: {},
implementation: async () => {
const git = await getGit();
return await git.status();
},
}));
tools.push(tool({
name: "git_diff",
description: "Show the changes in the repository. Can filter by file or show only staged changes.",
parameters: {
path: z.string().optional(),
staged: z.boolean().optional().default(false),
},
implementation: async ({ path, staged }) => {
const git = await getGit();
const args: string[] = [];
if (staged) args.push("--cached");
if (path) args.push(safePath(cwd, path));
const diff = await git.diff(args);
return { diff: truncate(diff || "No changes.", 8000) };
},
}));
tools.push(tool({
name: "git_add",
description: "Add files to the staging area. Add all changes without arguments.",
parameters: {
paths: z.array(z.string()).optional(),
},
implementation: async ({ paths }) => {
const git = await getGit();
if (paths && paths.length > 0) {
await git.add(paths.map(p => safePath(cwd, p)));
} else {
await git.add(".");
}
return { ok: true };
},
}));
tools.push(tool({
name: "git_commit",
description: "Create a commit with the staged changes.",
parameters: { message: z.string() },
implementation: async ({ message }) => {
const git = await getGit();
const result = await git.commit(message);
return { ok: true, summary: result.summary };
},
}));
tools.push(tool({
name: "git_log",
description: "Show the recent commit history.",
parameters: {
max: z.number().int().min(1).max(50).optional().default(10),
},
implementation: async ({ max }) => {
const git = await getGit();
const log = await git.log({ maxCount: max });
return log.all.map(c => ({
hash: c.hash.substring(0, 8),
date: c.date,
message: c.message,
author: c.author_name,
}));
},
}));
} // end if enableGit
// ══════════════════════════════════════════════════════════════
// SHELL / PYTHON
// ══════════════════════════════════════════════════════════════
if (enableShellCfg) {
tools.push(tool({
name: "run_shell",
description: "Execute a bash command in the working directory. Avoid destructive commands without confirmation. Timeout: 60s.",
parameters: {
command: z.string(),
timeout_s: z.number().int().min(1).max(300).optional().default(60),
},
implementation: async ({ command, timeout_s }) => {
const result = await runShell(command, cwd, timeout_s * 1000);
return result;
},
}));
}
if (enablePython) {
tools.push(tool({
name: "run_python",
description: "Execute a Python script using the system interpreter. The script is written to a temporary file.",
parameters: {
code: z.string(),
timeout_s: z.number().int().optional().default(30),
},
implementation: async ({ code, timeout_s }) => {
const tmpFile = join(cwd, `.tmp_py_${Date.now()}.py`);
try {
await writeFile(tmpFile, code, "utf-8");
const result = await runShell(`python3 "${tmpFile}"`, cwd, timeout_s * 1000);
return result;
} finally {
unlink(tmpFile).catch(() => {});
}
},
}));
}
// ══════════════════════════════════════════════════════════════
// WEB TOOLS
// ══════════════════════════════════════════════════════════════
if (enableWeb) {
tools.push(tool({
name: "web_search",
description: "Search for information on the web using DuckDuckGo (no API key).",
parameters: {
query: z.string(),
},
implementation: async ({ query }) => {
const result = await duckduckgoSearch(query);
return { result };
},
}));
tools.push(tool({
name: "fetch_page",
description: "Get the text content from a URL. Useful for reading documentation, APIs, etc.",
parameters: {
url: z.string().url(),
max_chars: z.number().int().optional().default(6000),
},
implementation: async ({ url, max_chars }) => {
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`HTTP ${res.status} when accessing ${url}`);
const text = await res.text();
// Basic HTML stripping
const plain = text
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
return { content: truncate(plain, max_chars as number), url };
},
}));
}
// ══════════════════════════════════════════════════════════════
// SYSTEM
// ══════════════════════════════════════════════════════════════
if (enableSysInfo) {
tools.push(tool({
name: "get_system_info",
description: "Returns system information: OS, CPUs, RAM, current directory, and active LMLink configuration.",
parameters: {},
implementation: async () => {
const os = await import("os");
const totalRam = Math.round(os.totalmem() / 1024 / 1024 / 1024);
const freeRam = Math.round(os.freemem() / 1024 / 1024 / 1024);
return {
platform: os.platform(),
release: os.release(),
arch: os.arch(),
hostname: os.hostname(),
cpus: os.cpus().length,
cpu_model: os.cpus()[0]?.model ?? "unknown",
ram_total_gb: totalRam,
ram_free_gb: freeRam,
cwd,
workspace: workspacePath,
lmlink: {
enabled: enableLMLink,
endpoint: LMLINK_ENDPOINT,
},
};
},
}));
}
// ══════════════════════════════════════════════════════════════
// NOTIFICATIONS
// ══════════════════════════════════════════════════════════════
if (enableNotifs) {
tools.push(tool({
name: "send_notification",
description: "Sends a desktop notification (useful after long tasks finish).",
parameters: {
title: z.string(),
message: z.string(),
},
implementation: async ({ title, message }) => {
// Uses notify-send (available on most Linux distros with GNOME/KDE)
await runShell(
`notify-send "${title.replace(/"/g, '\\"')}" "${message.replace(/"/g, '\\"')}"`,
cwd,
5000
).catch(() => {});
return { ok: true };
},
}));
}
// ══════════════════════════════════════════════════════════════
// REMOTE AGENT LMLINK (RX 480 / RPi5)
// ══════════════════════════════════════════════════════════════
if (enableLMLink) {
tools.push(tool({
name: "ask_remote_agent",
description: [
"Delegates a task to the model loaded on the remote node (RX 480 / raspberrypi5-3-gpu) via LMLink.",
"LMLink routes the request through the local LM Studio endpoint (localhost:1234) — no IP or network configuration is needed.",
"Ideal for downloading work from the main node (Arc iGPU) when it is busy or the remote model is more appropriate:",
" • Text generation, translations, content summarization",
" • Code generation from scratch (without access to workspace files)",
" • Text or data analysis that is provided as context",
" • General knowledge questions",
"Do NOT use for: reading/writing local files, executing commands, or accessing system resources.",
"IMPORTANT: The remote model must be loaded in LM Studio and visible via LMLink.",
].join("\n"),
parameters: {
task: z.string().describe("Task or question for the remote agent."),
context: z.string().optional().describe("Additional context (text, data). IMPORTANT: The remote node has limited context (8k tokens). Send only relevant fragments; otherwise, it will fail (max 25000 chars)."),
system_role: z.string().optional().describe("System role for the agent (e.g.: 'You are a Python expert'). Default: general assistant."),
temperature: z.number().min(0).max(2).optional().default(0.7),
max_tokens: z.number().int().min(100).max(4000).optional().default(1500),
},
implementation: async ({ task, context = "", system_role, temperature, max_tokens }) => {
const systemPrompt = system_role ??
"You are a helpful and concise AI assistant. Respond in the user's language.";
// Protect context to avoid overflowing the 8k tokens on the Raspberry (approx 25,000 characters)
const safeContext = truncate(context, 25000);
const userContent = safeContext
? `${task}\n\n--- Truncated Context ---\n${safeContext}`
: task;
let targetModel = "";
try {
const modelsRes = await fetch(`${LMLINK_ENDPOINT}/models`, { signal: AbortSignal.timeout(5000) });
if (modelsRes.ok) {
const modelsData = await modelsRes.json() as any;
const rpiModel = (modelsData?.data || []).find((m: any) => m.id && m.id.toLowerCase().endsWith("rpi"));
if (rpiModel) {
targetModel = rpiModel.id;
}
}
} catch (e) {
console.warn("Could not get model list", e);
}
if (!targetModel) {
return {
error: "No remote model found (ending in 'rpi') loaded in LM Studio.",
tip: "Ensure a model ending in '*rpi' is loaded in LM Studio and visible via LMLink.",
};
}
let response: Response;
try {
response = await fetch(`${LMLINK_ENDPOINT}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: targetModel,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
],
temperature,
max_tokens,
stream: false,
}),
signal: AbortSignal.timeout(120_000), // 2 min (RX 480 is slower)
});
} catch (e) {
return {
error: `Failed to connect to LMLink endpoint (${LMLINK_ENDPOINT}): ${e instanceof Error ? e.message : String(e)}`,
tip: "Check that LM Studio is running and the remote node is connected via LMLink with a loaded model.",
};
}
if (!response.ok) {
const body = await response.text().catch(() => "");
return {
error: `LM Studio error ${response.status} routing to remote model: ${body.substring(0, 500)}`,
tip: `Check that the model '${targetModel}' is loaded on the remote node and visible in LMLink.`,
};
}
const data = await response.json() as any;
const content = data?.choices?.[0]?.message?.content ?? "(no response)";
const usage = data?.usage ?? {};
return {
response: truncate(content, 4000),
routed_to_model: targetModel,
via: "LMLink → localhost:1234",
tokens: usage,
};
},
}));
}
// ════════════════════════════════════════════════════════════
// ZOTERO RAG
// ════════════════════════════════════════════════════════════
if (enableZotero) {
// ── zotero_search ──────────────────────────────────────
tools.push(tool({
name: "zotero_search",
description: [
"Search papers in your Zotero library by title, authors, abstract, DOI, or tags.",
"Returns a list of results with complete metadata (title, authors, year, DOI, abstract, tags).",
"Ideal for finding references before writing, or for answering research questions.",
"Use zotero_read_pdf if you need the full content of the paper.",
].join("\n"),
parameters: {
query: z.string().describe("Text to search for: title, author, keyword, DOI, or topic."),
limit: z.number().int().min(1).max(30).optional().default(10).describe("Maximum number of results to return (default: 10)."),
},
implementation: async ({ query, limit }) => {
try {
const results = await searchZotero(zoteroDbPath, query, limit);
if (results.length === 0) return { message: "No papers found for that search.", query };
return {
count: results.length,
results: results.map(r => ({
key: r.key,
title: r.title,
authors: r.authors,
year: r.year ? String(r.year).substring(0, 4) : null,
doi: r.doi,
abstract: r.abstract,
tags: r.tags,
collections: r.collections,
has_pdf: !!r.pdf_path,
abstract_snippet: r.snippet || (r.abstract ? truncate(r.abstract, 300) : null),
})),
};
} catch (e) {
return { error: `Error searching Zotero: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ── zotero_get_paper ────────────────────────────────
tools.push(tool({
name: "zotero_get_paper",
description: [
"Get the complete metadata of a specific paper from Zotero.",
"Search by exact title fragment, full DOI, or Zotero key (e.g.: 'VFHR78G9').",
"Returns title, authors, year, DOI, abstract, tags, collection, and if PDF is available.",
].join("\n"),
parameters: {
query: z.string().describe("Title (fragment), full DOI, or Zotero key."),
},
implementation: async ({ query }) => {
try {
const item = await getZoteroItem(zoteroDbPath, query);
if (!item) return { message: `No paper found with '${query}'.` };
return {
key: item.key,
title: item.title,
authors: item.authors,
year: item.year ? String(item.year).substring(0, 4) : null,
doi: item.doi,
abstract: item.abstract,
tags: item.tags,
collections: item.collections,
has_pdf: !!item.pdf_path,
storage_key: item.storage_key,
};
} catch (e) {
return { error: `Error retrieving paper: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ── zotero_read_pdf ─────────────────────────────────
tools.push(tool({
name: "zotero_read_pdf",
description: [
"Read the text content of the PDF attached to a Zotero paper.",
"Search for the paper by title, DOI, or Zotero key, then extract the PDF text.",
"Requires pdftotext (poppler-utils). If not available, return the abstract.",
"Use max_chars to control how much text is extracted from the PDF (default: 6000).",
].join("\n"),
parameters: {
query: z.string().describe("Title, DOI, or Zotero key of the paper."),
max_chars: z.number().int().min(500).max(20000).optional().default(6000).describe("Maximum characters of text to extract from the PDF."),
},
implementation: async ({ query, max_chars }) => {
try {
const item = await getZoteroItem(zoteroDbPath, query);
if (!item) return { message: `No paper found with '${query}'.` };
const text = await readZoteroPdf(zoteroStoragePath, item, max_chars);
return {
key: item.key,
title: item.title,
authors: item.authors,
year: item.year ? String(item.year).substring(0, 4) : null,
content: text,
};
} catch (e) {
return { error: `Error reading PDF: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ── zotero_stats ────────────────────────────────────
tools.push(tool({
name: "zotero_stats",
description: "Returns statistics for your Zotero library: total items, how many have PDFs, available collections, and most frequent tags.",
parameters: {},
implementation: async () => {
try {
return await getZoteroStats(zoteroDbPath);
} catch (e) {
return { error: `Error reading Zotero statistics: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
} // end if enableZotero
return tools;
};