Forked from ceveyne/playbook
Forked from ceveyne/playbook
src / tools / memorize.ts
src / tools / memorize.ts
/**
* memorize — write or overwrite a playbook document.
*
* Three modes, selected by which parameters are set:
* - note: title (+tags) → flat file `<slug>.md` at the playbook root
* - skill: name + description → `<slug>/SKILL.md` bundle entry point
* - companion: name + file → `<slug>/<file>` (raw body, no frontmatter)
*/
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 type { IndexManager } from "../indexManager.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
function slugify(title: string): string {
return title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 80);
}
/** Reject absolute paths, `..` traversal, and anything outside the supported .md/.txt extensions. */
function sanitizeRelativeFile(file: string): string | null {
if (!file || path.isAbsolute(file)) return null;
const normalized = path.normalize(file);
if (normalized === "." || normalized.split(path.sep).some((part) => part === "..")) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".md" && ext !== ".txt") return null;
return normalized;
}
function readExistingCreated(filePath: string): string | undefined {
if (!fs.existsSync(filePath)) return undefined;
const existing = fs.readFileSync(filePath, "utf8");
const m = existing.match(/^created:\s*"?([^"\n]+)"?/m);
return m ? m[1].trim() : undefined;
}
function buildFrontmatter(title: string, tags: string[], now: string, existingCreated?: string): string {
const created = existingCreated ?? now;
const tagList = tags.map((t) => `"${t}"`).join(", ");
return `---\ntitle: "${title}"\ntags: [${tagList}]\ncreated: "${created}"\nupdated: "${now}"\n---\n\n`;
}
function buildSkillFrontmatter(name: string, description: string, now: string, existingCreated?: string): string {
const created = existingCreated ?? now;
const esc = (s: string) => s.replace(/"/g, '\\"');
return `---\nname: "${esc(name)}"\ndescription: "${esc(description)}"\ncreated: "${created}"\nupdated: "${now}"\n---\n\n`;
}
export function createMemorizeTool(
_ctl: ToolsProviderController,
index: IndexManager
): Tool {
return tool({
name: "memorize",
description: `Write a new document to the playbook, or completely replace an existing one. Supports plain notes and SKILL.md skill bundles.
Three modes, chosen by which parameters you set:
- Note: set \`title\` (+ optional \`tags\`). Writes a flat file \`<slug>.md\` at the playbook root with title/tags/created/updated frontmatter.
- Skill: set \`name\` + \`description\`. Writes \`<slug>/SKILL.md\` with name/description/created/updated frontmatter — the entry point recall/read will surface.
- Companion file: set \`name\` + \`file\` (e.g. "tools/my-tool.md"). Writes \`<slug>/<file>\` as plain Markdown with no frontmatter — link to it from SKILL.md's body with a relative path. Not indexed by recall; fetch it directly with read once its path is known.
The filename/folder name is always derived by slugifying \`title\` or \`name\`.
When multiple Playbook Directories are configured, new documents are always written to the first (primary) one; the others are read-only search/read sources.
Use this tool when:
- The user explicitly asks you to remember something
- You derive or discover information useful for future conversations
- Summarising the outcome of a completed task
- Creating a structured reference note (how-tos, decisions, preferences)
- Authoring or updating a reusable skill bundle for future sessions
Returns:
- Confirmation with the resulting relative path
- An error if required parameters for the selected mode are missing
Examples:
- title: "API Auth Strategy", tags: ["api", "auth"], body: "Use OAuth2 for..." → note
- name: "pdf-report", description: "Generate paginated PDF reports from tabular data.", body: "# PDF Report Skill\n..." → pdf-report/SKILL.md
- name: "pdf-report", file: "tools/render_table.md", body: "# render_table\n..." → pdf-report/tools/render_table.md
${formatToolMetaBlock()}`,
parameters: {
title: z
.string()
.optional()
.describe("Document title for a plain note. Required unless `name` is set (skill mode)."),
name: z
.string()
.optional()
.describe(
"Skill identifier (kebab-case recommended). When set, targets a skill bundle folder instead of a flat note. Combine with `description` to write SKILL.md, or with `file` to write a companion file."
),
file: z
.string()
.optional()
.describe(
'Relative path inside the skill bundle for a companion file, e.g. "tools/my-tool.md". Requires `name`. Written as-is with no frontmatter.'
),
description: z
.string()
.optional()
.describe(
"One-paragraph discovery description. Required when writing SKILL.md (name set, file omitted) — this is what recall shows before the full file is read."
),
tags: z
.array(z.string())
.default([])
.describe("List of tags to categorise this document. Only used for plain notes."),
body: z
.string()
.describe("The main content of the document (plain Markdown, without frontmatter)."),
},
implementation: async (args) => {
const now = new Date().toISOString();
if (args.name) {
const slug = slugify(args.name);
if (!slug) return "Error: name must contain at least one word character.";
const skillDir = path.join(index.getPlaybookDirectory(), slug);
if (args.file) {
const rel = sanitizeRelativeFile(args.file);
if (!rel) return `Error: invalid file "${args.file}" — must be a relative .md/.txt path without "..".`;
const filePath = path.join(skillDir, rel);
await index.writeFile(filePath, args.body);
return `Saved companion file → ${slug}/${rel}`;
}
if (!args.description) {
return "Error: description is required to write a SKILL.md (or pass `file` to add a companion file instead).";
}
const filePath = path.join(skillDir, "SKILL.md");
const existingCreated = readExistingCreated(filePath);
const frontmatter = buildSkillFrontmatter(args.name, args.description, now, existingCreated);
await index.writeFile(filePath, `${frontmatter}${args.body}`);
return `Saved skill "${args.name}" → ${slug}/SKILL.md`;
}
if (!args.title) {
return "Error: title is required for a plain note (or use `name`/`description` to write a skill).";
}
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(index.getPlaybookDirectory(), filename);
const existingCreated = readExistingCreated(filePath);
const frontmatter = buildFrontmatter(args.title, args.tags, now, existingCreated);
await index.writeFile(filePath, `${frontmatter}${args.body}`);
return `Saved "${args.title}" → ${filename}`;
},
});
}
/**
* memorize — write or overwrite a playbook document.
*
* Three modes, selected by which parameters are set:
* - note: title (+tags) → flat file `<slug>.md` at the playbook root
* - skill: name + description → `<slug>/SKILL.md` bundle entry point
* - companion: name + file → `<slug>/<file>` (raw body, no frontmatter)
*/
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 type { IndexManager } from "../indexManager.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
function slugify(title: string): string {
return title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 80);
}
/** Reject absolute paths, `..` traversal, and anything outside the supported .md/.txt extensions. */
function sanitizeRelativeFile(file: string): string | null {
if (!file || path.isAbsolute(file)) return null;
const normalized = path.normalize(file);
if (normalized === "." || normalized.split(path.sep).some((part) => part === "..")) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".md" && ext !== ".txt") return null;
return normalized;
}
function readExistingCreated(filePath: string): string | undefined {
if (!fs.existsSync(filePath)) return undefined;
const existing = fs.readFileSync(filePath, "utf8");
const m = existing.match(/^created:\s*"?([^"\n]+)"?/m);
return m ? m[1].trim() : undefined;
}
function buildFrontmatter(title: string, tags: string[], now: string, existingCreated?: string): string {
const created = existingCreated ?? now;
const tagList = tags.map((t) => `"${t}"`).join(", ");
return `---\ntitle: "${title}"\ntags: [${tagList}]\ncreated: "${created}"\nupdated: "${now}"\n---\n\n`;
}
function buildSkillFrontmatter(name: string, description: string, now: string, existingCreated?: string): string {
const created = existingCreated ?? now;
const esc = (s: string) => s.replace(/"/g, '\\"');
return `---\nname: "${esc(name)}"\ndescription: "${esc(description)}"\ncreated: "${created}"\nupdated: "${now}"\n---\n\n`;
}
export function createMemorizeTool(
_ctl: ToolsProviderController,
index: IndexManager
): Tool {
return tool({
name: "memorize",
description: `Write a new document to the playbook, or completely replace an existing one. Supports plain notes and SKILL.md skill bundles.
Three modes, chosen by which parameters you set:
- Note: set \`title\` (+ optional \`tags\`). Writes a flat file \`<slug>.md\` at the playbook root with title/tags/created/updated frontmatter.
- Skill: set \`name\` + \`description\`. Writes \`<slug>/SKILL.md\` with name/description/created/updated frontmatter — the entry point recall/read will surface.
- Companion file: set \`name\` + \`file\` (e.g. "tools/my-tool.md"). Writes \`<slug>/<file>\` as plain Markdown with no frontmatter — link to it from SKILL.md's body with a relative path. Not indexed by recall; fetch it directly with read once its path is known.
The filename/folder name is always derived by slugifying \`title\` or \`name\`.
When multiple Playbook Directories are configured, new documents are always written to the first (primary) one; the others are read-only search/read sources.
Use this tool when:
- The user explicitly asks you to remember something
- You derive or discover information useful for future conversations
- Summarising the outcome of a completed task
- Creating a structured reference note (how-tos, decisions, preferences)
- Authoring or updating a reusable skill bundle for future sessions
Returns:
- Confirmation with the resulting relative path
- An error if required parameters for the selected mode are missing
Examples:
- title: "API Auth Strategy", tags: ["api", "auth"], body: "Use OAuth2 for..." → note
- name: "pdf-report", description: "Generate paginated PDF reports from tabular data.", body: "# PDF Report Skill\n..." → pdf-report/SKILL.md
- name: "pdf-report", file: "tools/render_table.md", body: "# render_table\n..." → pdf-report/tools/render_table.md
${formatToolMetaBlock()}`,
parameters: {
title: z
.string()
.optional()
.describe("Document title for a plain note. Required unless `name` is set (skill mode)."),
name: z
.string()
.optional()
.describe(
"Skill identifier (kebab-case recommended). When set, targets a skill bundle folder instead of a flat note. Combine with `description` to write SKILL.md, or with `file` to write a companion file."
),
file: z
.string()
.optional()
.describe(
'Relative path inside the skill bundle for a companion file, e.g. "tools/my-tool.md". Requires `name`. Written as-is with no frontmatter.'
),
description: z
.string()
.optional()
.describe(
"One-paragraph discovery description. Required when writing SKILL.md (name set, file omitted) — this is what recall shows before the full file is read."
),
tags: z
.array(z.string())
.default([])
.describe("List of tags to categorise this document. Only used for plain notes."),
body: z
.string()
.describe("The main content of the document (plain Markdown, without frontmatter)."),
},
implementation: async (args) => {
const now = new Date().toISOString();
if (args.name) {
const slug = slugify(args.name);
if (!slug) return "Error: name must contain at least one word character.";
const skillDir = path.join(index.getPlaybookDirectory(), slug);
if (args.file) {
const rel = sanitizeRelativeFile(args.file);
if (!rel) return `Error: invalid file "${args.file}" — must be a relative .md/.txt path without "..".`;
const filePath = path.join(skillDir, rel);
await index.writeFile(filePath, args.body);
return `Saved companion file → ${slug}/${rel}`;
}
if (!args.description) {
return "Error: description is required to write a SKILL.md (or pass `file` to add a companion file instead).";
}
const filePath = path.join(skillDir, "SKILL.md");
const existingCreated = readExistingCreated(filePath);
const frontmatter = buildSkillFrontmatter(args.name, args.description, now, existingCreated);
await index.writeFile(filePath, `${frontmatter}${args.body}`);
return `Saved skill "${args.name}" → ${slug}/SKILL.md`;
}
if (!args.title) {
return "Error: title is required for a plain note (or use `name`/`description` to write a skill).";
}
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(index.getPlaybookDirectory(), filename);
const existingCreated = readExistingCreated(filePath);
const frontmatter = buildFrontmatter(args.title, args.tags, now, existingCreated);
await index.writeFile(filePath, `${frontmatter}${args.body}`);
return `Saved "${args.title}" → ${filename}`;
},
});
}