Files
nexusAI/packages/memory-service/src/index.js
2026-04-27 21:41:32 -07:00

376 lines
13 KiB
JavaScript

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}`);
});