Skip to main content
Glama
machine.ts19.3 kB
import { createMachine, assign, createActor, type AnyActorRef } from 'xstate'; import * as fs from 'node:fs'; import * as path from 'node:path'; // State persistence file path - use __dirname to ensure it's relative to this file const STATE_FILE = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '.agentic-ui-state.json'); // Persisted state shape (Option B - full state restoration including pending input) interface PersistedState { text: string; contentType: 'text' | 'markdown'; history: HistoryEntry[]; userContext: Record<string, unknown>; // Option B: Also persist input request for full state restoration inputRequest: AnyInputRequest | null; inputStatus: InputStatus; multiFieldInput?: Record<string, unknown> | null; sidebarVisible: boolean; } // History entry type - stores both text and contentType for proper undo export interface HistoryEntry { text: string; contentType: 'text' | 'markdown'; } // Input request type - describes a pending user input request (single field) export interface InputRequest { prompt: string; inputType: 'text' | 'textarea' | 'number'; placeholder?: string; defaultValue?: string; requestId: string; key?: string; // Optional key for storing in userContext content?: string; // Optional markdown content to display above the input form } // Multi-field form types export interface FormField { key: string; label: string; type: 'text' | 'textarea' | 'number' | 'checkbox' | 'select'; placeholder?: string; defaultValue?: string | number | boolean; required?: boolean; options?: string[]; // For select fields } export interface MultiFieldRequest { fields: FormField[]; content?: string; // Optional markdown content to display above the form requestId: string; } // Union type for any input request export type AnyInputRequest = InputRequest | MultiFieldRequest; // Type guard to check if request is multi-field export function isMultiFieldRequest(request: AnyInputRequest | null): request is MultiFieldRequest { return request !== null && 'fields' in request; } // Input status type export type InputStatus = 'idle' | 'pending' | 'submitted' | 'cancelled'; // Context type export interface TextMachineContext { text: string; contentType: 'text' | 'markdown'; history: HistoryEntry[]; lastAction: string | null; lastError: string | null; // User input fields - supports both single and multi-field forms inputRequest: AnyInputRequest | null; userInput: string | null; // For single-field responses multiFieldInput: Record<string, unknown> | null; // For multi-field responses inputStatus: InputStatus; // Persistent user context - stores keyed values across multiple inputs userContext: Record<string, unknown>; // Sidebar visibility state sidebarVisible: boolean; } // Event types export type TextMachineEvent = | { type: 'SET_MARKDOWN'; markdown: string } | { type: 'APPEND'; text: string } | { type: 'UNDO' } | { type: 'RESET' } // Single-field input events | { type: 'SHOW_INPUT'; request: InputRequest } | { type: 'SUBMIT_INPUT'; value: string; requestId: string } | { type: 'CANCEL_INPUT'; requestId: string } // Multi-field form events | { type: 'SHOW_MULTI_FORM'; request: MultiFieldRequest } | { type: 'SUBMIT_MULTI_FORM'; values: Record<string, unknown>; requestId: string } // User context events | { type: 'SET_USER_CONTEXT'; key: string; value: unknown } | { type: 'CLEAR_USER_CONTEXT' } // Sidebar toggle event | { type: 'TOGGLE_SIDEBAR' }; // Initial context const initialContext: TextMachineContext = { text: '', contentType: 'text', history: [], lastAction: null, lastError: null, inputRequest: null, userInput: null, multiFieldInput: null, inputStatus: 'idle', userContext: {}, sidebarVisible: true, }; // Create the state machine export const textMachine = createMachine({ id: 'textDisplay', initial: 'idle', context: initialContext, states: { idle: { on: { SET_MARKDOWN: { target: 'displaying', actions: assign({ history: ({ context }) => [...context.history, { text: context.text, contentType: context.contentType }], text: ({ event }) => event.markdown, contentType: () => 'markdown' as const, lastAction: () => 'set_markdown', lastError: () => null, }), }, APPEND: { target: 'displaying', actions: assign({ history: ({ context }) => [...context.history, { text: context.text, contentType: context.contentType }], text: ({ context, event }) => context.text + event.text, lastAction: () => 'append', lastError: () => null, }), }, SHOW_INPUT: { target: 'waitingForInput', actions: assign({ inputRequest: ({ event }) => event.request, userInput: () => null, multiFieldInput: () => null, inputStatus: () => 'pending' as const, lastAction: () => 'show_input', lastError: () => null, }), }, SHOW_MULTI_FORM: { target: 'waitingForInput', actions: assign({ inputRequest: ({ event }) => event.request, userInput: () => null, multiFieldInput: () => null, inputStatus: () => 'pending' as const, lastAction: () => 'show_multi_form', lastError: () => null, }), }, SET_USER_CONTEXT: { actions: assign({ userContext: ({ context, event }) => ({ ...context.userContext, [event.key]: event.value, }), lastAction: () => 'set_user_context', lastError: () => null, }), }, CLEAR_USER_CONTEXT: { actions: assign({ userContext: () => ({}), lastAction: () => 'clear_user_context', lastError: () => null, }), }, TOGGLE_SIDEBAR: { actions: assign({ sidebarVisible: ({ context }) => !context.sidebarVisible, lastAction: () => 'toggle_sidebar', lastError: () => null, }), }, }, }, displaying: { on: { SET_MARKDOWN: { actions: assign({ history: ({ context }) => [...context.history, { text: context.text, contentType: context.contentType }], text: ({ event }) => event.markdown, contentType: () => 'markdown' as const, lastAction: () => 'set_markdown', lastError: () => null, }), }, APPEND: { actions: assign({ history: ({ context }) => [...context.history, { text: context.text, contentType: context.contentType }], text: ({ context, event }) => context.text + event.text, lastAction: () => 'append', lastError: () => null, }), }, UNDO: [ { guard: ({ context }) => context.history.length > 0, actions: assign({ text: ({ context }) => context.history[context.history.length - 1].text, contentType: ({ context }) => context.history[context.history.length - 1].contentType, history: ({ context }) => context.history.slice(0, -1), lastAction: () => 'undo', lastError: () => null, }), }, { actions: assign({ lastError: () => 'No history to undo', }), }, ], RESET: { target: 'idle', actions: assign(({ context }) => ({ ...initialContext, userContext: context.userContext, })), }, SHOW_INPUT: { target: 'waitingForInput', actions: assign({ inputRequest: ({ event }) => event.request, userInput: () => null, multiFieldInput: () => null, inputStatus: () => 'pending' as const, lastAction: () => 'show_input', lastError: () => null, }), }, SHOW_MULTI_FORM: { target: 'waitingForInput', actions: assign({ inputRequest: ({ event }) => event.request, userInput: () => null, multiFieldInput: () => null, inputStatus: () => 'pending' as const, lastAction: () => 'show_multi_form', lastError: () => null, }), }, SET_USER_CONTEXT: { actions: assign({ userContext: ({ context, event }) => ({ ...context.userContext, [event.key]: event.value, }), lastAction: () => 'set_user_context', lastError: () => null, }), }, CLEAR_USER_CONTEXT: { actions: assign({ userContext: () => ({}), lastAction: () => 'clear_user_context', lastError: () => null, }), }, TOGGLE_SIDEBAR: { actions: assign({ sidebarVisible: ({ context }) => !context.sidebarVisible, lastAction: () => 'toggle_sidebar', lastError: () => null, }), }, }, }, waitingForInput: { on: { SUBMIT_INPUT: { target: 'displaying', guard: ({ context, event }) => context.inputRequest?.requestId === event.requestId, actions: assign({ userInput: ({ event }) => event.value, inputStatus: () => 'submitted' as const, // Store in userContext if key was provided (only for single-field requests) userContext: ({ context, event }) => { const request = context.inputRequest; if (request && !isMultiFieldRequest(request) && request.key) { return { ...context.userContext, [request.key]: event.value }; } return context.userContext; }, // Preserve input content as the displayed text (if content was provided) text: ({ context }) => context.inputRequest?.content || context.text, contentType: ({ context }) => context.inputRequest?.content ? 'markdown' as const : context.contentType, inputRequest: () => null, lastAction: () => 'input_submitted', lastError: () => null, }), }, CANCEL_INPUT: { target: 'displaying', guard: ({ context, event }) => context.inputRequest?.requestId === event.requestId, actions: assign({ userInput: () => null, multiFieldInput: () => null, inputStatus: () => 'cancelled' as const, // Preserve input content as the displayed text (if content was provided) text: ({ context }) => context.inputRequest?.content || context.text, contentType: ({ context }) => context.inputRequest?.content ? 'markdown' as const : context.contentType, inputRequest: () => null, lastAction: () => 'input_cancelled', lastError: () => null, }), }, SUBMIT_MULTI_FORM: { target: 'displaying', guard: ({ context, event }) => context.inputRequest?.requestId === event.requestId, actions: assign({ multiFieldInput: ({ event }) => event.values, userInput: () => null, inputStatus: () => 'submitted' as const, // Store all field values in userContext userContext: ({ context, event }) => { const newContext = { ...context.userContext }; for (const [key, value] of Object.entries(event.values)) { newContext[key] = value; } return newContext; }, // Preserve input content as the displayed text (if content was provided) text: ({ context }) => context.inputRequest?.content || context.text, contentType: ({ context }) => context.inputRequest?.content ? 'markdown' as const : context.contentType, inputRequest: () => null, lastAction: () => 'multi_form_submitted', lastError: () => null, }), }, RESET: { target: 'idle', actions: assign(({ context }) => ({ ...initialContext, userContext: context.userContext, })), }, }, }, }, }); // Available actions type export interface AvailableAction { name: string; description: string; inputSchema: { type: string; properties?: Record<string, unknown>; required?: string[]; }; reason?: string; } // Get available actions based on current state export function getAvailableActions( state: string, context: TextMachineContext ): AvailableAction[] { const actions: AvailableAction[] = []; // When waiting for input, only reset is available (submit/cancel come from frontend) if (state === 'waitingForInput') { actions.push({ name: 'reset', description: 'Reset the display to initial state (cancels pending input)', inputSchema: { type: 'object', properties: {} }, }); return actions; } // Actions available in idle and displaying states actions.push({ name: 'set_markdown', description: 'Set the displayed content to markdown', inputSchema: { type: 'object', properties: { markdown: { type: 'string', description: 'The markdown content to display' }, }, required: ['markdown'], }, }); actions.push({ name: 'append', description: 'Append content to the current display', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The content to append' }, }, required: ['text'], }, }); // Actions only available in displaying state if (state === 'displaying') { if (context.history.length > 0) { actions.push({ name: 'undo', description: 'Undo the last change', inputSchema: { type: 'object', properties: {} }, reason: `Can undo ${context.history.length} change(s)`, }); } } // Reset is always available actions.push({ name: 'reset', description: 'Reset the display to initial state', inputSchema: { type: 'object', properties: {} }, }); // Toggle sidebar is always available actions.push({ name: 'toggle_sidebar', description: 'Toggle the sidebar visibility', inputSchema: { type: 'object', properties: {} }, }); return actions; } // ============================================ // State Persistence Functions // ============================================ // Load persisted state from file function loadPersistedState(): Partial<TextMachineContext> | null { try { if (fs.existsSync(STATE_FILE)) { const data = fs.readFileSync(STATE_FILE, 'utf-8'); const parsed = JSON.parse(data) as PersistedState; console.error(`[Persistence] Loaded state from ${STATE_FILE}`); // Option B: Restore inputRequest with a fresh requestId let inputRequest = parsed.inputRequest || null; if (inputRequest) { // Generate new requestId since old one is stale inputRequest = { ...inputRequest, requestId: `restored-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, }; console.error('[Persistence] Restored pending input request with new ID:', inputRequest.requestId); } return { text: parsed.text || '', contentType: parsed.contentType || 'text', history: parsed.history || [], userContext: parsed.userContext || {}, inputRequest, inputStatus: inputRequest ? 'pending' : (parsed.inputStatus || 'idle'), multiFieldInput: parsed.multiFieldInput || null, sidebarVisible: parsed.sidebarVisible ?? true, }; } } catch (error) { console.error('[Persistence] Failed to load state:', error); } return null; } // Save state to file function saveState(context: TextMachineContext): void { try { const toSave: PersistedState = { text: context.text, contentType: context.contentType, history: context.history, userContext: context.userContext, // Option B: Also save input request for full state restoration inputRequest: context.inputRequest, inputStatus: context.inputStatus, multiFieldInput: context.multiFieldInput, sidebarVisible: context.sidebarVisible, }; fs.writeFileSync(STATE_FILE, JSON.stringify(toSave, null, 2)); console.error(`[Persistence] State saved to ${STATE_FILE}`); } catch (error) { console.error('[Persistence] Failed to save state:', error); } } // ============================================ // Actor Instance Management // ============================================ // Singleton actor instance let actorInstance: AnyActorRef | null = null; export function getActor() { if (!actorInstance) { // Try to load persisted state const persistedState = loadPersistedState(); if (persistedState) { // Create machine with persisted context const restoredContext: TextMachineContext = { ...initialContext, text: persistedState.text || '', contentType: persistedState.contentType || 'text', history: persistedState.history || [], userContext: persistedState.userContext || {}, // Option B: Restore input request state inputRequest: persistedState.inputRequest || null, inputStatus: persistedState.inputStatus || 'idle', userInput: null, multiFieldInput: persistedState.multiFieldInput || null, sidebarVisible: persistedState.sidebarVisible ?? true, }; // Determine initial state based on content and pending input let initialState: string; if (restoredContext.inputRequest) { // Option B: If there's a pending input request, restore to waitingForInput initialState = 'waitingForInput'; console.error('[Persistence] Restoring to waitingForInput state'); } else if (restoredContext.text) { initialState = 'displaying'; } else { initialState = 'idle'; } // Create a machine starting in the correct state with restored context const restoredMachine = createMachine({ ...textMachine.config, initial: initialState, context: restoredContext, }); actorInstance = createActor(restoredMachine); } else { actorInstance = createActor(textMachine); } // Subscribe to state changes and persist actorInstance.subscribe((snapshot) => { saveState(snapshot.context); }); actorInstance.start(); } return actorInstance; } // Helper to get current snapshot export function getSnapshot() { const actor = getActor(); return actor.getSnapshot(); } // Helper to send events export function sendEvent(event: TextMachineEvent) { const actor = getActor(); actor.send(event); return actor.getSnapshot(); }

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/uptownhr/pane'

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