diff --git a/package.json b/package.json index 99b9db5..04a8d93 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.1.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^7.4.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/primo/.env.example b/primo/.env.example new file mode 100644 index 0000000..621fbc5 --- /dev/null +++ b/primo/.env.example @@ -0,0 +1,32 @@ +# Ex Libris Primo API Configuration +# Get your API key from your Ex Libris representative or institutional API portal +PRIMO_API_KEY=your-primo-api-key-here + +# Primo API base URL (default: https://api-na.hosted.exlibrisgroup.com/primo/v1/search) +primo_base_url=https://api-na.hosted.exlibrisgroup.com/primo/v1/search + +# Primo View ID (e.g., "01CUNY_GC:Everything" or "YOUR_INST_CODE") +primo_vid=YOUR_VIEW_ID + +# Primo tab to search in (e.g., "Everything") +primo_tab=Everything + +# Primo scope for search results (e.g., "MyInstitution_Scope") +primo_scope=YOUR_SCOPE + +# SpringShare LibGuides API Configuration +# Get these credentials from LibGuides Admin Panel → API / Widgets section +LIBGUIDES_SITE_ID=your-site-id +LIBGUIDES_CLIENT_ID=your-client-id +LIBGUIDES_CLIENT_SECRET=your-client-secret + +# Server Configuration (optional) +# Port for the web server (default: 3110) +# PORT=3110 + +# Base path for reverse proxy deployments (e.g., "/reference-agent") +# BASE_PATH=/ + +# Anthropic API Configuration (optional) +# Only needed if running outside of Claude Code +# ANTHROPIC_API_KEY=your-anthropic-api-key diff --git a/src/lib/conversationHistory.ts b/src/lib/conversationHistory.ts new file mode 100644 index 0000000..1317925 --- /dev/null +++ b/src/lib/conversationHistory.ts @@ -0,0 +1,41 @@ +export type ConversationTurn = { + role: 'user' | 'assistant'; + content: string; +}; + +const MAX_HISTORY_TURNS = 50; + +/** + * Sanitizes and validates conversation history, filtering invalid entries + * and limiting to the most recent turns. + * + * @param history - Raw conversation history + * @param maxTurns - Maximum number of turns to keep (default: 50) + * @returns Sanitized conversation history + */ +export function sanitizeHistory( + history: ConversationTurn[] | undefined, + maxTurns: number = MAX_HISTORY_TURNS +): ConversationTurn[] { + if (!Array.isArray(history) || history.length === 0) { + return []; + } + + const turns: ConversationTurn[] = []; + for (const entry of history) { + if (!entry || typeof entry !== 'object') { + continue; + } + const role = entry.role; + const content = typeof entry.content === 'string' ? entry.content.trim() : ''; + if ((role === 'user' || role === 'assistant') && content) { + turns.push({ role, content }); + } + } + + if (turns.length <= maxTurns) { + return turns; + } + + return turns.slice(turns.length - maxTurns); +} diff --git a/src/primo/client.ts b/src/primo/client.ts index 6a3ed3e..47546d5 100644 --- a/src/primo/client.ts +++ b/src/primo/client.ts @@ -310,6 +310,22 @@ function normaliseDoc(doc: PrimoApiDoc, config: PrimoConfig): PrimoItemSummary { }; } +/** + * Searches the library catalog using the Ex Libris Primo API. + * Returns normalized results with availability information. + * + * @param input - Search parameters + * @param input.query - Search query string + * @param input.limit - Maximum number of results to return (default: 10) + * @param input.offset - Pagination offset (default: 0) + * @param input.scopeOverride - Optional scope override + * @param input.tabOverride - Optional tab override + * @param input.includePcAvailability - Include consortium availability (default: true) + * @param input.qInclude - Additional query parameters + * @param input.facets - Facet filters to apply + * @returns Promise resolving to search results with normalized items + * @throws Error if API request fails or configuration is missing + */ export async function searchPrimo(input: PrimoSearchInput): Promise { const config = getPrimoConfig(); const params = new URLSearchParams(); diff --git a/src/server.ts b/src/server.ts index 969c813..7bb0e5b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,8 +2,10 @@ import express from 'express'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { rateLimit } from 'express-rate-limit'; import { processAgentStream, type ConversationTurn, substituteCitationTokens } from './services/agentRunner.js'; +import { sanitizeHistory } from './lib/conversationHistory.js'; import { searchPrimo } from './primo/client.js'; const currentFile = fileURLToPath(import.meta.url); @@ -30,34 +32,51 @@ const normalizeBasePath = (input: string | undefined): string => { const BASE_PATH = normalizeBasePath(process.env.BASE_PATH); const API_PREFIX = BASE_PATH === '/' ? '/api' : `${BASE_PATH}/api`; const API_PREFIXES = BASE_PATH === '/' ? [API_PREFIX] : ['/api', API_PREFIX]; -const MAX_HISTORY_TURNS = 20; - -function parseHistory(input: unknown): ConversationTurn[] { - if (!Array.isArray(input) || input.length === 0) { - return []; - } - - const turns: ConversationTurn[] = []; - for (const entry of input) { - if (!entry || typeof entry !== 'object') { - continue; - } - const role = (entry as { role?: unknown }).role; - const contentRaw = (entry as { content?: unknown }).content; - const content = typeof contentRaw === 'string' ? contentRaw.trim() : ''; - - if ((role === 'user' || role === 'assistant') && content) { - turns.push({ role, content }); - } - } - - if (turns.length <= MAX_HISTORY_TURNS) { - return turns; - } +const MAX_HISTORY_TURNS_SERVER = 20; // Server limit is more conservative than agent limit +const MAX_PROMPT_LENGTH = 10000; + +// Simple structured logger +function logError(message: string, error: unknown, context?: Record) { + const timestamp = new Date().toISOString(); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + console.error(JSON.stringify({ + timestamp, + level: 'error', + message, + error: errorMessage, + stack: errorStack, + ...context + })); +} - return turns.slice(turns.length - MAX_HISTORY_TURNS); +function logInfo(message: string, context?: Record) { + const timestamp = new Date().toISOString(); + console.log(JSON.stringify({ + timestamp, + level: 'info', + message, + ...context + })); } +// Rate limiting configuration +const queryRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 30, // limit each IP to 30 requests per windowMs + message: { error: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const primoRateLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 60, // limit each IP to 60 requests per minute + message: { error: 'Too many Primo search requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, +}); + export function createApp(): express.Express { const app = express(); @@ -115,13 +134,13 @@ export function createApp(): express.Express { app.use(rootStaticMiddleware); - const registerPostRoute = (suffix: string, handler: express.RequestHandler) => { + const registerPostRoute = (suffix: string, ...handlers: express.RequestHandler[]) => { for (const prefix of API_PREFIXES) { - app.post(`${prefix}${suffix}`, handler); + app.post(`${prefix}${suffix}`, ...handlers); } }; - registerPostRoute('/primo/search', async (req, res) => { + registerPostRoute('/primo/search', primoRateLimiter, async (req, res) => { const { query, limit } = req.body ?? {}; const queryText = typeof query === 'string' ? query.trim() : ''; @@ -160,24 +179,34 @@ export function createApp(): express.Express { res.json(payload); } catch (error) { - console.error('Primo search failed:', error); - res.status(502).json({ + logError('Primo search failed', error, { query: queryText, limit: resolvedLimit }); + + const statusCode = error instanceof Error && error.message.includes('not configured') ? 503 : 502; + res.status(statusCode).json({ error: 'Primo search failed', detail: error instanceof Error ? error.message : String(error) }); } }); - registerPostRoute('/query', async (req, res) => { + registerPostRoute('/query', queryRateLimiter, async (req, res) => { const { prompt, libraryId, history: rawHistory } = req.body ?? {}; const promptText = typeof prompt === 'string' ? prompt.trim() : ''; - const history = parseHistory(rawHistory); + const history = sanitizeHistory(rawHistory as ConversationTurn[] | undefined, MAX_HISTORY_TURNS_SERVER); if (!promptText) { res.status(400).json({ error: 'Prompt is required' }); return; } + if (promptText.length > MAX_PROMPT_LENGTH) { + res.status(400).json({ + error: `Prompt too long (max ${MAX_PROMPT_LENGTH} characters)`, + length: promptText.length + }); + return; + } + const resolvedLibraryId = typeof libraryId === 'string' && libraryId.trim() ? libraryId.trim() : 'mina-rees'; @@ -210,7 +239,11 @@ export function createApp(): express.Express { return true; }; - console.log('Received streaming request for prompt', promptText, 'library', resolvedLibraryId); + logInfo('Received streaming request', { + promptLength: promptText.length, + libraryId: resolvedLibraryId, + hasHistory: history.length > 0 + }); sendEvent('start', { libraryId: resolvedLibraryId }); let clientClosed = false; @@ -229,14 +262,12 @@ export function createApp(): express.Express { res.on('close', handleDisconnect); try { - console.log('Starting query for prompt', promptText); const { response } = await processAgentStream({ prompt: promptText, history, metadata: { source: 'web', libraryId: resolvedLibraryId }, abortController, onMessage: async (message) => { - console.log('SSE message', message.type); if (clientClosed) { return; } @@ -335,10 +366,17 @@ export function createApp(): express.Express { res.end(); } } catch (error) { - console.error('Agent request failed:', error); + logError('Agent request failed', error, { + promptLength: promptText.length, + libraryId: resolvedLibraryId, + clientClosed, + streamFinished + }); + if (!res.writableEnded && !clientClosed) { streamFinished = true; - sendEvent('error', { error: 'Agent request failed' }); + const errorMessage = error instanceof Error ? error.message : 'Agent request failed'; + sendEvent('error', { error: errorMessage }); res.end(); } } @@ -379,14 +417,36 @@ export function createApp(): express.Express { return app; } -const PORT = Number(process.env.PORT) || 3000; +const PORT = Number(process.env.PORT) || 3110; const app = createApp(); if (process.env.NODE_ENV !== 'test') { - app.listen(PORT, () => { + const server = app.listen(PORT, () => { const basePathSuffix = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`; - console.log(`Reference agent web server running at http://localhost:${PORT}${basePathSuffix}`); + logInfo('Server started', { + port: PORT, + basePath: BASE_PATH, + url: `http://localhost:${PORT}${basePathSuffix}` + }); }); + + // Graceful shutdown handling + const gracefulShutdown = (signal: string) => { + logInfo('Shutdown signal received', { signal }); + server.close(() => { + logInfo('Server closed gracefully'); + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + logError('Forced shutdown after timeout', new Error('Shutdown timeout')); + process.exit(1); + }, 10000); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); } export default app; diff --git a/src/services/agentRunner.ts b/src/services/agentRunner.ts index 3265919..474a61e 100644 --- a/src/services/agentRunner.ts +++ b/src/services/agentRunner.ts @@ -9,14 +9,15 @@ import { getBlogMcpServer, BLOG_MCP_SERVER_ID, BLOG_TOOL_NAME } from '../tools/b import { getDatabaseMcpServer, DATABASE_MCP_SERVER_ID, DATABASE_TOOL_NAME } from '../tools/databaseMcpServer.js'; import { getGuidesMcpServer, GUIDES_MCP_SERVER_ID, GUIDES_TOOL_NAME } from '../tools/guidesMcpServer.js'; import { getActiveSearchCache, runWithSearchCache, SearchCache } from '../lib/searchCache.js'; +import { sanitizeHistory, type ConversationTurn } from '../lib/conversationHistory.js'; import { userPromptSubmitHook } from './preRetrieval.js'; export const ALLOWED_TOOLS = ['WebSearch', 'WebFetch', PRIMO_TOOL_NAME, LOG_NOTE_TOOL_NAME, BLOG_TOOL_NAME, DATABASE_TOOL_NAME, GUIDES_TOOL_NAME] as const; -export type ConversationTurn = { - role: 'user' | 'assistant'; - content: string; -}; +// Pre-compiled regex for citation token substitution (performance optimization) +const CITATION_TOKEN_REGEX = /\{\{CITE_(\d+)\}\}/g; + +export type { ConversationTurn }; export type RunAgentOptions = { prompt: string; @@ -40,35 +41,9 @@ export type ProcessAgentStreamOptions = { export type ProcessAgentStreamResult = RunAgentResult; -const MAX_HISTORY_TURNS = 50; - -function sanitiseHistory(history: ConversationTurn[] | undefined): ConversationTurn[] { - if (!Array.isArray(history) || history.length === 0) { - return []; - } - - const turns: ConversationTurn[] = []; - for (const entry of history) { - if (!entry || typeof entry !== 'object') { - continue; - } - const role = entry.role; - const content = typeof entry.content === 'string' ? entry.content.trim() : ''; - if ((role === 'user' || role === 'assistant') && content) { - turns.push({ role, content }); - } - } - - if (turns.length <= MAX_HISTORY_TURNS) { - return turns; - } - - return turns.slice(turns.length - MAX_HISTORY_TURNS); -} - function buildPromptWithHistory(history: ConversationTurn[] | undefined, nextPrompt: string): string { const trimmedPrompt = nextPrompt.trim(); - const validHistory = sanitiseHistory(history); + const validHistory = sanitizeHistory(history); if (validHistory.length === 0) { return trimmedPrompt; } @@ -85,7 +60,9 @@ function buildPromptWithHistory(history: ConversationTurn[] | undefined, nextPro function substituteCitationTokens(text: string, cache: SearchCache = getActiveSearchCache()): string { // Replace {{CITE_N}} tokens with actual catalog links from cache - return text.replace(/\{\{CITE_(\d+)\}\}/g, (match, indexStr) => { + // Note: We must reset lastIndex for global regex when using in a function + CITATION_TOKEN_REGEX.lastIndex = 0; + return text.replace(CITATION_TOKEN_REGEX, (match, indexStr) => { const index = parseInt(indexStr, 10); if (isNaN(index)) { return match; // Keep original if not a valid number @@ -145,6 +122,18 @@ function createAgentQuery(prompt: string, history: ConversationTurn[] | undefine return { trimmedPrompt, responseStream: responseStream as AsyncIterable }; } +/** + * Processes an agent query with streaming support and conversation history. + * This is the main entry point for server-side SSE streaming. + * + * @param options - Configuration options + * @param options.prompt - The user's prompt/question + * @param options.history - Optional conversation history + * @param options.metadata - Optional metadata to include in logs + * @param options.onMessage - Callback for each SDK message (for SSE streaming) + * @param options.abortController - Optional abort controller for cancellation + * @returns Promise resolving to the final response and streaming status + */ export async function processAgentStream({ prompt, history, @@ -152,7 +141,7 @@ export async function processAgentStream({ onMessage, abortController }: ProcessAgentStreamOptions): Promise { - const normalisedHistory = sanitiseHistory(history); + const normalisedHistory = sanitizeHistory(history); const cache = new SearchCache(); let trimmedPrompt = ''; @@ -315,9 +304,20 @@ function createCitationAwareEmitter(emit: (chunk: string) => void): StreamEmitte }; } +/** + * Runs the agent with optional text streaming via callback. + * This is the main entry point for CLI-style execution. + * + * @param options - Configuration options + * @param options.prompt - The user's prompt/question + * @param options.onTextChunk - Optional callback for streaming text chunks + * @param options.metadata - Optional metadata to include in logs + * @param options.history - Optional conversation history + * @returns Promise resolving to the final response and streaming status + */ export async function runAgent({ prompt, onTextChunk, metadata, history }: RunAgentOptions): Promise { let emittedText = false; - const normalisedHistory = sanitiseHistory(history); + const normalisedHistory = sanitizeHistory(history); const streamEmitter = onTextChunk ? createCitationAwareEmitter((text) => {