import { formatError } from "@common/errors"; import { Type, type Static, type TObject } from '@common/typebox'; import { LocationScale, type AppState, type Character, type Location } from "../contexts/state"; import type LLM from "./llm"; const SCALE_DESCRIPTION = Object.entries(LocationScale) .filter(([, value]) => typeof value === 'number') .map(([key, value]) => `${value}=${key}`) .join(', '); const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number'); export namespace Tools { interface Tool { description: string; parameters: T; handler(args: Static, appState: AppState): unknown; } const tool = (t: Tool): Tool => t; const TOOLS: Record = { 'append_to_story': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_STORY', id: appState.currentStory.id, text: appState.currentStory.text + args.text, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'story' }); return 'Text appended successfully'; }, description: 'Append text to the current story', parameters: Type.Object({ text: Type.String({ description: 'The text to append to the story' }), }), }), 'append_to_lore': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_LORE', id: appState.currentStory.id, lore: appState.currentStory.lore + args.text }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'lore' }); return 'Text appended to lore successfully'; }, description: 'Append text to the story lore', parameters: Type.Object({ text: Type.String({ description: 'The text to append to the lore' }), }), }), 'add_character': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } // Filter out relations that reference non-existent characters const existingCharacterNames = appState.currentStory.characters.map(c => c.name); const invalidRelations: string[] = []; const validRelations = (args.relations || []).filter(rel => { if (!existingCharacterNames.includes(rel.name)) { invalidRelations.push(`${rel.name} (${rel.relation})`); return false; } return true; }); appState.dispatch({ type: 'ADD_CHARACTER', storyId: appState.currentStory.id, character: { id: crypto.randomUUID(), name: args.name.trim(), nicknames: args.nicknames || [], shortDescription: args.shortDescription.trim(), description: args.description || '', relations: validRelations, }, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'characters' }); let message = `Character "${args.name.trim()}" added successfully`; if (invalidRelations.length > 0) { message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`; } return message; }, description: 'Add a new character to the story', parameters: Type.Object({ name: Type.String({ description: "The character's full name" }), shortDescription: Type.String({ description: 'A brief description of the character (one line)' }), nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })), description: Type.Optional(Type.String({ description: 'Optional full character description' })), relations: Type.Optional(Type.Array( Type.Object({ name: Type.String({ description: 'Related character name' }), relation: Type.String({ description: 'Relationship type' }), }), { description: 'Optional list of relationships with other characters' } )), }), }), 'edit_character': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } const character = appState.currentStory.characters.find(c => c.name === args.name.trim()); if (!character) { return `Error: Character "${args.name.trim()}" not found`; } // Only include defined values to avoid setting fields to undefined const definedUpdates: Partial = {}; if (args.shortDescription !== undefined) { definedUpdates.shortDescription = args.shortDescription; } if (args.description !== undefined) { definedUpdates.description = args.description; } appState.dispatch({ type: 'EDIT_CHARACTER', storyId: appState.currentStory.id, characterId: character.id, updates: definedUpdates, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'characters' }); return `Character "${args.name.trim()}" updated successfully`; }, description: "Edit an existing character's description", parameters: Type.Object({ name: Type.String({ description: "The character's full name to identify which character to edit" }), shortDescription: Type.Optional(Type.String({ description: 'Brief description of the character (one line)' })), description: Type.Optional(Type.String({ description: 'Full character description' })), }), }), 'add_location': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'ADD_LOCATION', storyId: appState.currentStory.id, location: { id: crypto.randomUUID(), name: args.name.trim(), shortDescription: args.shortDescription.trim(), description: args.description || '', scale: args.scale, }, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'locations' }); return `Location "${args.name.trim()}" added successfully`; }, description: 'Add a new location to the story', parameters: Type.Object({ name: Type.String({ description: "The location's full name" }), shortDescription: Type.String({ description: 'A brief description of the location (one line)' }), description: Type.Optional(Type.String({ description: 'Optional full location description' })), scale: Type.Enum(VALID_SCALES, { description: `Location scale (enum): ${SCALE_DESCRIPTION}`, }), }), }), 'edit_location': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } const location = appState.currentStory.locations.find(l => l.name === args.name.trim()); if (!location) { return `Error: Location "${args.name.trim()}" not found`; } const definedUpdates: Partial = {}; if (args.shortDescription !== undefined) { definedUpdates.shortDescription = args.shortDescription; } if (args.description !== undefined) { definedUpdates.description = args.description; } if (args.scale !== undefined) { definedUpdates.scale = args.scale; } appState.dispatch({ type: 'EDIT_LOCATION', storyId: appState.currentStory.id, locationId: location.id, updates: definedUpdates, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'locations' }); return `Location "${args.name.trim()}" updated successfully`; }, description: "Edit an existing location's description", parameters: Type.Object({ name: Type.String({ description: "The location's full name to identify which location to edit" }), shortDescription: Type.Optional(Type.String({ description: 'Brief description of the location (one line)' })), description: Type.Optional(Type.String({ description: 'Full location description' })), scale: Type.Optional(Type.Enum(VALID_SCALES, { description: `Location scale (enum): ${SCALE_DESCRIPTION}`, })), }), }), 'edit_story': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } const occurrences = appState.currentStory.text.split(args.old_text).length - 1; if (occurrences === 0) { return 'Error: old_text not found in story'; } if (occurrences > 1 && !args.replace_all) { return 'Error: old_text appears multiple times in story'; } appState.dispatch({ type: 'EDIT_STORY', id: appState.currentStory.id, text: appState.currentStory.text.replaceAll(args.old_text, args.new_text), }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'story' }); return 'Story edited successfully'; }, description: 'Replace text in the current story', parameters: Type.Object({ old_text: Type.String({ description: 'The text to find and replace in the story' }), new_text: Type.String({ description: 'The new text to replace old_text with' }), replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })), }), }), 'edit_lore': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } const occurrences = appState.currentStory.lore.split(args.old_text).length - 1; if (occurrences === 0) { return 'Error: old_text not found in lore'; } if (occurrences > 1 && !args.replace_all) { return 'Error: old_text appears multiple times in lore'; } appState.dispatch({ type: 'EDIT_LORE', id: appState.currentStory.id, lore: appState.currentStory.lore.replaceAll(args.old_text, args.new_text), }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'lore' }); return 'Lore edited successfully'; }, description: 'Replace text in the story lore', parameters: Type.Object({ old_text: Type.String({ description: 'The text to find and replace in the lore' }), new_text: Type.String({ description: 'The new text to replace old_text with' }), replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })), }), }), 'grep': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } const lines = appState.currentStory.text.split('\n'); const matches: { line: number; content: string }[] = []; const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi'); for (let i = 0; i < lines.length; i++) { if (pattern.test(lines[i])) { matches.push({ line: i + 1, content: lines[i].trim() }); } } if (matches.length === 0) { return `No matches found for pattern: ${args.pattern}`; } const limit = args.limit ?? 20; const truncated = matches.length > limit; const displayed = truncated ? matches.slice(0, limit) : matches; let result = `Found ${matches.length} match(es) for pattern "${args.pattern}":\n`; result += displayed.map(m => `Line ${m.line}: ${m.content}`).join('\n'); if (truncated) { result += `\n... and ${matches.length - limit} more match(es)`; } return result; }, description: 'Search for a pattern in the story text', parameters: Type.Object({ pattern: Type.String({ description: 'The regex pattern to search for' }), case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })), limit: Type.Optional(Type.Integer({ description: 'Maximum number of matches to return (default: 20)' })), }), }) }; export function getTools(): LLM.Tool[] { return Object.entries(TOOLS).map(([key, tool]) => ({ type: 'function', function: { name: key, description: tool.description, parameters: tool.parameters, }, })); } function parseArg(arg: unknown): unknown { if (typeof arg !== 'string') return arg; try { const obj = JSON.parse(arg); if (Array.isArray(obj)) { return obj.map(parseArg); } if (typeof obj === 'object') { return Object.fromEntries( Object.entries(obj) .map(([key, value]) => [key, parseArg(value)]) ) } return obj; } catch { } return arg; } export async function executeTool(appState: AppState, toolCall: LLM.ToolCall): Promise { const { function: fn } = toolCall; const args = parseArg(fn.arguments); const tool = TOOLS[fn.name]; if (!tool) { return `Unknown tool: ${fn.name}`; } const errors = Type.Check(tool.parameters, args); if (errors.length > 0) { return errors.map(e => e.message).join('\n'); } try { const result = await tool.handler(args as any, appState); if (typeof result === 'string') { return result; } return JSON.stringify(result); } catch (err) { return formatError(err, 'Error executing tool'); } } }