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.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
db.exec(schema);
|
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}`);
|
console.log(`Connected to SQLite database at ${path}`);
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -58,6 +58,26 @@ const schema = `
|
|||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS episodes_fts
|
CREATE VIRTUAL TABLE IF NOT EXISTS episodes_fts
|
||||||
USING fts5(user_message, ai_response, content=episodes, content_rowid=id);
|
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;
|
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 express = require('express');
|
||||||
const {getEnv} = require('@nexusai/shared');
|
const {getEnv} = require('@nexusai/shared');
|
||||||
const { getDB } = require('./db');
|
const { getDB } = require('./db');
|
||||||
|
const episodic = require('./episodic');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -16,7 +17,81 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ service: 'Memory Service', status: 'healthy' });
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Memory Service is running on port ${PORT}`);
|
console.log(`Memory Service is running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user