dist / toolsProvider.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.toolsProvider = toolsProvider;
const sdk_1 = require("@lmstudio/sdk");
const node_fs_1 = require("node:fs");
const promises_1 = require("node:fs/promises");
const node_path_1 = require("node:path");
const zod_1 = require("zod");
const config_1 = require("./config");
const SIZE_WARNING_CHARS = 8000;
const ENCODING = "utf-8";
// ─── Helpers ────────────────────────────────────────────────────────────────
async function ensureMemoryFile(filePath) {
const dir = (0, node_path_1.dirname)(filePath);
if (!(0, node_fs_1.existsSync)(dir))
await (0, promises_1.mkdir)(dir, { recursive: true });
if (!(0, node_fs_1.existsSync)(filePath))
await (0, promises_1.writeFile)(filePath, "", ENCODING);
}
async function resolveFile(getPath) {
const filePath = getPath();
await ensureMemoryFile(filePath);
const content = normalizeNewlines(await (0, promises_1.readFile)(filePath, ENCODING));
return { filePath, content };
}
function getTimestamp() {
return new Date().toISOString().split("T")[0];
}
function normalizeNewlines(text) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
// Strip backslash escapes added by markdown renderers (e.g. \& -> &, \~ -> ~, \_ -> _)
function stripMarkdownEscapes(text) {
return text.replace(/\\([&~_*#`!|{}\[\]().])/g, "$1");
}
function countEntries(content) {
return (content.match(/^\[20\d\d-\d\d-\d\d\]/gm) || []).length;
}
function buildSizeWarning(content) {
if (content.length <= SIZE_WARNING_CHARS)
return "";
const entries = countEntries(content);
const dates = [...content.matchAll(/^\[(\d{4}-\d{2}-\d{2})\]/gm)].map(m => m[1]);
const uniqueDays = new Set(dates).size;
const density = uniqueDays > 0 ? (entries / uniqueDays).toFixed(1) : "N/A";
return `\n[Memory warning: ${content.length} chars, ${entries} entries, ${density} entries/day average. Consolidate before responding.]`;
}
function findSection(lines, sectionName) {
const header = stripMarkdownEscapes(`## ${sectionName}`);
const startIdx = lines.findIndex(l => stripMarkdownEscapes(l.trim()) === header);
if (startIdx === -1)
return null;
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
if (lines[i].startsWith("## ") || stripMarkdownEscapes(lines[i]).startsWith("## ")) {
endIdx = i;
break;
}
}
return { start: startIdx, end: endIdx };
}
function matchAndReplace(content, oldText, newText) {
const normalizedOld = stripMarkdownEscapes(normalizeNewlines(oldText.trim()));
const normalizedNew = normalizeNewlines(newText.trim());
const normalizedContent = stripMarkdownEscapes(content);
if (normalizedContent.includes(normalizedOld)) {
return normalizedContent.replace(normalizedOld, normalizedNew).replace(/\n{3,}/g, "\n\n");
}
// Fallback: try against original content without escape normalization
if (content.includes(normalizedOld)) {
return content.replace(normalizedOld, normalizedNew).replace(/\n{3,}/g, "\n\n");
}
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const fuzzy = new RegExp(escaped.replace(/\s+/g, "\\s+"), "");
if (fuzzy.test(normalizedContent)) {
return normalizedContent.replace(fuzzy, normalizedNew).replace(/\n{3,}/g, "\n\n");
}
return null;
}
// ─── Tool Provider ───────────────────────────────────────────────────────────
async function toolsProvider(ctl) {
function getMemoryPath() {
const configured = ctl.getPluginConfig(config_1.configSchematics).get("memoryPath").trim();
if (!configured)
throw new Error("Memory file path is not configured. Please set it in the plugin settings.");
return configured;
}
const getCurrentTimeTool = (0, sdk_1.tool)({
name: "get_current_time",
description: "Returns the current local date and time from the system clock. Call this at the start of every session after read_memory.",
parameters: {},
implementation: async () => {
const now = new Date();
const date = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
return `${date} ${time}`;
},
});
const readMemoryTool = (0, sdk_1.tool)({
name: "read_memory",
description: "Reads the entire persistent memory file. Call this at the start of every session before responding to anything. " +
"If the response includes a memory warning, consolidate before proceeding.",
parameters: {},
implementation: async () => {
const { content } = await resolveFile(getMemoryPath);
if (!content.trim())
return "Memory is empty. No prior context available.";
return content + buildSizeWarning(content);
},
});
const saveMemoryTool = (0, sdk_1.tool)({
name: "save_memory",
description: "Appends a new dated entry to the memory file. Use for standalone facts not belonging to a structured section. " +
"Write densely: subject, verb, object. Third person, present tense. No filler phrases, no 'user mentioned.' " +
"One fact or tightly related cluster per call. Skip if similar content already exists — use update_memory instead.",
parameters: {
fact: zod_1.z.string().describe("The fact to save. Dense, third-person, present tense. " +
"Example: 'Kevin uses liquid melatonin 0.3-0.6mg for phase-shifting, not sedation.'"),
},
implementation: async ({ fact }) => {
const { filePath, content } = await resolveFile(getMemoryPath);
const normalizedFact = normalizeNewlines(fact.trim());
const preview = normalizedFact.slice(0, 60).toLowerCase();
if (preview.length > 20 && stripMarkdownEscapes(content).toLowerCase().includes(preview)) {
return "Skipped: similar content already exists in memory. Use update_memory to correct it.";
}
await (0, promises_1.writeFile)(filePath, content + `[${getTimestamp()}] ${normalizedFact}\n`, ENCODING);
return "Saved.";
},
});
const updateMemoryTool = (0, sdk_1.tool)({
name: "update_memory",
description: "Finds and replaces an existing passage in memory. Use instead of save_memory when correcting or updating existing information. " +
"Pass an empty string for replace to delete the entry. For structured sections, prefer replace_section. Can also rename section headers by finding and replacing the ## header line directly.",
parameters: {
find: zod_1.z.string().describe("The text to find in memory."),
replace: zod_1.z.string().describe("Replacement text. Empty string to delete."),
},
implementation: async ({ find, replace }) => {
const { filePath, content } = await resolveFile(getMemoryPath);
const updated = matchAndReplace(content, find, replace);
if (updated === null)
return "Could not find the specified text in memory. No changes made.";
await (0, promises_1.writeFile)(filePath, updated, ENCODING);
const deleted = replace.trim() === "";
return deleted ? "Deleted." : "Updated.";
},
});
const deleteMemoryTool = (0, sdk_1.tool)({
name: "delete_memory",
description: "Removes a specific passage from memory entirely. Use when asked to forget something or remove an entry. " +
"More reliable than update_memory for multi-line entries.",
parameters: {
entry: zod_1.z.string().describe("The exact text to remove from memory."),
},
implementation: async ({ entry }) => {
const { filePath, content } = await resolveFile(getMemoryPath);
const updated = matchAndReplace(content, entry, "");
if (updated === null)
return "Could not find the specified text in memory. No changes made.";
await (0, promises_1.writeFile)(filePath, updated, ENCODING);
return "Deleted.";
},
});
const searchMemoryTool = (0, sdk_1.tool)({
name: "search_memory",
description: "Returns only the entries in memory containing the query string. " +
"More token-efficient than read_memory when looking for a specific detail.",
parameters: {
query: zod_1.z.string().describe("The word or phrase to search for in memory."),
},
implementation: async ({ query }) => {
const { content } = await resolveFile(getMemoryPath);
const lower = query.toLowerCase().trim();
if (!lower)
return "Query must not be empty.";
const lines = content.split("\n");
const matches = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(lower)) {
const block = lines.slice(i, Math.min(i + 3, lines.length)).join("\n").trim();
if (block && !matches.includes(block))
matches.push(block);
}
}
if (matches.length === 0)
return `No entries found containing "${query}".`;
return `Found ${matches.length} match(es):\n\n${matches.join("\n\n")}`;
},
});
const saveToSectionTool = (0, sdk_1.tool)({
name: "save_to_section",
description: "Appends content inside a named section of the memory file. " +
"Use for structured categories: 'Health & Wellness', 'Hardware & Tech Setup', 'Home Inventory', 'Preferences & Notes'. " +
"Creates the section if it does not exist. Use bullet points for lists. Dense, present tense, no filler.",
parameters: {
target: zod_1.z.string().describe("Section name without ##. Canonical sections: 'Health & Wellness', 'Hardware & Tech Setup', 'Home Inventory', 'Preferences & Notes'."),
content: zod_1.z.string().describe("Content to add. Use - bullet points (not *). Dense, present tense. Example: '- RTX 5090 (32GB VRAM)'"),
},
implementation: async ({ target, content }) => {
const { filePath, content: raw } = await resolveFile(getMemoryPath);
const lines = raw.split("\n");
const sectionRange = findSection(lines, target);
const normalizedContent = normalizeNewlines(content.trim());
if (sectionRange === null) {
await (0, promises_1.writeFile)(filePath, raw.trimEnd() + `\n## ${target}\n\n${normalizedContent}\n`, ENCODING);
return `Section '${target}' created and content added.`;
}
let insertLine = sectionRange.start + 1;
for (let i = sectionRange.end - 1; i > sectionRange.start; i--) {
if (lines[i].trim() !== "") {
insertLine = i + 1;
break;
}
}
const newLines = [...lines.slice(0, insertLine), normalizedContent, ...lines.slice(insertLine)];
await (0, promises_1.writeFile)(filePath, newLines.join("\n"), ENCODING);
return `Added to section '${target}'.`;
},
});
const replaceSectionTool = (0, sdk_1.tool)({
name: "replace_section",
description: "Replaces the entire body of a named section in the memory file. " +
"Use for wholesale updates to structured sections like hardware specs or pantry inventory. " +
"More reliable than update_memory for multi-line section content. Preserves the section header.",
parameters: {
target: zod_1.z.string().describe("Section name without ##. Must match an existing header exactly. " +
"Examples: 'Health & Wellness', 'Hardware & Tech Setup', 'Home Inventory'."),
new_content: zod_1.z.string().describe("Full replacement content for the section body. Use - bullet points (not *). Dense, present tense, no filler."),
},
implementation: async ({ target, new_content }) => {
const { filePath, content } = await resolveFile(getMemoryPath);
const lines = content.split("\n");
const sectionRange = findSection(lines, target);
if (sectionRange === null) {
return `Section '${target}' not found in memory. Use save_to_section to create it.`;
}
const normalizedContent = normalizeNewlines(new_content.trim());
const newLines = [
...lines.slice(0, sectionRange.start),
lines[sectionRange.start], "",
normalizedContent, "",
...lines.slice(sectionRange.end),
].join("\n").replace(/\n{3,}/g, "\n\n");
await (0, promises_1.writeFile)(filePath, newLines, ENCODING);
return `Section '${target}' replaced.`;
},
});
return [
getCurrentTimeTool,
readMemoryTool,
saveMemoryTool,
updateMemoryTool,
deleteMemoryTool,
searchMemoryTool,
saveToSectionTool,
replaceSectionTool,
];
}