src / toolsProvider.ts
/**
* arkantu-core-tools β toolsProvider.ts
*
* Conjunto mΓnimo de herramientas para agentes locales en LM Studio.
* DiseΓ±ado para el setup:
* β’ Nodo principal : Khazad-dum (Intel Arc iGPU, 94 GB)
* β’ Nodo remoto : raspberrypi5-3-gpu via LMLink (RX 480, 8 GB)
*
* Herramientas activas (~18 tools vs 57+ del plugin anterior):
* Filesystem : list_dir, read_file, write_file, find_files,
* search_in_file, delete_path, make_dir, move_file
* EdiciΓ³n : replace_text, insert_at_line
* Git : git_status, git_diff, git_add, git_commit, git_log
* Web : web_search, fetch_page
* Sistema : get_system_info, run_shell, run_python
* Agente rem. : ask_remote_agent (LMLink β RPi5/RX480)
* Utilidades : 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";
// βββ MΓ³dulo glob (opcional β fallback sin Γ©l) βββββββββββββββββββββββββββββββ
async function globFiles(
pattern: string,
cwd: string
): Promise<string[]> {
try {
// Usa find como fallback universal (no necesita dep extra)
const { stdout } = await runShell(
`find . -type f -name "${pattern}" 2>/dev/null | head -100`,
cwd
);
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
// βββ BΓΊsqueda web DuckDuckGo (sin 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")
: "Sin resultados relevantes en DuckDuckGo.";
} catch (e) {
return `Error en bΓΊsqueda web: ${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 enruta los modelos remotos a travΓ©s del endpoint LOCAL.
// No hay endpoint separado β siempre es 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");
// Estado persistente (directorio de trabajo actual)
const state = await loadState(workspacePath);
await ensureDir(state.cwd);
// Referencia mutable al CWD actual
let cwd = state.cwd;
const tools: Tool[] = [];
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// FILESYSTEM TOOLS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableFs) {
// ββ change_dir βββββββββββββββββββββββββββββββββββββββββββββ
tools.push(tool({
name: "change_dir",
description: "Cambia el directorio de trabajo actual. Persiste entre mensajes.",
parameters: { path: z.string().describe("Ruta absoluta o relativa al nuevo directorio.") },
implementation: async ({ path }) => {
const newCwd = safePath(workspacePath, path.startsWith("/") ? path.replace(workspacePath, "") : path);
const s = await stat(newCwd);
if (!s.isDirectory()) throw new Error(`No es un directorio: ${newCwd}`);
cwd = newCwd;
await saveState({ cwd });
return { cwd };
},
}));
// ββ list_dir βββββββββββββββββββββββββββββββββββββββββββββββ
tools.push(tool({
name: "list_dir",
description: "Lista archivos y carpetas del directorio actual o de un subdirectorio.",
parameters: {
path: z.string().optional().describe("Subdirectorio relativo al workspace. Omitir = directorio actual."),
},
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: "Lee el contenido de un archivo de texto. Para archivos grandes usa start_line/end_line.",
parameters: {
path: z.string(),
start_line: z.number().int().min(1).optional().describe("LΓnea inicial (1-indexed)."),
end_line: z.number().int().min(1).optional().describe("LΓnea final (1-indexed, inclusivo)."),
},
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: "Crea o sobreescribe un archivo con el contenido dado.",
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: "Reemplaza una cadena exacta en un archivo. Falla si no existe o hay mΓΊltiples coincidencias (usar count=all para reemplazar todas).",
parameters: {
path: z.string(),
old_text: z.string().describe("Texto exacto a buscar (debe coincidir incluyendo espacios)."),
new_text: z.string().describe("Texto de reemplazo."),
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(`Texto no encontrado en '${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: "Inserta texto en una lΓnea especΓfica de un archivo. El contenido existente se desplaza hacia abajo.",
parameters: {
path: z.string(),
line: z.number().int().min(1).describe("NΓΊmero de lΓnea donde insertar (1-indexed)."),
text: z.string().describe("Texto a insertar (se aΓ±ade como nueva(s) lΓnea(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: "Busca un patrΓ³n (texto o regex) dentro de un archivo. Devuelve lΓneas con nΓΊmero.",
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: "Busca archivos por nombre/patrΓ³n glob en el workspace (ej: **/*.ts, config.json).",
parameters: {
pattern: z.string().describe("PatrΓ³n de nombre. Ej: '*.py', 'README*', '**/*.json'"),
path: z.string().optional().describe("Subdirectorio de bΓΊsqueda (relativo al workspace)."),
},
implementation: async ({ pattern, path = "." }) => {
const searchDir = safePath(cwd, path);
// Convertir glob simple a 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: "Crea un directorio (y subdirectorios intermedios si es necesario).",
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: "Mueve o renombra un archivo o directorio.",
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: "β οΈ DESTRUCTIVO: Elimina un archivo. Para directorios usa el shell. Pide confirmaciΓ³n al usuario antes de llamar.",
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: "Guarda un hecho o preferencia en memory.md del workspace para futuras sesiones.",
parameters: { fact: z.string() },
implementation: async ({ fact }) => {
await appendMemory(cwd, fact);
return { ok: true };
},
}));
} // endif enableFs
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// GIT TOOLS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableGit) {
const getGit = async () => {
const { simpleGit } = await import("simple-git");
return simpleGit(cwd);
};
tools.push(tool({
name: "git_status",
description: "Muestra el estado actual del repositorio git (archivos modificados, staged, etc.).",
parameters: {},
implementation: async () => {
const git = await getGit();
return await git.status();
},
}));
tools.push(tool({
name: "git_diff",
description: "Muestra los cambios en el repositorio. Puede filtrar por archivo o mostrar sΓ³lo staged.",
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 || "Sin cambios.", 8000) };
},
}));
tools.push(tool({
name: "git_add",
description: "AΓ±ade archivos al staging area. Sin argumentos aΓ±ade todos los cambios.",
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: "Crea un commit con los cambios en staging.",
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: "Muestra el historial de commits recientes.",
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,
}));
},
}));
} // endif enableGit
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SHELL / PYTHON
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableShellCfg) {
tools.push(tool({
name: "run_shell",
description: "Ejecuta un comando bash en el directorio de trabajo. Evitar comandos destructivos sin confirmaciΓ³n. 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: "Ejecuta un script Python usando el intΓ©rprete del sistema. El script se escribe en un archivo temporal.",
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: "Busca informaciΓ³n en la web usando DuckDuckGo (sin API key).",
parameters: {
query: z.string(),
},
implementation: async ({ query }) => {
const result = await duckduckgoSearch(query);
return { result };
},
}));
tools.push(tool({
name: "fetch_page",
description: "Obtiene el contenido de texto de una URL. Γtil para leer documentaciΓ³n, 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} al acceder a ${url}`);
const text = await res.text();
// Stripping bΓ‘sico de HTML
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 };
},
}));
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SISTEMA
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableSysInfo) {
tools.push(tool({
name: "get_system_info",
description: "Devuelve informaciΓ³n del sistema: SO, CPUs, RAM, directorio actual y configuraciΓ³n LMLink activa.",
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,
},
};
},
}));
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// NOTIFICACIONES
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableNotifs) {
tools.push(tool({
name: "send_notification",
description: "EnvΓa una notificaciΓ³n de escritorio (ΓΊtil al finalizar tareas largas).",
parameters: {
title: z.string(),
message: z.string(),
},
implementation: async ({ title, message }) => {
// Usa notify-send (disponible en la mayorΓa de distros Linux con GNOME/KDE)
await runShell(
`notify-send "${title.replace(/"/g, '\\"')}" "${message.replace(/"/g, '\\"')}"`,
cwd,
5000
).catch(() => {});
return { ok: true };
},
}));
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// AGENTE REMOTO LMLINK (RX 480 / RPi5)
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (enableLMLink) {
tools.push(tool({
name: "ask_remote_agent",
description: [
"Delega una tarea al modelo cargado en el nodo remoto (RX 480 / raspberrypi5-3-gpu) vΓa LMLink.",
"LMLink enruta la peticiΓ³n a travΓ©s del endpoint local de LM Studio (localhost:1234) β no se necesita IP ni configuraciΓ³n de red.",
"Ideal para descargar trabajo del nodo principal (Arc iGPU) cuando estΓ‘ ocupado o el modelo remoto es mΓ‘s apropiado:",
" β’ RedacciΓ³n de texto, traducciones, resumen de contenido",
" β’ GeneraciΓ³n de cΓ³digo desde cero (sin acceso a archivos del workspace)",
" β’ AnΓ‘lisis de texto o datos que se proporcionen como contexto",
" β’ Preguntas de conocimiento general",
"NO usar para: leer/escribir archivos locales, ejecutar comandos o acceder a recursos del sistema.",
"IMPORTANTE: El modelo remoto debe estar cargado en LM Studio y visible vΓa LMLink.",
].join("\n"),
parameters: {
task: z.string().describe("Tarea o pregunta para el agente remoto."),
context: z.string().optional().describe("Contexto adicional (texto, datos). IMPORTANTE: el nodo remoto tiene poco contexto (8k tokens). EnvΓa solo fragmentos relevantes, de lo contrario fallarΓ‘ (mΓ‘x 25000 chars)."),
system_role: z.string().optional().describe("Rol del sistema para el agente (ej: 'Eres un experto en Python'). Por defecto: asistente general."),
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 ??
"Eres un asistente de IA ΓΊtil y conciso. Responde en el idioma del usuario.";
// Protegemos el contexto para no desbordar los 8k tokens de la Raspberry (aprox 25,000 caracteres)
const safeContext = truncate(context, 25000);
const userContent = safeContext
? `${task}\n\n--- Contexto truncado automΓ‘ticamente ---\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("No se pudo obtener la lista de modelos", e);
}
if (!targetModel) {
return {
error: "No se encontrΓ³ ningΓΊn modelo remoto (cuyo nombre termine en 'rpi') cargado en LM Studio.",
tip: "AsegΓΊrate de tener un modelo con terminaciΓ³n '*rpi' cargado y visible vΓa 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 (la RX 480 es mΓ‘s lenta)
});
} catch (e) {
return {
error: `No se pudo conectar al endpoint LMLink (${LMLINK_ENDPOINT}): ${e instanceof Error ? e.message : String(e)}`,
tip: "Verifica que LM Studio estΓ© corriendo y que el nodo remoto estΓ© conectado vΓa LMLink con un modelo cargado.",
};
}
if (!response.ok) {
const body = await response.text().catch(() => "");
return {
error: `Error ${response.status} de LM Studio al enrutar al modelo remoto: ${body.substring(0, 500)}`,
tip: `Comprueba que el modelo '${targetModel}' estΓ© cargado en el nodo remoto y visible en LMLink.`,
};
}
const data = await response.json() as any;
const content = data?.choices?.[0]?.message?.content ?? "(sin respuesta)";
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: [
"Busca papers en tu biblioteca de Zotero por tΓtulo, autores, abstract, DOI o etiquetas.",
"Devuelve una lista de resultados con metadatos completos (tΓtulo, autores, aΓ±o, DOI, abstract, tags).",
"Ideal para encontrar referencias antes de escribir, o para responder preguntas de investigaciΓ³n.",
"Usa zotero_read_pdf si necesitas el contenido completo del paper.",
].join("\n"),
parameters: {
query: z.string().describe("Texto a buscar: tΓtulo, autor, palabra clave, DOI o tema."),
limit: z.number().int().min(1).max(30).optional().default(10)
.describe("MΓ‘ximo de resultados a devolver (default: 10)."),
},
implementation: async ({ query, limit }) => {
try {
const results = await searchZotero(zoteroDbPath, query, limit);
if (results.length === 0) return { message: "No se encontraron papers para esa bΓΊsqueda.", 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,
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 al buscar en Zotero: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ββ zotero_get_paper ββββββββββββββββββββββββββββββββ
tools.push(tool({
name: "zotero_get_paper",
description: [
"Obtiene los metadatos completos de un paper especΓfico de Zotero.",
"Busca por fragmento de tΓtulo exacto, DOI completo, o clave Zotero (ej: 'VFHR78G9').",
"Devuelve tΓtulo, autores, aΓ±o, DOI, abstract completo, tags, colecciΓ³n y si tiene PDF disponible.",
].join("\n"),
parameters: {
query: z.string().describe("TΓtulo (fragmento), DOI completo, o clave Zotero."),
},
implementation: async ({ query }) => {
try {
const item = await getZoteroItem(zoteroDbPath, query);
if (!item) return { message: `No se encontrΓ³ ningΓΊn paper con '${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 al recuperar paper: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ββ zotero_read_pdf βββββββββββββββββββββββββββββββββ
tools.push(tool({
name: "zotero_read_pdf",
description: [
"Lee el contenido de texto del PDF adjunto a un paper de Zotero.",
"Busca el paper por tΓtulo, DOI o clave Zotero, luego extrae el texto del PDF.",
"Requiere pdftotext (poppler-utils). Si no estΓ‘ disponible devuelve el abstract.",
"Usa max_chars para controlar cuΓ‘nto texto extraer (default: 6000).",
].join("\n"),
parameters: {
query: z.string().describe("TΓtulo, DOI o clave Zotero del paper."),
max_chars: z.number().int().min(500).max(20000).optional().default(6000)
.describe("MΓ‘ximo de caracteres de texto a extraer del PDF."),
},
implementation: async ({ query, max_chars }) => {
try {
const item = await getZoteroItem(zoteroDbPath, query);
if (!item) return { message: `No se encontrΓ³ ningΓΊn paper con '${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 al leer PDF: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// ββ zotero_stats ββββββββββββββββββββββββββββββββββββ
tools.push(tool({
name: "zotero_stats",
description: "Devuelve estadΓsticas de tu biblioteca Zotero: total de items, cuΓ‘ntos tienen PDF, colecciones disponibles y las etiquetas mΓ‘s frecuentes.",
parameters: {},
implementation: async () => {
try {
return await getZoteroStats(zoteroDbPath);
} catch (e) {
return { error: `Error al leer estadΓsticas de Zotero: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
} // endif enableZotero
return tools;
};