Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
32 changes: 32 additions & 0 deletions primo/.env.example
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions src/lib/conversationHistory.ts
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 16 additions & 0 deletions src/primo/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrimoSearchResult> {
const config = getPrimoConfig();
const params = new URLSearchParams();
Expand Down
140 changes: 100 additions & 40 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string, unknown>) {
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<string, unknown>) {
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();

Expand Down Expand Up @@ -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() : '';

Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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;
Loading