require ('dotenv').config(); const express = require('express'); const {getEnv, PORTS, EPISODIC, logger} = require('@nexusai/shared'); const { getDB } = require('./db'); const { createProject, getProjects, getProject, updateProject, deleteProject } = require('./db/projects'); const { createSummary, getSummary, getSummariesBySession, getSummariesByProject, updateSummary, deleteSummary } = require('./db/summaries'); const { generateAndStoreProjectSummary } = require('./summarization/project'); const graph = require('./graph'); const episodic = require('./episodic'); const semantic = require('./semantic'); const entities = require('./entities'); const app = express(); app.use(express.json({ limit: '2mb' })); const PORT = getEnv('PORT', PORTS.MEMORY); //initialize database on startup const db = getDB(); semantic.initCollections() .then(() => logger.info(`QDrant collections ready`)) .catch(err => logger.error(`QDrant initialization error:`, err.message)); // Health check endpoint app.get('/health', (req, res) => { res.json({ service: 'Memory Service', status: 'healthy' }); }); /************************************ */ /********** Session Routes ********** */ /************************************ */ // Creates a new session with an external ID and optional metadata app.get('/sessions', (req, res) => { const { limit = EPISODIC.DEFAULT_PAGE_SIZE, offset = EPISODIC.DEFAULT_OFFSET, projectId } = req.query; const parsedProjectId = projectId && projectId !== 'null' ? Number(projectId) : null; const sessions = episodic.getSessions(Number(limit), Number(offset), parsedProjectId); res.json(sessions); }); app.post('/sessions', (req, res) => { const { externalId, metadata } = req.body; if (!externalId) { return res.status(400).json({ error: 'externalId is required' }); } try { const session = episodic.createSession(externalId, metadata); res.status(201).json(session); } catch (err) { res.status(409).json({ error: 'Session already exists', detail: err.message }); } }); // Retrieves a session by its external ID app.get('/sessions/by-external/:externalId', (req, res) => { const session = episodic.getSessionByExternalId(req.params.externalId); if (!session) return res.status(404).json({ error: 'Session not found' }); res.json(session); }); // Retrieves a session by its internal ID app.get('/sessions/:id', (req, res) => { const session = episodic.getSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); res.json(session); }); app.patch('/sessions/by-external/:externalId', (req, res) => { const { name, projectId } = req.body; try { const session = episodic.updateSessionByExternalId(req.params.externalId, {name, projectId }); res.json(session); } catch (err) { res.status(500).json({ error: 'Failed to update session', detail: err.message }); } }); // Deletes a session and all associated episodes app.delete('/sessions/by-external/:externalId', (req, res) => { episodic.deleteSessionByExternalId(req.params.externalId); res.status(204).send(); }); /************************************* */ /********** Episodic Routes ********** */ /************************************* */ app.post('/episodes', async (req, res) => { const { sessionId, userMessage, aiResponse, tokenCount, projectId } = req.body; if (!sessionId || !userMessage || !aiResponse) { return res.status(400).json({ error: 'sessionId, userMessage and aiResponse are required' }); } const episode = await episodic.createEpisode(sessionId, userMessage, aiResponse, tokenCount, projectId); res.status(201).json(episode); }); app.get('/episodes', (req, res) => { const { limit = 50, offset = 0, sessionId, q } = req.query; if (q) { const results = episodic.searchEpisodes(q, Number(limit)); return res.json({ episodes: results, total: results.length }); } const db = getDB(); let episodes; if (sessionId) { episodes = episodic.getEpisodesBySession(Number(sessionId), Number(limit), Number(offset)); } else { episodes = db.prepare( `SELECT * FROM episodes ORDER BY created_at DESC LIMIT ? OFFSET ?` ).all(Number(limit), Number(offset)).map(row => require('@nexusai/shared').parseRow(row)); } const total = db.prepare(`SELECT COUNT(*) as count FROM episodes`).get().count; res.json({ episodes, total }); }); // Search MUST come before /:id — otherwise 'search' gets captured as an id app.get('/episodes/search', (req, res) => { const { q, limit = EPISODIC.DEFAULT_PAGE_SIZE, sessionIds } = req.query; if (!q) return res.status(400).json({ error: 'q (query) parameter is required' }); const parsedSessionIds = sessionIds ? sessionIds.split(',').map(Number).filter(Boolean) : null; res.json(episodic.searchEpisodes(q, Number(limit), parsedSessionIds)); }); app.get('/episodes/:id', (req, res) => { const episode = episodic.getEpisode(req.params.id); if (!episode) return res.status(404).json({ error: 'Episode not found' }); res.json(episode); }); // Get paginated episodes for a session app.get('/sessions/:id/episodes', (req, res) => { const { limit = EPISODIC.DEFAULT_PAGE_SIZE, offset = EPISODIC.DEFAULT_OFFSET } = req.query; const episodes = episodic.getEpisodesBySession( req.params.id, Number(limit), Number(offset) ); res.json(episodes); }); app.delete('/episodes/:id', (req, res) => { const id = Number(req.params.id); episodic.deleteEpisode(id); semantic.deleteEpisode(id) // fire-and-forget .catch(err => logger.error(`[Memory] Qdrant delete failed for episode ${id}:`, err.message)); res.status(204).send(); }); /*********************************** */ /********** Entity Routes ********** */ /*********************************** */ //Upsert an entity, creates or updates if already exists app.post('/entities', (req, res) => { const {name, type, notes, metadata} = req.body; if (!name || !type) { return res.status(400).json({ error: 'name and type are required' }); } const entity = entities.upsertEntity(name, type, notes, metadata); res.status(201).json(entity); }); // Get all entities of a given type app.get('/entities/by-type/:type', (req, res) => { res.json(entities.getEntitiesByType(req.params.type)); }); // Get an entity by ID app.get('/entities/:id', (req, res) => { const entity = entities.getEntity(req.params.id); if (!entity) return res.status(404).json({ error: 'Entity not found' }); res.json(entity); }); // Delete an entity by ID app.delete('/entities/:id', (req, res) => { entities.deleteEntity(req.params.id); res.status(204).send(); }); /***************************************** */ /********** Relationship Routes ********** */ /***************************************** */ // Upsert a relationship between two entities app.post('/relationships', (req, res) => { const { fromId, toId, label, notes, metadata } = req.body; if (!fromId || !toId || !label) { return res.status(400).json({ error: 'fromId, toId and label are required' }); } const relationship = entities.upsertRelationship(fromId, toId, label, notes, metadata); res.status(201).json(relationship); }); // Get all relationships for a given entity ID app.get('/entities/:id/relationships', (req, res) => { res.json(entities.getOutboundRelationships(req.params.id)); }); // Delete a specific relationship app.delete('/relationships', (req, res) => { const {fromId, toId, label} = req.body; if (!fromId || !toId || !label) { return res.status(400).json({ error: 'fromId, toId and label are required' }); } entities.deleteRelationship(fromId, toId, label); res.status(204).send(); }) /********************************* */ /********** Graph Routes ********** */ /********************************* */ // Single-entity neighborhood — depth defaults to ENTITIES.GRAPH_HOP_DEPTH app.get('/graph/neighborhood/:entityId', (req, res) => { const entity = entities.getEntity(req.params.entityId); if (!entity) return res.status(404).json({ error: 'Entity not found' }); const depth = req.query.depth ? Math.min(Number(req.query.depth), 3) : undefined; const neighborhood = graph.getNeighborhood(Number(req.params.entityId), depth); res.json({ entity, neighborhood }); }); // Bulk 1-hop neighborhood — body: { entityIds: [...] } app.post('/graph/neighbors', (req, res) => { const { entityIds } = req.body; if (!Array.isArray(entityIds) || entityIds.length === 0) { return res.status(400).json({ error: 'entityIds array is required' }); } res.json(graph.getEntityNeighbors(entityIds.map(Number))); }); app.post('/episodes/by-entities', (req, res) => { const { entityIds } = req.body; if (!Array.isArray(entityIds) || entityIds.length === 0) { return res.status(400).json({ error: 'entityIds array is required' }); } res.json({ episodeIds: graph.getEpisodeIdsByEntities(entityIds.map(Number)) }); }); /*********************************** */ /********** Project Routes ********** */ /*********************************** */ app.post('/projects', (req, res) => { const { name, description, colour, icon } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'name is required' }); try { res.status(201).json(createProject({ name: name.trim(), description, colour, icon })); } catch (err) { res.status(500).json({ error: 'Failed to create project', detail: err.message }); } }); app.get('/projects', (req, res) => { res.json(getProjects()); }); // Generate (or regenerate) a project overview summary on demand app.post('/projects/:id/summarize', async (req, res) => { const project = getProject(Number(req.params.id)); if (!project) return res.status(404).json({ error: 'Project not found' }); try { const summary = await generateAndStoreProjectSummary(Number(req.params.id)); res.status(201).json(summary); } catch (err) { if (err.message.includes('No session summaries or episodes')) { return res.status(422).json({ error: err.message }); } res.status(500).json({ error: 'Failed to generate project summary', detail: err.message }); } }); // Get the current project overview summary app.get('/projects/:id/overview', async (req, res) => { const { getProjectOverviewSummary } = require('./db/summaries'); const summary = getProjectOverviewSummary(Number(req.params.id)); // 200 with null is fine — frontend can handle "no overview yet" gracefully res.json(summary ?? null); }); // Get summaries for a project app.get('/projects/:id/summaries', (req, res) => { res.json(getSummariesByProject(req.params.id)); }); app.get('/projects/:id', (req, res) => { const project = getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Not found' }); res.json(project); }); app.patch('/projects/:id', (req, res) => { const project = getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Not found' }); res.json(updateProject(req.params.id, req.body)); }); app.delete('/projects/:id', (req, res) => { const project = getProject(req.params.id); if (!project) return res.status(404).json({ error: 'Not found' }); deleteProject(req.params.id); res.status(204).send(); }); /*********************************** */ /********** Summary Routes ********** */ /*********************************** */ // Create a summary (called by orchestration, fire-and-forget style) app.post('/summaries', (req, res) => { const { sessionId, projectId, content, tokenCount, episodeRange, metadata } = req.body; if (!content) return res.status(400).json({ error: 'content is required' }); if (!sessionId && !projectId) return res.status(400).json({ error: 'sessionId or projectId is required' }); try { const summary = createSummary({ sessionId, projectId, content, tokenCount, episodeRange, metadata }); res.status(201).json(summary); } catch (err) { res.status(500).json({ error: 'Failed to create summary', detail: err.message }); } }); // Get summaries for a session app.get('/sessions/:id/summaries', (req, res) => { res.json(getSummariesBySession(req.params.id)); }); // Update a summary (for cumulative updates) app.patch('/summaries/:id', (req, res) => { const summary = getSummary(req.params.id); if (!summary) return res.status(404).json({ error: 'Not found' }); res.json(updateSummary(req.params.id, req.body)); }); // Delete a summary app.delete('/summaries/:id', (req, res) => { deleteSummary(req.params.id); res.status(204).send(); }); /********************************** */ /********** Start Server ********** */ /********************************** */ app.listen(PORT, () => { logger.info(`Memory Service is running on port ${PORT}`); });