191 lines
6.8 KiB
TypeScript
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),
|
|
}],
|
|
};
|
|
},
|
|
);
|
|
}
|