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

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