dist / index.js
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = main;
const sdk_1 = require("@lmstudio/sdk");
const child_process_1 = require("child_process");
const util_1 = require("util");
const promises_1 = require("fs/promises");
const fs_1 = require("fs");
const path_1 = require("path");
const os = __importStar(require("os"));
const zod_1 = require("zod");
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const execAsync = (0, util_1.promisify)(child_process_1.exec);
const ZOTERO_DB_PATH = "/home/arkantu/Zotero/zotero.sqlite";
const ZOTERO_STORAGE_PATH = "/home/arkantu/Zotero/storage";
const MAX_OUTPUT_CHARS = 3500;
function truncate(text, max = MAX_OUTPUT_CHARS) {
if (!text)
return "";
if (text.length <= max)
return text;
return text.substring(0, max) + "\n...[truncated output to protect context window]";
}
async function openZoteroDB(dbPath) {
try {
const db = new better_sqlite3_1.default(dbPath, { readonly: true, fileMustExist: true });
db.pragma("query_only = ON");
return { db, tmpDir: null };
}
catch {
const tmpDir = await (0, promises_1.mkdtemp)((0, path_1.join)(os.tmpdir(), "zotero-"));
const tmpPath = (0, path_1.join)(tmpDir, "zotero.sqlite");
await (0, promises_1.copyFile)(dbPath, tmpPath);
const db = new better_sqlite3_1.default(tmpPath, { readonly: true, fileMustExist: true });
db.pragma("query_only = ON");
return { db, tmpDir };
}
}
async function closeDB(db, tmpDir) {
try {
db.close();
}
catch { }
if (tmpDir) {
await (0, promises_1.rm)(tmpDir, { recursive: true, force: true }).catch(() => { });
}
}
function resolveFieldIds(db) {
const rows = db.prepare("SELECT fieldName, fieldID FROM fields WHERE fieldName IN ('title','date','DOI','abstractNote','url','ISBN','publisher')").all();
const fids = {};
for (const r of rows)
fids[r.fieldName] = r.fieldID;
return fids;
}
function resolveAuthorTypeId(db) {
const row = db.prepare("SELECT creatorTypeID FROM creatorTypes WHERE creatorType = 'author'").get();
return row?.creatorTypeID ?? 1;
}
function buildMainQuery(fids, authorTypeId) {
const titleId = fids["title"] ?? -1;
const dateId = fids["date"] ?? -1;
const doiId = fids["DOI"] ?? -1;
const abstractId = fids["abstractNote"] ?? -1;
return `
SELECT
i.itemID AS id,
i.key AS key,
tv.value AS title,
GROUP_CONCAT(c.lastName || ', ' || COALESCE(c.firstName,''), '; ') AS authors,
dv.value AS year,
doiv.value AS doi,
av.value AS abstract,
att.path AS pdf_path,
atti.key AS storage_key,
COALESCE(GROUP_CONCAT(DISTINCT t.name), '') AS tags,
COALESCE(GROUP_CONCAT(DISTINCT col.collectionName), '') AS collections
FROM items i
LEFT JOIN itemData td ON td.itemID = i.itemID AND td.fieldID = ${titleId}
LEFT JOIN itemDataValues tv ON tv.valueID = td.valueID
LEFT JOIN itemData dd ON dd.itemID = i.itemID AND dd.fieldID = ${dateId}
LEFT JOIN itemDataValues dv ON dv.valueID = dd.valueID
LEFT JOIN itemData doid ON doid.itemID = i.itemID AND doid.fieldID = ${doiId}
LEFT JOIN itemDataValues doiv ON doiv.valueID = doid.valueID
LEFT JOIN itemData ad ON ad.itemID = i.itemID AND ad.fieldID = ${abstractId}
LEFT JOIN itemDataValues av ON av.valueID = ad.valueID
LEFT JOIN itemCreators ic ON ic.itemID = i.itemID AND ic.creatorTypeID = ${authorTypeId}
LEFT JOIN creators c ON c.creatorID = ic.creatorID
LEFT JOIN (
SELECT parentItemID, MIN(itemID) AS itemID, path
FROM itemAttachments
WHERE contentType = 'application/pdf' AND path LIKE 'storage:%'
GROUP BY parentItemID
) att ON att.parentItemID = i.itemID
LEFT JOIN items atti ON atti.itemID = att.itemID
LEFT JOIN itemTags it2 ON it2.itemID = i.itemID
LEFT JOIN tags t ON t.tagID = it2.tagID
LEFT JOIN collectionItems ci ON ci.itemID = i.itemID
LEFT JOIN collections col ON col.collectionID = ci.collectionID
WHERE i.itemTypeID NOT IN (14, 26)
AND tv.value IS NOT NULL
GROUP BY i.itemID
`;
}
// ── 1. Tool: zotero_search ──
const zotero_search = (0, sdk_1.tool)({
name: "zotero_search",
description: "Search papers in local Zotero library by keywords, title, author, abstract, tags or DOI.",
parameters: {
query: zod_1.z.string().describe("Search keywords or title/author phrase."),
limit: zod_1.z.number().optional().describe("Max number of results to return (default: 5).")
},
implementation: async ({ query, limit = 5 }) => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const fids = resolveFieldIds(dbObj.db);
const authorTypeId = resolveAuthorTypeId(dbObj.db);
const sql = buildMainQuery(fids, authorTypeId);
const rows = dbObj.db.prepare(sql).all();
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
const scored = rows.map(r => {
let score = 0;
const title = (r.title ?? "").toLowerCase();
const authors = (r.authors ?? "").toLowerCase();
const abstract = (r.abstract ?? "").toLowerCase();
const tags = (r.tags ?? "").toLowerCase();
const doi = (r.doi ?? "").toLowerCase();
for (const term of terms) {
if (title.includes(term))
score += 3;
if (authors.includes(term))
score += 2;
if (abstract.includes(term))
score += 1;
if (tags.includes(term))
score += 1;
if (doi.includes(term))
score += 3;
}
return { ...r, score };
});
const results = scored
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(limit, 10))
.map(r => ({
key: r.key,
title: r.title,
authors: r.authors ? r.authors.substring(0, 80) : "N/A",
year: r.year ? String(r.year).substring(0, 4) : "N/A",
has_pdf: Boolean(r.pdf_path && r.storage_key),
doi: r.doi || null,
abstract_preview: r.abstract ? truncate(r.abstract, 250) : null
}));
return { success: true, count: results.length, papers: results };
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
// ── 2. Tool: zotero_read_paper ──
const zotero_read_paper = (0, sdk_1.tool)({
name: "zotero_read_paper",
description: "Read full metadata and PDF text/abstract of a specific paper from Zotero using its item key or title.",
parameters: {
paper_key_or_title: zod_1.z.string().describe("The Zotero item key (e.g. '8XYZW123') or exact/partial title.")
},
implementation: async ({ paper_key_or_title }) => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const fids = resolveFieldIds(dbObj.db);
const authorTypeId = resolveAuthorTypeId(dbObj.db);
const sql = buildMainQuery(fids, authorTypeId);
const rows = dbObj.db.prepare(sql).all();
const q = paper_key_or_title.toLowerCase().trim();
const item = rows.find(r => r.key?.toLowerCase() === q ||
r.doi?.toLowerCase() === q ||
r.title?.toLowerCase().includes(q));
if (!item) {
return { success: false, error: `Paper not found for query: ${paper_key_or_title}` };
}
let pdfContent = "";
if (item.pdf_path && item.storage_key) {
const fileName = item.pdf_path.replace("storage:", "");
const fullPdfPath = (0, path_1.join)(ZOTERO_STORAGE_PATH, item.storage_key, fileName);
if ((0, fs_1.existsSync)(fullPdfPath)) {
try {
const { stdout } = await execAsync(`pdftotext -l 10 "${fullPdfPath}" - 2>/dev/null || echo ""`, {
timeout: 15000,
maxBuffer: 2 * 1024 * 1024
});
if (stdout && stdout.trim()) {
pdfContent = truncate(stdout.trim(), 3500);
}
}
catch { }
}
}
return {
success: true,
item: {
key: item.key,
title: item.title,
authors: item.authors,
year: item.year,
doi: item.doi,
tags: item.tags,
collections: item.collections,
abstract: item.abstract,
pdf_text: pdfContent || "(PDF text not extracted or no PDF attached)"
}
};
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
// ── 3. Tool: zotero_stats ──
const zotero_stats = (0, sdk_1.tool)({
name: "zotero_stats",
description: "Get general summary and stats from your local Zotero library (collections, tags, paper count).",
parameters: {},
implementation: async () => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const total = dbObj.db.prepare("SELECT COUNT(*) AS n FROM items WHERE itemTypeID NOT IN (14,26)").get().n;
const withPdf = dbObj.db.prepare("SELECT COUNT(DISTINCT parentItemID) AS n FROM itemAttachments WHERE contentType='application/pdf' AND path LIKE 'storage:%'").get().n;
const cols = dbObj.db.prepare("SELECT collectionName FROM collections ORDER BY collectionName LIMIT 20").all();
const tags = dbObj.db.prepare("SELECT t.name, COUNT(*) AS n FROM tags t JOIN itemTags it ON it.tagID=t.tagID GROUP BY t.tagID ORDER BY n DESC LIMIT 15").all();
return {
success: true,
total_items: total,
items_with_pdf: withPdf,
collections: cols.map(c => c.collectionName),
top_tags: tags.map(t => `${t.name} (${t.n})`)
};
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
const toolsProvider = async () => {
return [zotero_search, zotero_read_paper, zotero_stats];
};
async function main(context) {
context.withToolsProvider(toolsProvider);
}
dist / index.js
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = main;
const sdk_1 = require("@lmstudio/sdk");
const child_process_1 = require("child_process");
const util_1 = require("util");
const promises_1 = require("fs/promises");
const fs_1 = require("fs");
const path_1 = require("path");
const os = __importStar(require("os"));
const zod_1 = require("zod");
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const execAsync = (0, util_1.promisify)(child_process_1.exec);
const ZOTERO_DB_PATH = "/home/arkantu/Zotero/zotero.sqlite";
const ZOTERO_STORAGE_PATH = "/home/arkantu/Zotero/storage";
const MAX_OUTPUT_CHARS = 3500;
function truncate(text, max = MAX_OUTPUT_CHARS) {
if (!text)
return "";
if (text.length <= max)
return text;
return text.substring(0, max) + "\n...[truncated output to protect context window]";
}
async function openZoteroDB(dbPath) {
try {
const db = new better_sqlite3_1.default(dbPath, { readonly: true, fileMustExist: true });
db.pragma("query_only = ON");
return { db, tmpDir: null };
}
catch {
const tmpDir = await (0, promises_1.mkdtemp)((0, path_1.join)(os.tmpdir(), "zotero-"));
const tmpPath = (0, path_1.join)(tmpDir, "zotero.sqlite");
await (0, promises_1.copyFile)(dbPath, tmpPath);
const db = new better_sqlite3_1.default(tmpPath, { readonly: true, fileMustExist: true });
db.pragma("query_only = ON");
return { db, tmpDir };
}
}
async function closeDB(db, tmpDir) {
try {
db.close();
}
catch { }
if (tmpDir) {
await (0, promises_1.rm)(tmpDir, { recursive: true, force: true }).catch(() => { });
}
}
function resolveFieldIds(db) {
const rows = db.prepare("SELECT fieldName, fieldID FROM fields WHERE fieldName IN ('title','date','DOI','abstractNote','url','ISBN','publisher')").all();
const fids = {};
for (const r of rows)
fids[r.fieldName] = r.fieldID;
return fids;
}
function resolveAuthorTypeId(db) {
const row = db.prepare("SELECT creatorTypeID FROM creatorTypes WHERE creatorType = 'author'").get();
return row?.creatorTypeID ?? 1;
}
function buildMainQuery(fids, authorTypeId) {
const titleId = fids["title"] ?? -1;
const dateId = fids["date"] ?? -1;
const doiId = fids["DOI"] ?? -1;
const abstractId = fids["abstractNote"] ?? -1;
return `
SELECT
i.itemID AS id,
i.key AS key,
tv.value AS title,
GROUP_CONCAT(c.lastName || ', ' || COALESCE(c.firstName,''), '; ') AS authors,
dv.value AS year,
doiv.value AS doi,
av.value AS abstract,
att.path AS pdf_path,
atti.key AS storage_key,
COALESCE(GROUP_CONCAT(DISTINCT t.name), '') AS tags,
COALESCE(GROUP_CONCAT(DISTINCT col.collectionName), '') AS collections
FROM items i
LEFT JOIN itemData td ON td.itemID = i.itemID AND td.fieldID = ${titleId}
LEFT JOIN itemDataValues tv ON tv.valueID = td.valueID
LEFT JOIN itemData dd ON dd.itemID = i.itemID AND dd.fieldID = ${dateId}
LEFT JOIN itemDataValues dv ON dv.valueID = dd.valueID
LEFT JOIN itemData doid ON doid.itemID = i.itemID AND doid.fieldID = ${doiId}
LEFT JOIN itemDataValues doiv ON doiv.valueID = doid.valueID
LEFT JOIN itemData ad ON ad.itemID = i.itemID AND ad.fieldID = ${abstractId}
LEFT JOIN itemDataValues av ON av.valueID = ad.valueID
LEFT JOIN itemCreators ic ON ic.itemID = i.itemID AND ic.creatorTypeID = ${authorTypeId}
LEFT JOIN creators c ON c.creatorID = ic.creatorID
LEFT JOIN (
SELECT parentItemID, MIN(itemID) AS itemID, path
FROM itemAttachments
WHERE contentType = 'application/pdf' AND path LIKE 'storage:%'
GROUP BY parentItemID
) att ON att.parentItemID = i.itemID
LEFT JOIN items atti ON atti.itemID = att.itemID
LEFT JOIN itemTags it2 ON it2.itemID = i.itemID
LEFT JOIN tags t ON t.tagID = it2.tagID
LEFT JOIN collectionItems ci ON ci.itemID = i.itemID
LEFT JOIN collections col ON col.collectionID = ci.collectionID
WHERE i.itemTypeID NOT IN (14, 26)
AND tv.value IS NOT NULL
GROUP BY i.itemID
`;
}
// ── 1. Tool: zotero_search ──
const zotero_search = (0, sdk_1.tool)({
name: "zotero_search",
description: "Search papers in local Zotero library by keywords, title, author, abstract, tags or DOI.",
parameters: {
query: zod_1.z.string().describe("Search keywords or title/author phrase."),
limit: zod_1.z.number().optional().describe("Max number of results to return (default: 5).")
},
implementation: async ({ query, limit = 5 }) => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const fids = resolveFieldIds(dbObj.db);
const authorTypeId = resolveAuthorTypeId(dbObj.db);
const sql = buildMainQuery(fids, authorTypeId);
const rows = dbObj.db.prepare(sql).all();
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
const scored = rows.map(r => {
let score = 0;
const title = (r.title ?? "").toLowerCase();
const authors = (r.authors ?? "").toLowerCase();
const abstract = (r.abstract ?? "").toLowerCase();
const tags = (r.tags ?? "").toLowerCase();
const doi = (r.doi ?? "").toLowerCase();
for (const term of terms) {
if (title.includes(term))
score += 3;
if (authors.includes(term))
score += 2;
if (abstract.includes(term))
score += 1;
if (tags.includes(term))
score += 1;
if (doi.includes(term))
score += 3;
}
return { ...r, score };
});
const results = scored
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(limit, 10))
.map(r => ({
key: r.key,
title: r.title,
authors: r.authors ? r.authors.substring(0, 80) : "N/A",
year: r.year ? String(r.year).substring(0, 4) : "N/A",
has_pdf: Boolean(r.pdf_path && r.storage_key),
doi: r.doi || null,
abstract_preview: r.abstract ? truncate(r.abstract, 250) : null
}));
return { success: true, count: results.length, papers: results };
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
// ── 2. Tool: zotero_read_paper ──
const zotero_read_paper = (0, sdk_1.tool)({
name: "zotero_read_paper",
description: "Read full metadata and PDF text/abstract of a specific paper from Zotero using its item key or title.",
parameters: {
paper_key_or_title: zod_1.z.string().describe("The Zotero item key (e.g. '8XYZW123') or exact/partial title.")
},
implementation: async ({ paper_key_or_title }) => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const fids = resolveFieldIds(dbObj.db);
const authorTypeId = resolveAuthorTypeId(dbObj.db);
const sql = buildMainQuery(fids, authorTypeId);
const rows = dbObj.db.prepare(sql).all();
const q = paper_key_or_title.toLowerCase().trim();
const item = rows.find(r => r.key?.toLowerCase() === q ||
r.doi?.toLowerCase() === q ||
r.title?.toLowerCase().includes(q));
if (!item) {
return { success: false, error: `Paper not found for query: ${paper_key_or_title}` };
}
let pdfContent = "";
if (item.pdf_path && item.storage_key) {
const fileName = item.pdf_path.replace("storage:", "");
const fullPdfPath = (0, path_1.join)(ZOTERO_STORAGE_PATH, item.storage_key, fileName);
if ((0, fs_1.existsSync)(fullPdfPath)) {
try {
const { stdout } = await execAsync(`pdftotext -l 10 "${fullPdfPath}" - 2>/dev/null || echo ""`, {
timeout: 15000,
maxBuffer: 2 * 1024 * 1024
});
if (stdout && stdout.trim()) {
pdfContent = truncate(stdout.trim(), 3500);
}
}
catch { }
}
}
return {
success: true,
item: {
key: item.key,
title: item.title,
authors: item.authors,
year: item.year,
doi: item.doi,
tags: item.tags,
collections: item.collections,
abstract: item.abstract,
pdf_text: pdfContent || "(PDF text not extracted or no PDF attached)"
}
};
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
// ── 3. Tool: zotero_stats ──
const zotero_stats = (0, sdk_1.tool)({
name: "zotero_stats",
description: "Get general summary and stats from your local Zotero library (collections, tags, paper count).",
parameters: {},
implementation: async () => {
let dbObj;
try {
dbObj = await openZoteroDB(ZOTERO_DB_PATH);
const total = dbObj.db.prepare("SELECT COUNT(*) AS n FROM items WHERE itemTypeID NOT IN (14,26)").get().n;
const withPdf = dbObj.db.prepare("SELECT COUNT(DISTINCT parentItemID) AS n FROM itemAttachments WHERE contentType='application/pdf' AND path LIKE 'storage:%'").get().n;
const cols = dbObj.db.prepare("SELECT collectionName FROM collections ORDER BY collectionName LIMIT 20").all();
const tags = dbObj.db.prepare("SELECT t.name, COUNT(*) AS n FROM tags t JOIN itemTags it ON it.tagID=t.tagID GROUP BY t.tagID ORDER BY n DESC LIMIT 15").all();
return {
success: true,
total_items: total,
items_with_pdf: withPdf,
collections: cols.map(c => c.collectionName),
top_tags: tags.map(t => `${t.name} (${t.n})`)
};
}
catch (err) {
return { success: false, error: err.message };
}
finally {
if (dbObj)
await closeDB(dbObj.db, dbObj.tmpDir);
}
}
});
const toolsProvider = async () => {
return [zotero_search, zotero_read_paper, zotero_stats];
};
async function main(context) {
context.withToolsProvider(toolsProvider);
}