Added episodic layer with FTS5 triggers and search
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
151
packages/memory-service/src/episodic/index.js
Normal file
151
packages/memory-service/src/episodic/index.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user