Added episodic layer with FTS5 triggers and search

This commit is contained in:
Storme-bit
2026-04-04 06:53:36 -07:00
parent 5d51aa9895
commit 34075258c0
4 changed files with 252 additions and 1 deletions

View File

@@ -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;

View File

@@ -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;

View 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
};

View File

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