Skip to main content
Glama
narrative-tools.ts16.7 kB
/** * Narrative Tools - Session Notes & Memory Layer * * Implements the "Narrative Memory Layer" for tracking: * - Plot Threads: Active storylines, quests, hooks * - Canonical Moments: Verbatim quotes, key decisions, immutable history * - NPC Voices: Speech patterns, vocabulary, secrets * - Foreshadowing: Hints to drop, secrets to reveal later * - Session Logs: General summaries and mini-updates */ import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; import { SessionContext } from './types.js'; import { getDb } from '../storage/index.js'; // ============================================================================ // SCHEMAS // ============================================================================ const NoteTypeEnum = z.enum([ 'plot_thread', 'canonical_moment', 'npc_voice', 'foreshadowing', 'session_log' ]); const NoteStatusEnum = z.enum([ 'active', 'resolved', 'dormant', 'archived' ]); const VisibilityEnum = z.enum([ 'dm_only', 'player_visible' ]); // Type-specific metadata schemas const PlotThreadMetadata = z.object({ urgency: z.enum(['low', 'medium', 'high', 'critical']).optional(), hooks: z.array(z.string()).optional().default([]), resolution_conditions: z.array(z.string()).optional().default([]) }); const CanonicalMomentMetadata = z.object({ speaker: z.string().optional(), participants: z.array(z.string()).optional().default([]), location: z.string().optional(), session_number: z.number().optional() }); const NpcVoiceMetadata = z.object({ speech_pattern: z.string().optional(), vocabulary: z.array(z.string()).optional().default([]), mannerisms: z.array(z.string()).optional().default([]), current_goal: z.string().optional(), secrets: z.array(z.string()).optional().default([]) }); const ForeshadowingMetadata = z.object({ target: z.string().describe('What this foreshadows'), hints_given: z.array(z.string()).optional().default([]), hints_remaining: z.array(z.string()).optional().default([]), trigger: z.string().optional().describe('When to reveal fully') }); const SessionLogMetadata = z.object({ session_number: z.number().optional(), xp_awarded: z.number().optional(), player_count: z.number().optional() }); // ============================================================================ // TOOL DEFINITIONS // ============================================================================ export const AddNarrativeNoteSchema = z.object({ worldId: z.string().describe('World/campaign ID to associate the note with'), type: NoteTypeEnum.describe('Category of note'), content: z.string().min(1).describe('Main text content of the note'), metadata: z.record(z.any()).optional().default({}).describe('Type-specific structured data'), visibility: VisibilityEnum.optional().default('dm_only'), tags: z.array(z.string()).optional().default([]).describe('Tags for filtering (e.g., "faction:legion")'), entityId: z.string().optional().describe('Link to a character/NPC/location'), entityType: z.enum(['character', 'npc', 'location', 'item']).optional(), status: NoteStatusEnum.optional().default('active') }); export const SearchNarrativeNotesSchema = z.object({ worldId: z.string().describe('World/campaign ID'), query: z.string().optional().describe('Text search in content'), type: NoteTypeEnum.optional().describe('Filter by note type'), status: NoteStatusEnum.optional().describe('Filter by status'), tags: z.array(z.string()).optional().describe('Filter by tags (AND logic)'), entityId: z.string().optional().describe('Filter by linked entity'), visibility: VisibilityEnum.optional().describe('Filter by visibility'), limit: z.number().optional().default(20).describe('Max results to return'), orderBy: z.enum(['created_at', 'updated_at']).optional().default('created_at') }); export const UpdateNarrativeNoteSchema = z.object({ noteId: z.string().describe('ID of the note to update'), content: z.string().optional().describe('New content (if changing)'), metadata: z.record(z.any()).optional().describe('Merge into existing metadata'), status: NoteStatusEnum.optional().describe('Change status (e.g., resolve a plot thread)'), visibility: VisibilityEnum.optional(), tags: z.array(z.string()).optional().describe('Replace tags') }); export const GetNarrativeNoteSchema = z.object({ noteId: z.string().describe('ID of the note to retrieve') }); export const DeleteNarrativeNoteSchema = z.object({ noteId: z.string().describe('ID of the note to delete') }); export const GetNarrativeContextSchema = z.object({ worldId: z.string().describe('World/campaign ID'), includeTypes: z.array(NoteTypeEnum).optional().default(['plot_thread', 'canonical_moment', 'npc_voice', 'foreshadowing']), maxPerType: z.number().optional().default(5).describe('Max notes per type to include'), statusFilter: z.array(NoteStatusEnum).optional().default(['active']).describe('Only include notes with these statuses'), forPlayer: z.boolean().optional().default(false).describe('If true, only return player_visible notes') }); // Tool definitions for registry export const NarrativeTools = { ADD_NARRATIVE_NOTE: { name: 'add_narrative_note', description: 'Create a typed narrative note (plot thread, canonical moment, NPC voice, foreshadowing, or session log). Used to build long-term narrative memory.', inputSchema: AddNarrativeNoteSchema }, SEARCH_NARRATIVE_NOTES: { name: 'search_narrative_notes', description: 'Search and filter narrative notes by type, status, tags, or text content. Returns matching notes for context building.', inputSchema: SearchNarrativeNotesSchema }, UPDATE_NARRATIVE_NOTE: { name: 'update_narrative_note', description: 'Update an existing narrative note. Common use: marking a plot_thread as resolved.', inputSchema: UpdateNarrativeNoteSchema }, GET_NARRATIVE_NOTE: { name: 'get_narrative_note', description: 'Retrieve a single narrative note by ID.', inputSchema: GetNarrativeNoteSchema }, DELETE_NARRATIVE_NOTE: { name: 'delete_narrative_note', description: 'Delete a narrative note. Use sparingly - prefer archiving via status update.', inputSchema: DeleteNarrativeNoteSchema }, GET_NARRATIVE_CONTEXT: { name: 'get_narrative_context_notes', description: 'Retrieve aggregated narrative context for LLM prompt injection. Returns active plot threads, recent canonical moments, NPC voices, and pending foreshadowing.', inputSchema: GetNarrativeContextSchema } } as const; // ============================================================================ // HANDLERS // ============================================================================ export async function handleAddNarrativeNote(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.ADD_NARRATIVE_NOTE.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); const id = uuidv4(); const now = new Date().toISOString(); // Validate metadata against type-specific schema let validatedMetadata = parsed.metadata; try { switch (parsed.type) { case 'plot_thread': validatedMetadata = PlotThreadMetadata.parse(parsed.metadata); break; case 'canonical_moment': validatedMetadata = CanonicalMomentMetadata.parse(parsed.metadata); break; case 'npc_voice': validatedMetadata = NpcVoiceMetadata.parse(parsed.metadata); break; case 'foreshadowing': validatedMetadata = ForeshadowingMetadata.parse(parsed.metadata); break; case 'session_log': validatedMetadata = SessionLogMetadata.parse(parsed.metadata); break; } } catch (e) { // Allow flexible metadata, just warn console.warn(`[NarrativeNote] Metadata validation warning for type ${parsed.type}:`, e); } db.prepare(` INSERT INTO narrative_notes (id, world_id, type, content, metadata, visibility, tags, entity_id, entity_type, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, parsed.worldId, parsed.type, parsed.content, JSON.stringify(validatedMetadata), parsed.visibility, JSON.stringify(parsed.tags), parsed.entityId || null, parsed.entityType || null, parsed.status, now, now ); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, noteId: id, type: parsed.type, message: `Created ${parsed.type} note: "${parsed.content.substring(0, 50)}..."` }) }] }; } export async function handleSearchNarrativeNotes(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.SEARCH_NARRATIVE_NOTES.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); let sql = `SELECT * FROM narrative_notes WHERE world_id = ?`; const params: any[] = [parsed.worldId]; if (parsed.type) { sql += ` AND type = ?`; params.push(parsed.type); } if (parsed.status) { sql += ` AND status = ?`; params.push(parsed.status); } if (parsed.visibility) { sql += ` AND visibility = ?`; params.push(parsed.visibility); } if (parsed.entityId) { sql += ` AND entity_id = ?`; params.push(parsed.entityId); } if (parsed.query) { sql += ` AND content LIKE ?`; params.push(`%${parsed.query}%`); } // Tag filtering (AND logic - all specified tags must be present) if (parsed.tags && parsed.tags.length > 0) { for (const tag of parsed.tags) { sql += ` AND tags LIKE ?`; params.push(`%"${tag}"%`); } } sql += ` ORDER BY ${parsed.orderBy} DESC LIMIT ?`; params.push(parsed.limit); const notes = db.prepare(sql).all(...params) as any[]; // Parse JSON fields const results = notes.map(note => ({ ...note, metadata: JSON.parse(note.metadata || '{}'), tags: JSON.parse(note.tags || '[]') })); return { content: [{ type: 'text' as const, text: JSON.stringify({ count: results.length, notes: results }) }] }; } export async function handleUpdateNarrativeNote(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.UPDATE_NARRATIVE_NOTE.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); // First get the existing note const existing = db.prepare('SELECT * FROM narrative_notes WHERE id = ?').get(parsed.noteId) as any; if (!existing) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Note not found' }) }], isError: true }; } const updates: string[] = []; const params: any[] = []; if (parsed.content !== undefined) { updates.push('content = ?'); params.push(parsed.content); } if (parsed.status !== undefined) { updates.push('status = ?'); params.push(parsed.status); } if (parsed.visibility !== undefined) { updates.push('visibility = ?'); params.push(parsed.visibility); } if (parsed.tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(parsed.tags)); } if (parsed.metadata !== undefined) { // Merge with existing metadata const existingMeta = JSON.parse(existing.metadata || '{}'); const merged = { ...existingMeta, ...parsed.metadata }; updates.push('metadata = ?'); params.push(JSON.stringify(merged)); } if (updates.length === 0) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, message: 'No updates provided' }) }] }; } updates.push('updated_at = ?'); params.push(new Date().toISOString()); params.push(parsed.noteId); db.prepare(`UPDATE narrative_notes SET ${updates.join(', ')} WHERE id = ?`).run(...params); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, noteId: parsed.noteId, message: `Updated note. Changes: ${updates.slice(0, -1).join(', ')}` }) }] }; } export async function handleGetNarrativeNote(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.GET_NARRATIVE_NOTE.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); const note = db.prepare('SELECT * FROM narrative_notes WHERE id = ?').get(parsed.noteId) as any; if (!note) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Note not found' }) }], isError: true }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ ...note, metadata: JSON.parse(note.metadata || '{}'), tags: JSON.parse(note.tags || '[]') }) }] }; } export async function handleDeleteNarrativeNote(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.DELETE_NARRATIVE_NOTE.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); const result = db.prepare('DELETE FROM narrative_notes WHERE id = ?').run(parsed.noteId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: result.changes > 0, deleted: result.changes > 0, message: result.changes > 0 ? 'Note deleted' : 'Note not found' }) }] }; } export async function handleGetNarrativeContextNotes(args: unknown, _ctx: SessionContext) { const parsed = NarrativeTools.GET_NARRATIVE_CONTEXT.inputSchema.parse(args); const db = getDb(process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'); const sections: { title: string; notes: any[]; priority: number }[] = []; // Priority order: foreshadowing > plot_thread > npc_voice > canonical_moment > session_log const typePriority: Record<string, number> = { 'foreshadowing': 100, 'plot_thread': 90, 'npc_voice': 80, 'canonical_moment': 70, 'session_log': 50 }; const typeLabels: Record<string, string> = { 'foreshadowing': '🔮 FORESHADOWING HINTS', 'plot_thread': '📜 ACTIVE PLOT THREADS', 'npc_voice': '🗣️ NPC VOICE NOTES', 'canonical_moment': '⭐ CANONICAL MOMENTS', 'session_log': '📝 SESSION LOGS' }; for (const noteType of parsed.includeTypes) { let sql = `SELECT * FROM narrative_notes WHERE world_id = ? AND type = ?`; const params: any[] = [parsed.worldId, noteType]; // Status filter if (parsed.statusFilter.length > 0) { sql += ` AND status IN (${parsed.statusFilter.map(() => '?').join(',')})`; params.push(...parsed.statusFilter); } // Visibility filter for player-facing context if (parsed.forPlayer) { sql += ` AND visibility = 'player_visible'`; } sql += ` ORDER BY created_at DESC LIMIT ?`; params.push(parsed.maxPerType); const notes = db.prepare(sql).all(...params) as any[]; if (notes.length > 0) { sections.push({ title: typeLabels[noteType] || noteType.toUpperCase(), notes: notes.map(n => ({ id: n.id, content: n.content, metadata: JSON.parse(n.metadata || '{}'), tags: JSON.parse(n.tags || '[]'), status: n.status, entityId: n.entity_id, entityType: n.entity_type, createdAt: n.created_at })), priority: typePriority[noteType] || 0 }); } } // Sort by priority (highest first) sections.sort((a, b) => b.priority - a.priority); // Format for LLM injection let contextText = ''; for (const section of sections) { contextText += `--- ${section.title} ---\n`; for (const note of section.notes) { contextText += `• ${note.content}`; if (note.metadata && Object.keys(note.metadata).length > 0) { const metaStr = Object.entries(note.metadata) .filter(([_, v]) => v !== undefined && v !== null && (Array.isArray(v) ? v.length > 0 : true)) .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`) .join(' | '); if (metaStr) contextText += ` [${metaStr}]`; } if (note.tags && note.tags.length > 0) { contextText += ` #${note.tags.join(' #')}`; } contextText += '\n'; } contextText += '\n'; } return { content: [{ type: 'text' as const, text: contextText.trim() || '(No narrative notes found for this world)' }] }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server