Forked from khtsly/persistent-memory
Project Files
src / toolsProvider.ts
/**
* @file toolsProvider.ts
* Registers all five memory tools with LM Studio.
*
* Tools:
* 1. Remember — store a new memory
* 2. Recall — retrieve memories by topic/query
* 3. Search Memory — advanced search with filters
* 4. Forget — delete memories
* 5. Memory Status — stats and diagnostics
*/
import { tool } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./config";
import { MemoryDatabase } from "./storage/db";
import { RetrievalEngine } from "./retrieval/engine";
import { extractFacts, detectConflicts } from "./processing/ai";
import {
VALID_CATEGORIES,
VALID_SCOPES,
MAX_MEMORY_CONTENT_LENGTH,
MAX_TAGS_PER_MEMORY,
MAX_SEARCH_RESULTS,
DEFAULT_SEARCH_RESULTS,
MAX_SESSION_MEMORIES,
type MemoryCategory,
} from "./constants";
import {
buildProjectMemoryTags,
getDurableProjectCategories,
resolveProjectMemoryTarget,
} from "./projectMemory";
import type { PluginController } from "./pluginTypes";
import type { MemoryRecord, ScoredMemory } from "./types";
function readConfig(ctl: PluginController) {
const c = ctl.getPluginConfig(configSchematics);
return {
autoInject: c.get("autoInjectMemories") === "on",
contextCount: c.get("contextMemoryCount") || 5,
enableAI: c.get("enableAIExtraction") === "on",
enableConflict: c.get("enableConflictDetection") === "on",
decayDays: c.get("decayHalfLifeDays") || 30,
storagePath: c.get("memoryStoragePath") || "",
activeProject: c.get("activeProject") || "",
};
}
/** Shared singleton instances (initialized once, reused across calls). */
let db: MemoryDatabase | null = null;
let engine: RetrievalEngine | null = null;
let currentPath: string = "";
let initPromise: Promise<{
db: MemoryDatabase;
engine: RetrievalEngine;
}> | null = null;
async function ensureInitialized(
storagePath: string,
): Promise<{ db: MemoryDatabase; engine: RetrievalEngine }> {
const resolved = storagePath || "";
if (db && engine && currentPath === resolved) return { db, engine };
if (initPromise && currentPath === resolved) return initPromise;
initPromise = (async () => {
if (db) {
try {
db.close();
} catch {
}
}
db = new MemoryDatabase(resolved || undefined);
await db.init();
engine = new RetrievalEngine(db);
engine.rebuildIndex();
currentPath = resolved;
return { db: db!, engine: engine! };
})();
return initPromise;
}
/** Export for use by the prompt preprocessor. */
export async function getSharedInstances(storagePath: string) {
return ensureInitialized(storagePath);
}
/**
* Session memory store — in-memory only, never persisted to SQLite.
* Cleared when LM Studio restarts or plugin reloads.
*/
const sessionMemories: Map<string, MemoryRecord> = new Map();
function storeSession(
content: string,
category: MemoryCategory,
tags: string[],
): string {
if (sessionMemories.size >= MAX_SESSION_MEMORIES) {
const oldest = [...sessionMemories.entries()].sort(
(a, b) => a[1].createdAt - b[1].createdAt,
)[0];
if (oldest) sessionMemories.delete(oldest[0]);
}
const now = Date.now();
const id = `sess_${now}_${Math.random().toString(36).slice(2, 8)}`;
sessionMemories.set(id, {
id,
content,
category,
tags,
confidence: 1.0,
source: "tool-call",
scope: "session",
project: null,
createdAt: now,
updatedAt: now,
lastAccessedAt: now,
accessCount: 0,
supersedes: null,
});
return id;
}
/** Get session memories matching a query (simple substring match). */
function searchSession(query: string): MemoryRecord[] {
const lower = query.toLowerCase();
return [...sessionMemories.values()].filter(
(m) =>
m.content.toLowerCase().includes(lower) ||
m.tags.some((t) => t.includes(lower)),
);
}
export async function toolsProvider(ctl: PluginController) {
const cfg = readConfig(ctl);
const { db, engine } = await ensureInitialized(cfg.storagePath);
const durableProjectCategories = getDurableProjectCategories() as [
MemoryCategory,
...MemoryCategory[],
];
const rememberTool = tool({
name: "Remember",
description:
`Store a fact, preference, project detail, or note in memory.\n\n` +
`SCOPES:\n` +
`• "global" — user-wide preferences/instructions across all conversations\n` +
`• "project" — repo-specific knowledge for one stable project namespace\n` +
`• "session" — temporary task state, lost when LM Studio closes\n\n` +
`PROJECT RULE:\n` +
`• When Active Project is set, durable repo facts, architecture decisions, workflow rules, team conventions, and recurring issues should use project scope for that project\n` +
`• Do not store transient chatter as project memory\n` +
`• Prefer distilled reusable facts over raw dumps\n\n` +
`Categories: ${VALID_CATEGORIES.join(", ")}`,
parameters: {
content: z
.string()
.min(3)
.max(MAX_MEMORY_CONTENT_LENGTH)
.describe(
"The fact/preference/note to remember. Be concise but complete.",
),
category: z.enum(VALID_CATEGORIES).describe("Category of this memory."),
tags: z
.array(z.string().max(50))
.max(MAX_TAGS_PER_MEMORY)
.optional()
.describe(
"Optional tags for easier retrieval (e.g., ['typescript', 'coding', 'preference']).",
),
confidence: z
.number()
.min(0)
.max(1)
.optional()
.describe(
"How confident you are in this fact (0.0–1.0). Default 1.0 for explicit statements.",
),
scope: z
.enum(VALID_SCOPES)
.optional()
.describe(
"Memory scope: 'global' (default, all chats), 'project' (project-specific), 'session' (temporary).",
),
project: z
.string()
.max(60)
.optional()
.describe(
"Project name (required when scope='project'). E.g., 'lms-memory-plugin', 'my-website'.",
),
},
implementation: async (
{ content, category, tags, confidence, scope, project },
{ status, warn },
) => {
const target = resolveProjectMemoryTarget(cfg, {
scope,
project,
});
if (target.scope === "project" && !target.project) {
return {
stored: false,
error:
"project-scoped memory requires a project name or configured Active Project",
};
}
if (target.scope === "session") {
const id = storeSession(content, category, tags ?? []);
status("Session memory stored (temporary)");
return {
stored: true,
id,
scope: "session",
content,
category,
tags: tags ?? [],
};
}
const normalizedTags =
target.scope === "project" && target.project
? buildProjectMemoryTags(tags, target.project)
: (tags ?? []);
status("Storing memory…");
try {
const id = db.store(
content,
category,
normalizedTags,
confidence ?? 1.0,
"tool-call",
null,
target.scope,
target.project,
);
engine.indexMemory(id, content, normalizedTags, category);
let conflictInfo: {
type: string;
existingContent: string;
resolution: string;
} | null = null;
if (cfg.enableConflict) {
try {
const existing = engine.retrieve(content, 5, cfg.decayDays);
const others = existing.memories.filter((m) => m.id !== id);
if (others.length > 0) {
const conflicts = await detectConflicts(content, others);
for (const conflict of conflicts) {
if (
conflict.resolution === "skip" ||
conflict.conflictType === "duplicate"
) {
db.delete(id);
engine.removeFromIndex(id);
status("Duplicate detected — removed");
return {
stored: false,
reason: "duplicate",
existingMemory: conflict.existingContent,
};
}
if (conflict.resolution === "supersede") {
db.update(id, content, confidence ?? 1.0, normalizedTags);
conflictInfo = {
type: "supersede",
existingContent: conflict.existingContent,
resolution: "New memory stored, supersedes older version",
};
break;
}
if (conflict.conflictType === "contradiction") {
warn(
`Potential contradiction with: "${conflict.existingContent.slice(0, 100)}"`,
);
conflictInfo = {
type: "contradiction",
existingContent: conflict.existingContent,
resolution:
"Both memories kept — you may want to resolve this",
};
}
}
}
} catch {
}
}
status("Memory stored successfully");
return {
stored: true,
id,
scope: target.scope,
content,
category,
tags: normalizedTags,
...(target.project ? { project: target.project } : {}),
...(conflictInfo ? { conflict: conflictInfo } : {}),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
warn(`Failed to store memory: ${msg}`);
return { stored: false, error: msg };
}
},
});
const bootstrapProjectTool = tool({
name: "Bootstrap Project Memory",
description:
`Store a distilled batch of project memories for the active project or an explicit project slug. ` +
`Use this after reviewing repo structure, architecture, commands, conventions, constraints, failure modes, or active goals. ` +
`Do not paste raw file dumps; store concise reusable facts instead.`,
parameters: {
project: z
.string()
.max(60)
.optional()
.describe(
"Project slug to store under. Defaults to Active Project when omitted.",
),
entries: z
.array(
z.object({
content: z
.string()
.min(8)
.max(MAX_MEMORY_CONTENT_LENGTH)
.describe("Distilled project fact to remember."),
category: z
.enum(durableProjectCategories)
.describe(
"Project-memory category. Use project/instruction/fact/note only.",
),
tags: z
.array(z.string().max(50))
.max(MAX_TAGS_PER_MEMORY)
.optional()
.describe("Helpful retrieval tags for this distilled memory."),
confidence: z
.number()
.min(0)
.max(1)
.optional()
.describe("Confidence for this distilled memory."),
}),
)
.min(1)
.max(25)
.describe(
"Distilled project memories such as architecture invariants, commands, service boundaries, constraints, failure modes, and active goals.",
),
},
implementation: async ({ project, entries }, { status, warn }) => {
const target = resolveProjectMemoryTarget(cfg, {
scope: "project",
project,
});
if (!target.project) {
return {
stored: 0,
error:
"Bootstrap Project Memory requires an explicit project or configured Active Project",
};
}
status(`Bootstrapping ${entries.length} project memories…`);
const stored: Array<{
id: string;
content: string;
category: MemoryCategory;
tags: string[];
}> = [];
for (const entry of entries) {
try {
const normalizedTags = buildProjectMemoryTags(
entry.tags,
target.project,
);
const id = db.store(
entry.content,
entry.category,
normalizedTags,
entry.confidence ?? 0.9,
"project-bootstrap",
null,
"project",
target.project,
);
engine.indexMemory(id, entry.content, normalizedTags, entry.category);
stored.push({
id,
content: entry.content,
category: entry.category,
tags: normalizedTags,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
warn(`Failed to store bootstrap memory: ${msg}`);
}
}
status(`Stored ${stored.length} project memories`);
return {
stored: stored.length,
scope: "project",
project: target.project,
entries: stored,
};
},
});
const recallTool = tool({
name: "Recall",
description:
`Search memory for relevant facts, preferences, or notes. ` +
`Searches across all scopes (global + project + session) by default.\n\n` +
`USE THIS when:\n` +
`• You need to check what you know about the user\n` +
`• The user references something from a past conversation\n` +
`• You want context before answering a question\n` +
`• The user asks "do you remember…" or "what do you know about…"`,
parameters: {
query: z
.string()
.min(2)
.describe(
"What to search for — natural language topic, keyword, or question.",
),
limit: z
.number()
.int()
.min(1)
.max(MAX_SEARCH_RESULTS)
.optional()
.describe(
`Max results to return (default: ${DEFAULT_SEARCH_RESULTS}).`,
),
category: z
.enum(VALID_CATEGORIES)
.optional()
.describe("Filter by category."),
scope: z
.enum(VALID_SCOPES)
.optional()
.describe("Filter by scope. Omit to search all scopes."),
project: z
.string()
.max(60)
.optional()
.describe(
"Filter by project name. Only returns memories from this project.",
),
},
implementation: async (
{ query, limit, category, scope, project },
{ status },
) => {
status(`Searching memories: "${query}"`);
const maxResults = limit ?? DEFAULT_SEARCH_RESULTS;
const result = cfg.enableAI
? await engine.retrieveWithSRLM(query, maxResults, cfg.decayDays, 3)
: engine.retrieve(query, maxResults, cfg.decayDays);
let memories: Array<ScoredMemory | MemoryRecord> = [...result.memories];
const sessionHits = searchSession(query);
if (sessionHits.length > 0) {
memories.push(...sessionHits);
}
if (category) memories = memories.filter((m) => m.category === category);
if (scope) memories = memories.filter((m) => m.scope === scope);
if (project) memories = memories.filter((m) => m.project === project);
memories.sort((a, b) => {
const scoreA = "compositeScore" in a ? a.compositeScore : 0.5;
const scoreB = "compositeScore" in b ? b.compositeScore : 0.5;
return scoreB - scoreA;
});
memories = memories.slice(0, maxResults);
if (memories.length === 0) {
status("No relevant memories found");
return {
found: 0,
memories: [],
suggestion:
"No memories match this query. The user may need to share this information first.",
};
}
status(
`Found ${memories.length} relevant memories (${result.timeTakenMs.toFixed(1)}ms)`,
);
return {
found: memories.length,
totalMatched: result.totalMatched + sessionHits.length,
searchTimeMs: Math.round(result.timeTakenMs),
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
confidence: m.confidence,
scope: m.scope,
...(m.project ? { project: m.project } : {}),
relevance:
"compositeScore" in m ? Math.round(m.compositeScore * 100) : 50,
lastAccessed: new Date(m.lastAccessedAt).toISOString(),
accessCount: m.accessCount,
})),
};
},
});
const searchTool = tool({
name: "Search Memory",
description:
`Advanced memory search with filters by category, tag, or recency. ` +
`Use 'Recall' for simple topic-based retrieval. Use this for precise filtering.`,
parameters: {
query: z
.string()
.optional()
.describe("Optional text query for semantic search."),
category: z
.enum(VALID_CATEGORIES)
.optional()
.describe("Filter by memory category."),
tag: z.string().optional().describe("Filter by tag (exact match)."),
recent: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe(
"Get the N most recently created memories (ignores query/filters).",
),
limit: z
.number()
.int()
.min(1)
.max(MAX_SEARCH_RESULTS)
.optional()
.describe(`Max results (default: ${DEFAULT_SEARCH_RESULTS}).`),
},
implementation: async (
{ query, category, tag, recent, limit },
{ status },
) => {
const maxResults = limit ?? DEFAULT_SEARCH_RESULTS;
if (recent) {
status(`Getting ${recent} most recent memories`);
const memories = db.getRecent(recent);
return {
mode: "recent",
found: memories.length,
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
confidence: m.confidence,
created: new Date(m.createdAt).toISOString(),
})),
};
}
if (tag && !query) {
status(`Searching by tag: "${tag}"`);
const memories = db.getByTag(tag, maxResults);
return {
mode: "tag-filter",
tag,
found: memories.length,
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
})),
};
}
if (category && !query) {
status(`Searching by category: "${category}"`);
const memories = db.getByCategory(category, maxResults);
return {
mode: "category-filter",
category,
found: memories.length,
memories: memories.map((m) => ({
id: m.id,
content: m.content,
tags: m.tags,
confidence: m.confidence,
})),
};
}
if (query) {
status(`Searching: "${query}"`);
const result = engine.retrieve(query, maxResults, cfg.decayDays);
let memories = result.memories;
if (category)
memories = memories.filter((m) => m.category === category);
if (tag)
memories = memories.filter((m) => m.tags.includes(tag.toLowerCase()));
return {
mode: "semantic",
found: memories.length,
searchTimeMs: Math.round(result.timeTakenMs),
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
relevance: Math.round(m.compositeScore * 100),
})),
};
}
status("Returning recent memories");
const memories = db.getRecent(maxResults);
return {
mode: "recent-fallback",
found: memories.length,
memories: memories.map((m) => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
})),
};
},
});
const forgetTool = tool({
name: "Forget",
description:
`Delete memories by ID, content pattern, or clear all. ` +
`Use when the user asks you to forget something or when information is outdated.\n\n` +
`USE THIS when:\n` +
`• User says "forget that" or "delete that memory"\n` +
`• User corrects a fact (delete old, store new)\n` +
`• User wants to clear their data`,
parameters: {
id: z
.string()
.optional()
.describe("Exact memory ID to delete (from Recall/Search results)."),
pattern: z
.string()
.optional()
.describe("Delete all memories whose content contains this text."),
deleteAll: z
.boolean()
.optional()
.describe(
"Set to true to delete ALL memories. Use with extreme caution.",
),
},
implementation: async ({ id, pattern, deleteAll }, { status, warn }) => {
if (deleteAll === true) {
const dbCount = db.deleteAll();
const sessCount = sessionMemories.size;
sessionMemories.clear();
engine.rebuildIndex();
status(
`Deleted all ${dbCount + sessCount} memories (${dbCount} persistent + ${sessCount} session)`,
);
return { deleted: dbCount + sessCount, mode: "delete-all" };
}
if (id) {
if (sessionMemories.has(id)) {
sessionMemories.delete(id);
status("Session memory deleted");
return { deleted: 1, id, scope: "session" };
}
const existed = db.delete(id);
if (existed) {
engine.removeFromIndex(id);
status("Memory deleted");
return { deleted: 1, id };
}
return { deleted: 0, error: "Memory ID not found" };
}
if (pattern) {
let sessDeleted = 0;
for (const [sid, mem] of sessionMemories) {
if (mem.content.toLowerCase().includes(pattern.toLowerCase())) {
sessionMemories.delete(sid);
sessDeleted++;
}
}
const dbDeleted = db.deleteByPattern(pattern);
if (dbDeleted > 0) engine.rebuildIndex();
const total = dbDeleted + sessDeleted;
status(`Deleted ${total} memories matching "${pattern}"`);
return { deleted: total, pattern };
}
warn("No deletion target specified");
return {
deleted: 0,
error: "Specify an id, pattern, or set deleteAll: true",
};
},
});
const statusTool = tool({
name: "Memory Status",
description:
`Get statistics about the memory system: total count by scope, categories, ` +
`session memory count, most accessed memory, database size, and index health.`,
parameters: {},
implementation: async (_, { status }) => {
status("Gathering memory statistics…");
const stats = db.getStats();
const idxStats = engine.indexStats;
return {
...stats,
sessionMemories: sessionMemories.size,
dbSizeKB: Math.round(stats.dbSizeBytes / 1024),
indexVocabularySize: idxStats.vocabSize,
indexedDocuments: idxStats.docCount,
scopes: {
global: "Persistent across all conversations",
project: "Persistent, filtered by project name",
session: `Temporary, in-memory only (${sessionMemories.size} active)`,
},
};
},
});
return [
rememberTool,
bootstrapProjectTool,
recallTool,
searchTool,
forgetTool,
statusTool,
];
}