Files
waggle-os/packages/memory-mcp/src/tools/memory.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

191 lines
6.8 KiB
TypeScript

/**
* Memory tools — save_memory + recall_memory.
* The bread and butter: create I-Frames and hybrid-search recall.
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
getFrameStore,
getSearch,
getSessions,
getEmbedder,
getWorkspaceMind,
getWorkspaceManager,
} from '../core/setup.js';
import type { Importance, FrameSource } from '@waggle/core';
export function registerMemoryTools(server: McpServer): void {
// ── save_memory ─────────────────────────────────────────────────
server.tool(
'save_memory',
'Save a memory (fact, decision, preference, context) that persists across conversations. Auto-indexes for semantic search.',
{
content: z.string().describe('The memory content to save'),
importance: z.enum(['critical', 'important', 'normal', 'temporary']).optional()
.describe('Memory importance level. Defaults to "normal"'),
source: z.enum(['user_stated', 'tool_verified', 'agent_inferred', 'system']).optional()
.describe('How this memory was obtained. Defaults to "agent_inferred"'),
workspace: z.string().optional()
.describe('Workspace ID to save into. Omit for personal memory'),
},
async ({ content, importance, source, workspace }) => {
const imp = (importance ?? 'normal') as Importance;
const src = (source ?? 'agent_inferred') as FrameSource;
// Resolve target mind
const target = workspace ? getWorkspaceMind(workspace) : null;
const frameStore = target?.frameStore ?? getFrameStore();
const sessions = target?.sessions ?? getSessions();
const search = target?.search ?? getSearch();
// Group frames into daily sessions (mcp:YYYY-MM-DD)
const today = new Date().toISOString().slice(0, 10);
const session = sessions.ensure(`mcp:${today}`, undefined, `MCP session ${today}`);
// Create the I-Frame (dedup is built into FrameStore)
const frame = frameStore.createIFrame(session.gop_id, content, imp, src);
// Index in vector store for semantic search
try {
await search.indexFrame(frame.id, content);
} catch {
// Vector indexing failure is non-fatal — FTS still works
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
id: frame.id,
content: frame.content,
importance: frame.importance,
source: frame.source,
created_at: frame.created_at,
workspace: workspace ?? 'personal',
}, null, 2),
}],
};
},
);
// ── recall_memory ───────────────────────────────────────────────
server.tool(
'recall_memory',
'Search memories using semantic hybrid search (keyword + vector). Returns ranked results from personal and/or workspace memories.',
{
query: z.string().describe('Natural language search query'),
limit: z.number().min(1).max(100).optional()
.describe('Maximum results to return. Defaults to 10'),
workspace: z.string().optional()
.describe('Workspace ID to search. Omit to search personal memory'),
scope: z.enum(['current', 'personal', 'all']).optional()
.describe('"current" = active workspace only, "personal" = personal mind only, "all" = search everything. Defaults to "personal"'),
profile: z.enum(['balanced', 'recent', 'important', 'connected']).optional()
.describe('Scoring profile for ranking results. Defaults to "balanced"'),
},
async ({ query, limit, workspace, scope, profile }) => {
const maxResults = limit ?? 10;
const scoringProfile = profile ?? 'balanced';
const searchScope = scope ?? 'personal';
interface ResultItem {
id: number;
content: string;
importance: string;
source: string;
score: number;
created_at: string;
from: string;
}
const results: ResultItem[] = [];
const searchOpts = {
limit: maxResults,
profile: scoringProfile as 'balanced' | 'recent' | 'important' | 'connected',
};
// Search personal mind
if (searchScope === 'personal' || searchScope === 'all') {
const search = getSearch();
const personalResults = await search.search(query, searchOpts);
for (const r of personalResults) {
results.push({
id: r.frame.id,
content: r.frame.content,
importance: r.frame.importance,
source: r.frame.source,
score: Math.round(r.finalScore * 1000) / 1000,
created_at: r.frame.created_at,
from: 'personal',
});
}
}
// Search specific workspace
if (searchScope === 'current' && workspace) {
const wsMind = getWorkspaceMind(workspace);
if (wsMind) {
const wsResults = await wsMind.search.search(query, searchOpts);
for (const r of wsResults) {
results.push({
id: r.frame.id,
content: r.frame.content,
importance: r.frame.importance,
source: r.frame.source,
score: Math.round(r.finalScore * 1000) / 1000,
created_at: r.frame.created_at,
from: `workspace:${workspace}`,
});
}
}
}
// Search ALL workspaces when scope is 'all'
if (searchScope === 'all') {
const wm = getWorkspaceManager();
const allWorkspaces = wm.list();
for (const ws of allWorkspaces) {
const wsMind = getWorkspaceMind(ws.id);
if (!wsMind) continue;
try {
const wsResults = await wsMind.search.search(query, searchOpts);
for (const r of wsResults) {
results.push({
id: r.frame.id,
content: r.frame.content,
importance: r.frame.importance,
source: r.frame.source,
score: Math.round(r.finalScore * 1000) / 1000,
created_at: r.frame.created_at,
from: `workspace:${ws.id}`,
});
}
} catch { /* workspace search failure is non-fatal */ }
}
}
// Sort all results by score descending and trim
results.sort((a, b) => b.score - a.score);
const trimmed = results.slice(0, maxResults);
if (trimmed.length === 0) {
return {
content: [{
type: 'text' as const,
text: `No memories found for query: "${query}"`,
}],
};
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify(trimmed, null, 2),
}],
};
},
);
}