diff --git a/packages/memory-service/src/db/index.js b/packages/memory-service/src/db/index.js index 04f7a53..da63c86 100644 --- a/packages/memory-service/src/db/index.js +++ b/packages/memory-service/src/db/index.js @@ -13,6 +13,11 @@ function getDB() { db.pragma('foreign_keys = ON'); db.exec(schema); + + // Sync FTS index with any existing episodes data + db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response) + SELECT id, user_message, ai_response FROM episodes`); + console.log(`Connected to SQLite database at ${path}`); } return db; diff --git a/packages/memory-service/src/db/schema.js b/packages/memory-service/src/db/schema.js index bd8e526..3d052e1 100644 --- a/packages/memory-service/src/db/schema.js +++ b/packages/memory-service/src/db/schema.js @@ -58,6 +58,26 @@ const schema = ` CREATE VIRTUAL TABLE IF NOT EXISTS episodes_fts USING fts5(user_message, ai_response, content=episodes, content_rowid=id); + + CREATE TRIGGER IF NOT EXISTS episodes_fts_insert + AFTER INSERT ON episodes BEGIN + INSERT INTO episodes_fts(rowid, user_message, ai_response) + VALUES (new.id, new.user_message, new.ai_response); + END; + + CREATE TRIGGER IF NOT EXISTS episodes_fts_delete + AFTER DELETE ON episodes BEGIN + INSERT INTO episodes_fts(episodes_fts, rowid, user_message, ai_response) + VALUES ('delete', old.id, old.user_message, old.ai_response); + END; + + CREATE TRIGGER IF NOT EXISTS episodes_fts_update + AFTER UPDATE ON episodes BEGIN + INSERT INTO episodes_fts(episodes_fts, rowid, user_message, ai_response) + VALUES ('delete', old.id, old.user_message, old.ai_response); + INSERT INTO episodes_fts(rowid, user_message, ai_response) + VALUES (new.id, new.user_message, new.ai_response); + END; `; module.exports = schema; \ No newline at end of file diff --git a/packages/memory-service/src/episodic/index.js b/packages/memory-service/src/episodic/index.js new file mode 100644 index 0000000..0bf3a5c --- /dev/null +++ b/packages/memory-service/src/episodic/index.js @@ -0,0 +1,151 @@ +const {getDB} = require('../db'); + +// --Sessions -------------------------------------------------- + +// Creates a new session with the given external ID and optional metadata +function createSession(externalId, metadata = null) { + const db = getDB(); + const stmt = db.prepare(` + INSERT INTO sessions (external_id, metadata) + VALUES (?, ?) + `); + + const result = stmt.run(externalId, metadata ? JSON.stringify(metadata) : null); + return getSession(result.lastInsertRowid); +} + +// Retrieves a session by its external ID +function getSession(id) { + const db = getDB(); + const stmt = db.prepare(`SELECT * FROM sessions WHERE id = ?`); + return parseSession(stmt.get(id)); +} + +// Retrieves a session by its external ID +function getSessionByExternalId(externalId) { + const db = getDB(); + const stmt = db.prepare(`SELECT * FROM sessions WHERE external_id = ?`); + return parseSession(stmt.get(externalId)); +} + +// Updates the updated_at timestamp of a session to the current time +function touchSession(id) { + const db = getDB(); + db.prepare(`UPDATE sessions SET updated_at = unixepoch() WHERE id = ?`).run(id); +} + +// Deletes a session and all associated episodes, entities, relationships, and summaries +function deleteSession(id) { + const db = getDB(); + db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id); +} + +// --Episodes -------------------------------------------------- +// Creates a new episode linked to a session, with user message, AI response, optional token count, and metadata +function createEpisode(sessionId, userMessage, aiResponse, tokenCount = null, metadata = null) { + const db = getDB(); + + // Wrap insert + session touch in a transaction — both succeed or neither does + const insert = db.transaction(() => { + const stmt = db.prepare(` + INSERT INTO episodes (session_id, user_message, ai_response, token_count, metadata) + VALUES (?, ?, ?, ?, ?) + `); + const result = stmt.run( + sessionId, + userMessage, + aiResponse, + tokenCount, + metadata ? JSON.stringify(metadata) : null + ); + touchSession(sessionId); + return getEpisode(result.lastInsertRowid); + }); + + return insert(); +} + +// Retrieves an episode by its ID +function getEpisode(id) { + const db = getDB(); + const stmt = db.prepare(`SELECT * FROM episodes WHERE id = ?`); + return parseEpisode(stmt.get(id)); +} + +// Retrieves episodes for a given session, ordered by creation time descending, with pagination +function getEpisodesBySession(sessionId, limit = 20, offset = 0) { + const db = getDB(); + const stmt = db.prepare(` + SELECT * FROM episodes + WHERE session_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `); + return stmt.all(sessionId, limit, offset).map(parseEpisode); +} + +// Retrieves recent episodes across all sessions, ordered by creation time descending, with a limit +function getRecentEpisodes(limit = 10) { + // Cross-session recent episodes — useful for recency-based retrieval + const db = getDB(); + const stmt = db.prepare(` + SELECT * FROM episodes + ORDER BY created_at DESC + LIMIT ? + `); + return stmt.all(limit).map(parseEpisode); +} + + +// Searches episodes using FTS5 full-text search, ordered by relevance, with a limit +function searchEpisodes(query, limit = 10) { + // FTS5 full-text search across all episodes + const db = getDB(); + const stmt = db.prepare(` + SELECT e.* FROM episodes e + JOIN episodes_fts fts ON e.id = fts.rowid + WHERE episodes_fts MATCH ? + ORDER BY rank + LIMIT ? + `); + return stmt.all(query, limit).map(parseEpisode); +} + +// Deletes an episode by its ID +function deleteEpisode(id) { + const db = getDB(); + db.prepare(`DELETE FROM episodes WHERE id = ?`).run(id); +} + +// ─── Parsers ────────────────────────────────────────────────────────────────── + +// Parse JSON metadata back out on the way up — stored as string, returned as object +function parseSession(row) { + if (!row) return null; + return { + ...row, + metadata: row.metadata ? JSON.parse(row.metadata) : null + }; +} + +// Parse JSON metadata back out on the way up — stored as string, returned as object +function parseEpisode(row) { + if (!row) return null; + return { + ...row, + metadata: row.metadata ? JSON.parse(row.metadata) : null + }; +} + +module.exports = { + createSession, + getSession, + getSessionByExternalId, + deleteSession, + createEpisode, + getEpisode, + getEpisodesBySession, + getRecentEpisodes, + searchEpisodes, + deleteEpisode +}; \ No newline at end of file diff --git a/packages/memory-service/src/index.js b/packages/memory-service/src/index.js index aec0faf..6d2ae42 100644 --- a/packages/memory-service/src/index.js +++ b/packages/memory-service/src/index.js @@ -2,6 +2,7 @@ require ('dotenv').config(); const express = require('express'); const {getEnv} = require('@nexusai/shared'); const { getDB } = require('./db'); +const episodic = require('./episodic'); const app = express(); app.use(express.json()); @@ -16,7 +17,81 @@ app.get('/health', (req, res) => { res.json({ service: 'Memory Service', status: 'healthy' }); }); -// Start the server +/************************************ */ +/********** Session Routes ********** */ +/************************************ */ + +// Creates a new session with an external ID and optional metadata +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 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); +}); + +// 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); +}); + + +// Updates the session's updated_at timestamp to now +app.delete('/sessions/:id', (req, res) => { + episodic.deleteSession(req.params.id); + res.status(204).send(); +}); + + +/************************************* */ +/********** Episodic Routes ********** */ +/************************************* */ + +app.post('/episodes', (req, res) => { + const { sessionId, userMessage, aiResponse, tokenCount, metadata } = req.body; + if (!sessionId || !userMessage || !aiResponse) { + return res.status(400).json({ error: 'sessionId, userMessage and aiResponse are required' }); + } + const episode = episodic.createEpisode(sessionId, userMessage, aiResponse, tokenCount, metadata); + res.status(201).json(episode); +}); + +// Search MUST come before /:id — otherwise 'search' gets captured as an id +app.get('/episodes/search', (req, res) => { + const { q, limit = 10 } = req.query; + if (!q) return res.status(400).json({ error: 'q (query) parameter is required' }); + const results = episodic.searchEpisodes(q, Number(limit)); + res.json(results); +}); + +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); +}); + +app.delete('/episodes/:id', (req, res) => { + episodic.deleteEpisode(req.params.id); + res.status(204).send(); +}); + +/********************************** */ +/********** Start Server ********** */ +/********************************** */ app.listen(PORT, () => { console.log(`Memory Service is running on port ${PORT}`); }); \ No newline at end of file