Project Files
src / tools / memorize_doc.ts
/**
* memorize_doc — write or overwrite a notes document.
* Creates YAML frontmatter automatically; derives filename from title slug.
* Optionally embeds image references from the pN/aN pool.
*/
import { tool, type Tool, type ToolsProviderController } 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 { slugify, buildFrontmatter } from "../helpers/frontmatter.js";
// @ts-ignore — typed via core-bundle.d.mts
import { readState, formatToolMetaBlock } from "../core-bundle.mjs";
function sanitizeImageFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export function createMemorizeDocTool(ctl: ToolsProviderController): Tool {
return tool({
name: "memorize_doc",
description: `Write a new note to the notes directory, or completely replace an existing one.
The filename is derived automatically from the title (slugified).
YAML frontmatter (title, tags, created, updated) is generated and maintained automatically.
Use this tool when:
- The user explicitly asks you to save or write a note
- You derive or discover information useful to persist
- Summarising the outcome of a completed task
- Creating a structured reference note (how-tos, decisions, preferences)
Optionally attach images from the current chat (pN/aN notations) — they are copied to images/ in the notes directory and referenced in the Markdown body.
Returns:
- Confirmation with title and filename
${formatToolMetaBlock()}`,
parameters: {
title: z
.string()
.describe("Document title (used as frontmatter title and to derive the filename)."),
tags: z
.array(z.string())
.default([])
.describe("List of tags to categorise this document."),
body: z
.string()
.describe("The main content of the document (plain Markdown, without frontmatter)."),
images: z
.array(z.string())
.default([])
.describe(
"Optional list of image notations from the current chat (e.g. ['p1', 'a2']) to embed as references at the end of the document."
),
},
implementation: async (args) => {
const getter: any =
(ctl as any).getGlobalPluginConfig || (ctl as any).getGlobalConfig;
const gcfg = getter ? getter.call(ctl, globalConfigSchematics) : null;
const notesDirectory: string = gcfg?.get("notesDirectory") ?? "";
if (!notesDirectory) {
return "Error: Notes Directory is not configured. The initial plugin setup has not been completed yet — start a new chat and follow the setup guide, then set Notes Directory in the plugin global settings.";
}
const slug = slugify(args.title);
if (!slug) return "Error: Title must contain at least one word character.";
const filename = `${slug}.md`;
const filePath = path.join(notesDirectory, filename);
const now = new Date().toISOString();
// Preserve original created date if file exists
let existingCreated: string | undefined;
if (fs.existsSync(filePath)) {
const existing = fs.readFileSync(filePath, "utf8");
const m = existing.match(/^created:\s*"?([^"\n]+)"?/m);
existingCreated = m ? m[1].trim() : undefined;
}
// Ensure notes directory exists
if (!fs.existsSync(notesDirectory)) {
fs.mkdirSync(notesDirectory, { recursive: true });
}
// Handle image attachments
let bodyWithImages = args.body;
const imageNotations: string[] = (args.images ?? []).filter((s) => /^[aivp]\d+$/i.test(s));
if (imageNotations.length > 0) {
const chatWd = ctl.getWorkingDirectory();
if (typeof chatWd !== "string" || !chatWd.trim()) {
return "Error: Working directory not available — cannot resolve image notations.";
}
const imagesDir = path.join(notesDirectory, "images");
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true });
}
let state: any;
try {
state = await readState(chatWd);
} catch (e) {
return `Error: Could not read chat media state — ${(e as Error).message ?? String(e)}`;
}
const imageRefs: string[] = [];
let hasImages = false;
for (const notation of imageNotations) {
const m = /^([avip])(\d+)$/i.exec(notation);
if (!m) continue;
const prefix = m[1].toLowerCase() as "a" | "v" | "i" | "p";
const n = parseInt(m[2], 10);
const poolKey =
prefix === "a" ? "attachments" :
prefix === "v" ? "variants" :
prefix === "i" ? "images" :
"pictures";
const idKey = prefix;
const rec = ((state as any)[poolKey] ?? []).find((x: any) => x?.[idKey] === n);
if (!rec) {
imageRefs.push(`<!-- ${notation}: not found in state -->`);
continue;
}
// Resolve original absolute path
const origAbs: string | undefined =
prefix === "a"
? (rec.originAbs as string | undefined) ??
(rec.filename ? path.join(chatWd, rec.filename as string) : undefined)
: rec.filename
? path.join(chatWd, rec.filename as string)
: undefined;
if (!origAbs || !fs.existsSync(origAbs)) {
imageRefs.push(`<!-- ${notation}: file not found -->`);
continue;
}
const baseName = sanitizeImageFilename(path.basename(origAbs));
const dest = path.join(imagesDir, baseName);
try {
fs.copyFileSync(origAbs, dest);
imageRefs.push(``);
hasImages = true;
} catch (e) {
imageRefs.push(`<!-- ${notation}: copy failed — ${(e as Error).message} -->`);
}
}
if (imageRefs.length > 0) {
bodyWithImages = `${args.body}\n\n${imageRefs.join("\n")}`;
}
if (hasImages) {
// Append has_images tag for searchability if not already present
const hasTags: string[] = args.tags ?? [];
if (!hasTags.includes("has_images")) {
hasTags.push("has_images");
}
const frontmatter = buildFrontmatter(args.title, hasTags, now, existingCreated);
const content = `${frontmatter}${bodyWithImages}`;
fs.writeFileSync(filePath, content, "utf8");
return `Saved "${args.title}" → ${filename}`;
}
}
const frontmatter = buildFrontmatter(args.title, args.tags ?? [], now, existingCreated);
const content = `${frontmatter}${bodyWithImages}`;
fs.writeFileSync(filePath, content, "utf8");
return `Saved "${args.title}" → ${filename}`;
},
});
}