Added episodic layer with FTS5 triggers and search
This commit is contained in:
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
|
||||
};
|
||||
Reference in New Issue
Block a user