#include "sqlite/backend_impl.h"
#include "embedder.h"
#include "config.h"
#include "lang.h"
#include "util/fs.h"
#include "util/time.h"
#include "util/sqlite.h"
#include "double_metaphone.h"
#include "fts_tokenize.h"
#include "sqlite/text_index.h"
#include "vector_codec.h"
#include "Logger.h"
#include
#include "nlohmann_json.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
namespace ragger::sqlite {
using json = nlohmann::json;
namespace fs = std::filesystem;
// Build the tags string from a metadata JSON object. Accepts metadata["tags"]
// as a comma-separated string or a JSON array. `keep`/`bad` flag fields are
// folded in (they protect a row from deletion / mark it as low-quality).
static std::string tags_from_metadata(const json& metadata) {
std::string tags_str;
if (metadata.contains("tags")) {
const auto& tv = metadata["tags"];
if (tv.is_array()) {
for (size_t i = 0; i < tv.size(); ++i) {
if (i > 0) tags_str += ",";
tags_str += tv[i].get<:string>();
}
} else if (tv.is_string()) {
tags_str = tv.get<:string>();
}
}
if (metadata.value("keep", false) &&
tags_str.find("keep") == std::string::npos)
tags_str += (tags_str.empty() ? "" : ",") + std::string("keep");
if (metadata.value("bad", false) &&
tags_str.find("bad") == std::string::npos)
tags_str += (tags_str.empty() ? "" : ",") + std::string("bad");
return tags_str;
}
// ---- path normalization -------------------------------------------
std::string Backend::Impl::normalize_path(const std::string& text) {
if (!config().normalize_home_path) return text;
std::string home = home_dir();
if (home.empty()) return text;
std::string prefix = home + "/";
std::string out = text;
size_t pos = 0;
while ((pos = out.find(prefix, pos)) != std::string::npos) {
out.replace(pos, prefix.size(), "~/");
pos += 2;
}
return out;
}
// Strip a leading "Decision #NNN", "Design Decision #NNN", or
// "Decision Log #NNN" label (optionally with a "(context)" aside
// and/or the word "Logged") from decision text, e.g.
// "Decision #122 (Vienna, June 2026): Ragger adds ..." ->
// "Ragger adds ...". The row's own decision_id + created_at already
// carry this information â a hand/agent-authored numeric label baked
// into the text is pure duplication that dilutes embedding/FTS search
// (and drifts out of sync with the real decision_id over time).
// Markdown emphasis markers ("**"/"*") wrapping the label are also
// consumed. No-op if the text doesn't start with this pattern.
std::string Backend::Impl::strip_decision_number_prefix(const std::string& text) {
static const std::regex prefix_re(
R"(^\s*\*{0,2}(?:Design\s+)?Decision(?:\s+Log)?\s*#\d+\s*)"
R"((?:Logged)?\s*(?:\([^)]*\))?\s*\*{0,2}\s*:\s*\*{0,2}\s*)",
std::regex::icase);
std::smatch m;
if (!std::regex_search(text, m, prefix_re) || m.position(0) != 0)
return text;
std::string out = text.substr(m.length(0));
// If the consumed prefix opened with "**"/"*" (bold/italic wrapping
// the whole label), the matching close marker sits at the very end
// of the string, past what the prefix regex could reach â trim it
// too so we don't leave a dangling "**"/"*" behind.
std::string opened = m.str(0);
size_t nstars = 0;
for (char c : opened) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') continue;
if (c == '*') { ++nstars; continue; }
break;
}
if (nstars > 0) {
while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' '))
out.pop_back();
size_t trailing = 0;
for (size_t i = out.size(); i > 0 && out[i - 1] == '*'; --i) ++trailing;
if (trailing >= nstars) out.erase(out.size() - nstars);
}
return out;
}
// ---- local timestamp ("%F %T" == "YYYY-MM-DD HH:MM:SS") ----------
// Thin aliases over the shared formatter (ragger/util/time.h) so every
// DB timestamp uses one local-time format.
std::string Backend::Impl::local_timestamp(std::time_t tt) { return db_timestamp(tt); }
std::string Backend::Impl::local_timestamp() { return db_timestamp(); }
// ---- public API ---------------------------------------------------
// v2: the generic store API writes a summary (L2/L3/L4). A memory-only
// install records agent memories here. `level`/`status` come from
// metadata when supplied; defaults suit a settled session-level note.
// collection/category and the free-form metadata blob are gone in v2 â
// metadata fields are either promoted to columns or dropped. FTS5 sync
// triggers index the row, so there is no explicit BM25 step.
std::string Backend::Impl::store(const std::string& raw_text, json metadata, bool defer_embedding) {
if (metadata.is_null()) metadata = json::object();
std::string level = metadata.value("level", std::string("session"));
// Optional historical timestamp override (imports of past
// conversations). Must be an ISO-8601 UTC string; else falls to now.
std::string ts_override;
if (metadata.contains("timestamp") && metadata["timestamp"].is_string()) {
ts_override = metadata["timestamp"].get<:string>();
}
// tags: accept a JSON array or a plain string. `keep`/`bad` are
// flag-tags folded into the tags column â a row tagged "keep" is
// protected from delete/update (see delete_memory / update_text).
std::string tags_str = tags_from_metadata(metadata);
std::string text = normalize_path(raw_text);
std::vector emb;
if (!defer_embedding) {
emb = embedder->encode(text);
}
auto ts = resolve_epoch(ts_override);
// Every summary should record the model that produced it. Callers pass
// the live model via metadata["model"]; empty â 0 â NULL (the raw-turn
// sentinel), which a non-turn summary should never be. get_or_create_model
// interns the name in the models table.
std::string model_name = metadata.value("model", std::string(""));
int model_id = get_or_create_model(model_name);
// Custom text index (v0.16): index_record() below tokenizes text and
// maintains terms/*_terms/count columns â no FTS5 triggers involved.
Stmt s(db,
"INSERT INTO summaries (text, embedding_version, embedding, level, tags, created_at, model_id, updated_at) "
"VALUES (?,?,?,?,?,?,?,?)");
s.bind(1, text);
if (defer_embedding) {
s.bind_null(2);
s.bind_null(3);
} else {
bind_embedding_version(s.raw(), 2, emb);
bind_embedding(s.raw(), 3, emb);
}
s.bind(4, level).bind(5, tags_str).bind(6, ts);
if (model_id) s.bind(7, model_id); else s.bind_null(7);
s.bind(8, ts); // updated_at == created_at on insert
if (!s.exec()) {
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
}
int summary_id = static_cast(sqlite3_last_insert_rowid(db));
invalidate_cache();
text_index_->index_record("summaries", summary_id, text);
return std::to_string(summary_id);
}
// ---- store_document: write Level 5 RAG chunk ----------------------
// Resolve a (path, title) document to its document_sources row, inserting
// one on first sighting. Returns {document_source_id, imported_at_epoch}.
// imported_at_text is the caller's timestamp (TEXT 'YYYY-MM-DD HH:MM:SS'
// or 'YYYYMMDD', interpreted LOCAL); empty -> now. On an existing source
// the stored epoch is returned (the first import's time wins), so every
// chunk of a document shares one imported_at.
std::pair Backend::Impl::get_or_create_document_source(
const std::string& path, const std::string& title,
int year, const std::string& tags,
const std::string& imported_at_text) {
{
Stmt s(db,
"SELECT document_source_id, imported_at FROM document_sources "
"WHERE ifnull(path,'') = ifnull(?,'') "
" AND ifnull(title,'') = ifnull(?,'')");
if (path.empty()) s.bind_null(1); else s.bind(1, path);
if (title.empty()) s.bind_null(2); else s.bind(2, title);
if (s.step())
return { s.column_int64(0), s.column_int64(1) };
}
// Convert the caller's local-wall-clock text to a unix epoch via SQLite
// (matches the migration's TEXT->epoch reading); fall back to now.
long long epoch = static_cast(std::time(nullptr));
if (!imported_at_text.empty()) {
Stmt c(db, "SELECT CAST(strftime('%s', ?, 'utc') AS INTEGER)");
c.bind(1, imported_at_text);
if (c.step() && !c.is_null(0)) epoch = c.column_int64(0);
}
Stmt ins(db,
"INSERT INTO document_sources(title, path, year, tags, imported_at) "
"VALUES (?,?,?,?,?)");
if (title.empty()) ins.bind_null(1); else ins.bind(1, title);
if (path.empty()) ins.bind_null(2); else ins.bind(2, path);
if (year <= 0) ins.bind_null(3); else ins.bind(3, year);
ins.bind(4, tags);
ins.bind(5, epoch);
if (!ins.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
return { static_cast(sqlite3_last_insert_rowid(db)), epoch };
}
int Backend::Impl::store_document(const DocumentChunk& chunk, bool defer_embedding) {
// Path-normalise the body text for consistency with store().
std::string text = normalize_path(chunk.text);
// Title rides the vector + phon signals (not just FTS): the embedder
// and phonizer see `text + '\n' + title`. Title goes LAST so the chunk
// body leads each signal (chunks of one doc don't share a phon prefix)
// and the primary content dominates the embedding.
std::string signal_text = chunk.title.empty()
? text
: text + "\n" + chunk.title;
std::vector emb;
if (!defer_embedding) {
emb = embedder->encode(signal_text);
}
// Resolve/insert the source; every chunk of a (path,title) doc shares
// one document_sources row and its imported_at epoch. modified_on
// starts equal to that epoch (until the chunk text is later edited).
auto [source_id, epoch] = get_or_create_document_source(
chunk.path, chunk.title, chunk.year, chunk.tags, chunk.imported_at);
// Slim chunk row: no path/title/year/imported_at (they live on the
// source). documents_fts sync triggers index text/tags only.
Stmt s(db,
"INSERT INTO documents "
"(text, tags, chunk_index, document_source_id, modified_on, "
" embedding_version, embedding) "
"VALUES (?,?,?,?,?,?,?)");
s.bind(1, text);
s.bind(2, chunk.tags);
if (chunk.chunk_index <= 0) s.bind_null(3);
else s.bind(3, chunk.chunk_index);
s.bind(4, source_id);
s.bind(5, epoch);
if (defer_embedding) {
s.bind_null(6);
s.bind_null(7);
} else {
bind_embedding_version(s.raw(), 6, emb);
bind_embedding(s.raw(), 7, emb);
}
if (!s.exec()) {
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
}
invalidate_doc_cache();
int doc_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("documents", doc_id, signal_text);
return doc_id;
}
// ---- turns (L1) raw exchange capture ------------------------------
// Resolve a model name to its models.model_id, creating the row if it
// doesn't exist. Empty name â 0 (callers bind NULL).
int Backend::Impl::get_or_create_model(const std::string& name) {
if (name.empty()) return 0;
Stmt s(db, "SELECT model_id FROM models WHERE name = ?");
s.bind(1, name);
int id = 0;
if (s.step()) id = s.column_int(0);
if (id) return id;
Stmt ins(db, "INSERT INTO models (name) VALUES (?)");
ins.bind(1, name);
ins.step();
return static_cast(sqlite3_last_insert_rowid(db));
}
/// Resolve a session GUID to its compact integer id, inserting on first
/// sighting. Empty guid â 0 (NULL session_id), mirroring get_or_create_model.
/// If session_name is non-empty, sets sessions.name (and name_source) on
/// first sighting or updates it if the title has changed since last capture.
int Backend::Impl::get_or_create_session(const std::string& guid,
const std::string& session_name,
const std::string& name_source) {
if (guid.empty()) return 0;
Stmt s(db, "SELECT session_id FROM sessions WHERE guid = ?");
s.bind(1, guid);
int id = 0;
if (s.step()) id = s.column_int(0);
if (!id) {
Stmt ins(db, "INSERT INTO sessions (guid, name, name_source) VALUES (?, ?, ?)");
ins.bind(1, guid);
if (session_name.empty()) ins.bind_null(2);
else ins.bind(2, session_name);
if (session_name.empty()) ins.bind_null(3);
else ins.bind(3, name_source);
ins.step();
id = static_cast(sqlite3_last_insert_rowid(db));
} else if (!session_name.empty()) {
// Update name if changed (retitled session, or first time a name
// is available for a previously-unnamed session).
Stmt u(db, "UPDATE sessions SET name = ?, name_source = ? "
"WHERE session_id = ? AND (name IS NULL OR name != ?)");
u.bind(1, session_name);
u.bind(2, name_source);
u.bind(3, id);
u.bind(4, session_name);
u.exec();
}
return id;
}
// Embedding for a turn is over the joined exchange (user + assistant),
// using the same U+001F unit-separator as chat's summary turns.
std::string Backend::Impl::turn_embed_text(const std::string& u, const std::string& a) {
return a.empty() ? u : (u + "\n\x1F\n" + a);
}
// Store a raw L1 turn. An empty assistant_text writes a *partial* row
// (assistant + embedding NULL) for the prompt-arrival/finalize flow;
// otherwise the exchange is embedded unless defer_embedding. FTS5 sync
// triggers index user_text/assistant_text. Returns turn_id.
// `source_timestamp` (non-empty, db format) overrides created_at for
// historical imports; the regeneration-dedup window is skipped in that
// case (it reasons about "now", which is meaningless for old turns).
int Backend::Impl::store_turn(const std::string& user_text, const std::string& assistant_text,
const std::string& model_name, bool defer_embedding,
const std::string& session_guid,
const std::string& source_timestamp,
const std::string& session_name,
const std::string& name_source) {
std::string u = normalize_path(user_text);
std::string a = normalize_path(assistant_text);
int model_id = get_or_create_model(model_name);
int session_id = get_or_create_session(session_guid, session_name, name_source);
// Retry/regeneration dedup. When the agent re-answers the *same* user
// prompt without an intervening new prompt, we get a second turn whose
// user_text duplicates the immediately-preceding turn. In a healthy
// agent a regeneration stays in one session; a session change between
// two identical prompts is itself a breakage signal (observed when the
// TUI crash-restarted and minted a fresh session GUID per turn). So the
// match is intentionally cross-session: identical user_text in the most
// recent turn, within a short time window. We keep-latest â update that
// row's assistant_text (+ re-embed) in place rather than inserting a
// duplicate. The FTS au-trigger keeps the index consistent. Empty
// assistant_text (partial/prompt-arrival rows) never dedups.
// Skipped entirely for historical imports (source_timestamp set).
if (!a.empty() && source_timestamp.empty()) {
Stmt p(db,
"SELECT turn_id, user_text, created_at, session_id FROM turns "
"ORDER BY turn_id DESC LIMIT 1");
if (p.step()) {
const int prev_id = p.column_int(0);
const std::string prev_u = p.column_text(1);
const int64_t prev_ts_ep = p.column_int64(2);
// 5-minute window: wide enough for a slow regeneration, narrow
// enough that a genuinely re-typed identical prompt much later
// is still recorded as its own turn.
bool within_window = false;
if (prev_u == u) {
int64_t now_ep = db_epoch();
within_window = (now_ep - prev_ts_ep) <= 300;
}
if (within_window) {
std::vector emb2 = embedder->encode(turn_embed_text(u, a));
// Single timestamp value shared by the turns UPDATE and the
// placeholder cleanup/insert below â computed once, never
// re-read from the clock, so the two writes can't drift
// apart and strand the old (session_id, prev_ts) slot.
const int64_t new_ts = db_epoch();
Stmt up(db,
"UPDATE turns SET assistant_text = ?, embedding_version = ?, embedding = ?, "
"model_id = COALESCE(?, model_id), "
"session_id = COALESCE(?, session_id), created_at = ? "
"WHERE turn_id = ?");
up.bind(1, a);
bind_embedding_version(up.raw(), 2, emb2);
bind_embedding(up.raw(), 3, emb2);
if (model_id) up.bind(4, model_id); else up.bind_null(4);
if (session_id) up.bind(5, session_id); else up.bind_null(5);
up.bind(6, new_ts);
up.bind(7, prev_id);
if (!up.exec())
if (!up.exec()) throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
text_index_->index_record("turns", prev_id, u + "\n" + a);
return prev_id;
}
}
}
std::vector emb;
bool have_emb = false;
if (!defer_embedding && !a.empty()) {
emb = embedder->encode(turn_embed_text(u, a));
have_emb = true;
}
Stmt s(db,
"INSERT INTO turns (model_id, session_id, user_text, assistant_text, embedding_version, embedding, created_at) "
"VALUES (?,?,?,?,?,?,?)");
if (model_id) s.bind(1, model_id); else s.bind_null(1);
if (session_id) s.bind(2, session_id); else s.bind_null(2);
s.bind(3, u);
if (a.empty()) s.bind_null(4);
else s.bind(4, a);
if (have_emb) {
bind_embedding_version(s.raw(), 5, emb);
bind_embedding(s.raw(), 6, emb);
} else {
s.bind_null(5);
s.bind_null(6);
}
s.bind(7, resolve_epoch(source_timestamp));
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
int new_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("turns", new_id, a.empty() ? u : (u + "\n" + a));
return new_id;
}
// Shared implementation for turn queries over a session GUID.
// asc=true â oldest-first (ORDER BY turn_id ASC, no limit).
// asc=false â newest-first (ORDER BY timestamp DESC, turn_id DESC) with optional limit.
std::vector Backend::Impl::turns_by_session_impl(
const std::string& session_guid, bool asc, int limit) {
std::vector out;
if (session_guid.empty()) return out;
std::string sql =
"SELECT t.turn_id, t.user_text, t.assistant_text, m.name, "
" datetime(t.created_at,'unixepoch','localtime') "
"FROM turns t "
"JOIN sessions ss ON t.session_id = ss.session_id "
"LEFT JOIN models m ON t.model_id = m.model_id "
"WHERE ss.guid = ? ";
sql += asc ? "ORDER BY t.turn_id ASC"
: "ORDER BY t.created_at DESC, t.turn_id DESC";
if (limit > 0) sql += " LIMIT ?";
Stmt s(db, sql);
s.bind(1, session_guid);
if (limit > 0) s.bind(2, limit);
while (s.step()) {
out.push_back({
s.column_int(0),
s.column_text(1),
s.column_text(2),
s.column_text(3),
s.column_text(4),
session_guid
});
}
return out;
}
/// All turns belonging to a session GUID, oldest first.
std::vector Backend::Impl::turns_by_session(const std::string& session_guid) {
return turns_by_session_impl(session_guid, true);
}
// Finalize a partial turn: set assistant_text, (re)embed the exchange,
// and record the model. Returns false if the turn doesn't exist.
bool Backend::Impl::finalize_turn(int turn_id, const std::string& assistant_text,
const std::string& model_name) {
Stmt g(db, "SELECT user_text FROM turns WHERE turn_id = ?");
g.bind(1, turn_id);
std::string u;
bool found = false;
if (g.step()) {
found = true;
u = g.column_text(0);
}
if (!found) return false;
std::string a = normalize_path(assistant_text);
int model_id = get_or_create_model(model_name);
auto emb = embedder->encode(turn_embed_text(u, a));
Stmt s(db,
"UPDATE turns SET assistant_text = ?, embedding_version = ?, embedding = ?, "
"model_id = COALESCE(?, model_id) WHERE turn_id = ?");
s.bind(1, a);
bind_embedding_version(s.raw(), 2, emb);
bind_embedding(s.raw(), 3, emb);
if (model_id) s.bind(4, model_id); else s.bind_null(4);
s.bind(5, turn_id);
if (!s.exec()) return false;
text_index_->index_record("turns", turn_id, u + "\n" + a);
return true;
}
// ---- summaries (L2/L3) pipeline primitives (issue #22) ------------
// Insert a summary row. level: 'turn' (L2) | 'session' (L3) | 'project'.
// Embeds text, records model.
// source_timestamp (non-empty) overrides the row's timestamp: L2 turn
// summaries inherit the source turn's timestamp so the (session_id,
// timestamp) pair links a turn to its summary (no FK column needed) and
// stays stable across embedding-model changes.
int Backend::Impl::store_summary(const std::string& text, const std::string& level,
const std::string& model_name,
const std::string& session_guid,
const std::string& source_timestamp,
const std::string& tags) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(model_name);
int session_id = get_or_create_session(session_guid);
auto emb = embedder->encode(t);
Stmt s(db,
"INSERT INTO summaries (model_id, session_id, text, embedding_version, embedding, level, tags, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?)");
if (model_id) s.bind(1, model_id); else s.bind_null(1);
if (session_id) s.bind(2, session_id); else s.bind_null(2);
s.bind(3, t);
bind_embedding_version(s.raw(), 4, emb);
bind_embedding(s.raw(), 5, emb);
s.bind(6, level).bind(7, tags);
{
const int64_t ts = resolve_epoch(source_timestamp);
s.bind(8, ts);
s.bind(9, ts); // updated_at == created_at on insert
}
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
invalidate_cache();
int sid = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("summaries", sid, t);
return sid;
}
// ---- store_decision: write Level 6 curated decision/lesson ----------
// Mirrors store_summary but the decisions table has no level/model/session
// columns â just text/embedding/status/tags/timestamp. defer_embedding
// leaves embedding NULL for a later backfill pass.
int Backend::Impl::store_decision(const std::string& text, const std::string& status,
const std::string& tags,
const std::string& source_timestamp,
bool defer_embedding) {
std::string t = normalize_path(strip_decision_number_prefix(text));
Stmt s(db,
"INSERT INTO decisions (text, embedding_version, embedding, status, tags, created_at) "
"VALUES (?,?,?,?,?,?)");
s.bind(1, t);
if (defer_embedding) {
s.bind_null(2);
s.bind_null(3);
} else {
auto emb = embedder->encode(t);
bind_embedding_version(s.raw(), 2, emb);
bind_embedding(s.raw(), 3, emb);
}
s.bind(4, status.empty() ? "current" : status);
s.bind(5, tags);
s.bind(6, resolve_epoch(source_timestamp));
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
invalidate_dec_cache();
int did = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("decisions", did, t);
return did;
}
// ---- catch-up / recipe helpers ------------------------------------
// Turns lacking an L2 summary, linked by (session_id, timestamp).
// LEFT JOIN keeps turns whose summary row doesn't exist; ordered
// newest-first so the summarizer works on the most recently active
// turns first. This matters most for a manual resummarize (nulling
// model_id on a batch of `summaries` rows via direct SQL): the turns
// the user actually cares about right now are usually the recent
// ones, and with catch_up_batch_size capping each tick, newest-first
// means those show up with real summaries soonest instead of waiting
// behind a long tail of old backlog.
std::vector Backend::Impl::unsummarized_turns(int limit) {
std::vector out;
std::string sql =
"SELECT t.turn_id, t.user_text, t.assistant_text, m.name, "
" datetime(t.created_at,'unixepoch','localtime'), COALESCE(ss.guid, '') "
"FROM turns t "
"LEFT JOIN turn_summaries ts ON ts.turn_id = t.turn_id "
"LEFT JOIN models m ON t.model_id = m.model_id "
"LEFT JOIN sessions ss ON t.session_id = ss.session_id "
"WHERE ts.turn_id IS NULL "
" AND t.assistant_text IS NOT NULL "
"ORDER BY t.created_at DESC, t.turn_id DESC";
if (limit > 0) sql += " LIMIT ?";
Stmt s(db, sql);
if (limit > 0) s.bind(1, limit);
while (s.step()) {
out.push_back({
s.column_int(0),
s.column_text(1),
s.column_text(2),
s.column_text(3),
s.column_text(4),
s.column_text(5)
});
}
return out;
}
// ---- turn_id-based turn_summaries helpers (v0.12.0) ----------------
bool Backend::Impl::turn_summary_exists(int turn_id) {
Stmt s(db, "SELECT 1 FROM turn_summaries WHERE turn_id = ? LIMIT 1");
s.bind(1, turn_id);
return s.step();
}
// Housekeeping retry for poison-abandoned turns: mark_turn_summarized()
// permanently stamps a turn_summaries row with model "bad" and text=''
// when a turn hits max_turn_failures (summarizer_service.cpp). That row
// deliberately blocks re-enqueue via unsummarized_turns()'s LEFT JOIN
// exclusion -- otherwise a persistently-poison turn would spin forever.
// But whatever caused the failure (inference outage, config regression)
// may since be fixed, and there's no other path back for these turns.
// Deleting the abandoned row here lets the turn fall back into
// unsummarized_turns()'s normal candidate set on the very next
// enqueue_catch_up() pass, giving it a fresh set of max_turn_failures
// attempts. Trivial-turn skips are NOT touched -- those are tagged with
// the real summarizer model name, never "bad", so this only targets
// genuine poison-abandon rows.
int Backend::Impl::reset_abandoned_turn_summaries(int limit) {
// SQLite's DELETE doesn't support ORDER BY/LIMIT without a special
// build flag (SQLITE_ENABLE_UPDATE_DELETE_LIMIT), which we don't
// assume is compiled in. Select target ids first, then delete by id.
std::vector ids;
{
std::string sel =
"SELECT ts.turn_summary_id FROM turn_summaries ts "
"JOIN models m ON m.model_id = ts.summary_model_id "
"WHERE m.name = 'bad' AND ts.text = '' "
"ORDER BY ts.turn_summary_id ASC";
if (limit > 0) sel += " LIMIT ?";
Stmt s(db, sel);
if (limit > 0) s.bind(1, limit);
while (s.step()) ids.push_back(s.column_int(0));
}
if (ids.empty()) return 0;
std::string placeholders;
for (size_t i = 0; i < ids.size(); ++i)
placeholders += (i ? ",?" : "?");
Stmt d(db, "DELETE FROM turn_summaries WHERE turn_summary_id IN (" + placeholders + ")");
for (size_t i = 0; i < ids.size(); ++i)
d.bind(static_cast(i + 1), ids[i]);
d.exec();
invalidate_turn_cache();
return static_cast(ids.size());
}
bool Backend::Impl::finalize_turn_summary(int turn_id, const std::string& text,
const std::string& summary_model_name) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(summary_model_name);
auto emb = embedder->encode(t);
Stmt g(db, "SELECT created_at, model_id, session_id FROM turns WHERE turn_id = ?");
g.bind(1, turn_id);
if (!g.step()) return false; // turn_id must exist
int64_t turn_dt = g.column_int64(0);
bool turn_model_null = g.is_null(1);
int turn_model_id = turn_model_null ? 0 : g.column_int(1);
bool session_null = g.is_null(2);
int session_id = session_null ? 0 : g.column_int(2);
// Plain insert â no placeholder row exists to conflict with anymore
// (Reid's design decision). The unique partial index
// idx_turn_summaries_turn_id_uniq still guards against an accidental
// duplicate finalize for the same turn_id: Stmt::exec() just checks
// sqlite3_step() == SQLITE_DONE, so a UNIQUE-constraint violation on
// a second finalize for the same turn_id naturally falls through to
// `return false` below (no exception, no special-case needed) â
// matching this file's existing convention of surfacing insert
// failures via the bool return rather than throwing.
Stmt s(db,
"INSERT INTO turn_summaries "
"(turn_id, text, embedding_version, embedding, session_id, turn_model_id, "
" summary_model_id, turn_datetime, summarized_on) "
"VALUES (?,?,?,?,?,?,?,?,unixepoch())");
s.bind(1, turn_id);
s.bind(2, t);
bind_embedding_version(s.raw(), 3, emb);
bind_embedding(s.raw(), 4, emb);
if (session_null) s.bind_null(5); else s.bind(5, session_id);
if (turn_model_null) s.bind_null(6); else s.bind(6, turn_model_id);
if (model_id) s.bind(7, model_id); else s.bind_null(7);
s.bind(8, turn_dt);
bool ok = s.exec();
if (ok) {
invalidate_turn_cache();
text_index_->index_record("turn_summaries", turn_id, t);
}
return ok;
}
// Create a turn_summaries row marking a turn as "done" with no summary
// text (trivial-turn skip, or poison-turn abandonment) â mirrors
// finalize_turn_summary's turns lookup, just with text left as ''
// (empty string, not NULL -- an empty string unambiguously means
// "intentionally blank"; NULL in this column would look like a data
// integrity error rather than a deliberate skip/abandon marker).
// Reid's design decision: no placeholder mechanism, so a turn_summaries
// row now always represents completed work (real summary, trivial-skip,
// or poison-abandon), never an in-progress sentinel.
bool Backend::Impl::mark_turn_summarized(int turn_id, const std::string& model_name) {
int model_id = get_or_create_model(model_name);
if (!model_id) return false;
Stmt g(db, "SELECT created_at, model_id, session_id FROM turns WHERE turn_id = ?");
g.bind(1, turn_id);
if (!g.step()) return false; // turn_id must exist
int64_t turn_dt = g.column_int64(0);
bool turn_model_null = g.is_null(1);
int turn_model_id = turn_model_null ? 0 : g.column_int(1);
bool session_null = g.is_null(2);
int session_id = session_null ? 0 : g.column_int(2);
Stmt s(db,
"INSERT INTO turn_summaries "
"(turn_id, text, session_id, turn_model_id, summary_model_id, turn_datetime, summarized_on) "
"VALUES (?,'',?,?,?,?,unixepoch())");
s.bind(1, turn_id);
if (session_null) s.bind_null(2); else s.bind(2, session_id);
if (turn_model_null) s.bind_null(3); else s.bind(3, turn_model_id);
s.bind(4, model_id);
s.bind(5, turn_dt);
bool ok = s.exec();
if (ok) {
invalidate_turn_cache();
text_index_->index_record("turn_summaries", turn_id, "");
}
return ok;
}
// Draft-tagged summary rows for re-summarization (housekeeping retry).
std::vector Backend::Impl::draft_summaries(int limit) {
std::vector out;
std::string sql =
"SELECT s.summary_id, s.level, COALESCE(ss.guid, ''), "
" datetime(s.created_at,'unixepoch','localtime') "
"FROM summaries s "
"LEFT JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE s.tags LIKE '%draft%' "
"ORDER BY s.created_at ASC, s.summary_id ASC";
if (limit > 0) sql += " LIMIT ?";
Stmt s(db, sql);
if (limit > 0) s.bind(1, limit);
while (s.step()) {
out.push_back({
s.column_int(0),
s.column_text(1),
s.column_text(2),
s.column_text(3)
});
}
return out;
}
// Sessions whose newest turn is older than (now - pause_minutes) AND
// that have at least one non-draft L2 summary but no complete L3 yet.
// The summarizer's pause timer treats this set as "ready to finalize."
// Returns session GUIDs; anonymous (session_id NULL) turns are skipped.
std::vector<:string> Backend::Impl::sessions_needing_close(int pause_minutes) {
std::vector<:string> out;
if (pause_minutes <= 0) return out;
const std::string cutoff =
std::format("datetime('now','localtime','-{} minutes')", pause_minutes);
std::string sql =
"SELECT ss.guid "
"FROM sessions ss "
// NOTE: sessions_needing_close() has no live callers as of the
// Phase-2 episode/session-rollup rework (see summarizer_service.cpp's
// "sessions_needing_close() path is retired" comment) -- this query
// is dead code today. Redirected to turn_summaries anyway (rather
// than left pointing at the now-empty summaries/level='turn' rows)
// so the function stays correct if it's ever revived.
"WHERE EXISTS (SELECT 1 FROM turn_summaries s2 "
" WHERE s2.session_id = ss.session_id) "
" AND (SELECT MAX(t.created_at) FROM turns t "
" WHERE t.session_id = ss.session_id) < " + cutoff + " "
" AND NOT EXISTS (SELECT 1 FROM summaries s "
" WHERE s.session_id = ss.session_id "
" AND s.level = 'session') "
"ORDER BY ss.session_id ASC";
Stmt s(db, sql);
while (s.step()) {
const auto g = s.column_text(0);
if (!g.empty()) out.push_back(g);
}
return out;
}
// ---- episode layer (EPISODE_PLAN Phase 1) --------------------------
// Most recent episode's span-end (stored in updated_at) for a session.
// Empty when the session has no episode rows yet.
std::string Backend::Impl::last_episode_end(const std::string& session_guid) {
if (session_guid.empty()) return {};
Stmt s(db,
"SELECT datetime(COALESCE(s.updated_at, s.created_at),'unixepoch','localtime') "
"FROM summaries s "
"JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE ss.guid = ? AND s.level = 'episode' "
"ORDER BY s.created_at DESC, s.summary_id DESC LIMIT 1");
s.bind(1, session_guid);
if (s.step()) return s.column_text(0);
return {};
}
// Non-draft L2 turn summaries for a session with timestamp > since_ts
// (empty since_ts = all), oldest-first. Composes the closing episode.
// since_ts arrives as a "%F %T" string (from last_episode_end(), which
// renders the epoch column back to that format) -- parse it back to
// epoch seconds to compare against the INTEGER created_at column.
std::vector Backend::Impl::l2_summaries_since(
const std::string& session_guid, const std::string& since_ts) {
std::vector out;
if (session_guid.empty()) return out;
// L2 turn summaries now live entirely in turn_summaries (db_version
// 0.12) -- redirected from the old summaries/level='turn' rows.
// turn_summaries has no `status`/`tags` columns (no draft state for
// turn-level rows under the new design), so `status` is left empty
// and the old `tags != 'draft'` filter is simply dropped.
std::string sql =
"SELECT s.turn_summary_id, s.text, '', "
" datetime(s.turn_datetime,'unixepoch','localtime') "
"FROM turn_summaries s "
"JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE ss.guid = ? AND s.text != '' ";
if (!since_ts.empty()) sql += "AND s.turn_datetime > ? ";
sql += "ORDER BY s.turn_datetime ASC, s.turn_summary_id ASC";
Stmt s(db, sql);
s.bind(1, session_guid);
if (!since_ts.empty()) s.bind(2, resolve_epoch(since_ts));
while (s.step()) {
out.push_back({s.column_int(0), s.column_text(1),
s.column_text(2), s.column_text(3)});
}
return out;
}
// Candidate turns for similarity-based episode detection: joins
// turn_summaries against turns to fetch both embeddings (turn + summary)
// plus the turn-summary text. Rows where EITHER embedding is NULL are
// skipped (boundary detection requires both signals).
std::vector Backend::Impl::episode_candidate_turns(
const std::string& session_guid, const std::string& since_ts) {
std::vector out;
if (session_guid.empty()) return out;
const int dims = config().embedding_dimensions;
std::string sql =
"SELECT ts.turn_summary_id, ts.text, "
" datetime(ts.turn_datetime,'unixepoch','localtime'), "
" ts.turn_datetime, "
" t.embedding, " // raw turn embedding
" ts.embedding " // turn-summary embedding
"FROM turn_summaries ts "
"JOIN turns t ON ts.turn_id = t.turn_id "
"JOIN sessions ss ON ts.session_id = ss.session_id "
"WHERE ss.guid = ? "
" AND t.embedding IS NOT NULL "
" AND ts.embedding IS NOT NULL ";
if (!since_ts.empty()) sql += "AND ts.turn_datetime > ? ";
sql += "ORDER BY ts.turn_datetime ASC, ts.turn_summary_id ASC";
Stmt s(db, sql);
s.bind(1, session_guid);
if (!since_ts.empty()) s.bind(2, resolve_epoch(since_ts));
bool warned = false;
while (s.step()) {
EpisodeCandidateTurn ct;
ct.turn_summary_id = s.column_int(0);
ct.summary_text = s.column_text(1);
ct.timestamp = s.column_text(2);
ct.epoch = s.column_int64(3);
ct.turn_embedding = decode_embedding_blob(
s.column_blob(4), s.column_bytes(4), dims, "turns", ct.turn_summary_id, warned);
ct.summary_embedding = decode_embedding_blob(
s.column_blob(5), s.column_bytes(5), dims, "turn_summaries", ct.turn_summary_id, warned);
out.push_back(std::move(ct));
}
return out;
}
// Insert one immutable level='episode' row: timestamp=first_ts (span
// start), updated_at=last_ts (span end). Mirrors store_summary.
int Backend::Impl::store_episode(const std::string& text, const std::string& model_name,
const std::string& session_guid,
const std::string& first_ts, const std::string& last_ts) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(model_name);
int session_id = get_or_create_session(session_guid);
auto emb = embedder->encode(t);
Stmt s(db,
"INSERT INTO summaries (model_id, session_id, text, embedding_version, embedding, "
"level, tags, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?)");
if (model_id) s.bind(1, model_id); else s.bind_null(1);
if (session_id) s.bind(2, session_id); else s.bind_null(2);
s.bind(3, t);
bind_embedding_version(s.raw(), 4, emb);
bind_embedding(s.raw(), 5, emb);
s.bind(6, std::string("episode"))
.bind(7, std::string(""));
s.bind(8, resolve_epoch(first_ts));
s.bind(9, resolve_epoch(last_ts.empty() ? first_ts : last_ts));
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
invalidate_cache();
int eid = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("summaries", eid, t);
return eid;
}
// Sessions whose open episode is ready to close: they have >=1 non-draft
// L2 turn summary past the last episode's end, and their newest turn is
// older than idle_minutes. Returns session GUIDs.
std::vector<:string> Backend::Impl::episodes_needing_close(int idle_minutes) {
std::vector<:string> out;
if (idle_minutes <= 0) return out;
const std::string cutoff =
std::format("(unixepoch('now','-{} minutes'))", idle_minutes);
// For each session: last episode end (updated_at of newest episode, or
// '' when none). "Turns needing an episode" = L2 turn_summaries rows
// with timestamp > that end (turn_summaries has no tags/'draft'
// state under the new design, so the old tags != 'draft' filter is
// simply dropped here). Idle = MAX(turn.timestamp) < cutoff.
std::string sql =
"SELECT ss.guid "
"FROM sessions ss "
"WHERE EXISTS ("
" SELECT 1 FROM turn_summaries s "
" WHERE s.session_id = ss.session_id "
" AND s.turn_datetime > COALESCE(("
" SELECT COALESCE(e.updated_at, e.created_at) FROM summaries e "
" WHERE e.session_id = ss.session_id AND e.level = 'episode' "
" ORDER BY e.created_at DESC, e.summary_id DESC LIMIT 1), 0)) "
" AND (SELECT MAX(t.created_at) FROM turns t "
" WHERE t.session_id = ss.session_id) < " + cutoff + " "
"ORDER BY ss.session_id ASC";
Stmt s(db, sql);
while (s.step()) {
const auto g = s.column_text(0);
if (!g.empty()) out.push_back(g);
}
return out;
}
// ---- Phase 2: boundary-triggered session/project rollups -----------
// All episode texts for a session, oldest-first. Corpus for the session
// rollup (episodes + any tail L2 turns are combined by the caller).
std::vector<:string> Backend::Impl::episode_texts(const std::string& session_guid) {
std::vector<:string> out;
if (session_guid.empty()) return out;
Stmt s(db,
"SELECT s.text FROM summaries s "
"JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE ss.guid = ? AND s.level = 'episode' "
"ORDER BY s.created_at ASC, s.summary_id ASC");
s.bind(1, session_guid);
while (s.step()) {
auto t = s.column_text(0);
if (!t.empty()) out.push_back(std::move(t));
}
return out;
}
// Stamp updated_at = now for a running rollup row.
bool Backend::Impl::set_summary_updated_at(int summary_id) {
Stmt s(db, "UPDATE summaries SET updated_at = ? WHERE summary_id = ?");
s.bind(1, db_epoch()).bind(2, summary_id);
return s.exec() && sqlite3_changes(db) > 0;
}
// ---- boundary-detection watermark (settings key/value table) --------
int64_t Backend::Impl::get_watermark(const std::string& key) {
Stmt s(db, "SELECT value FROM settings WHERE key = ?");
s.bind(1, key);
if (s.step()) {
try { return std::stoll(s.column_text(0)); } catch (...) { return 0; }
}
return 0;
}
void Backend::Impl::set_watermark(const std::string& key, int64_t turn_id) {
Stmt s(db, "INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value");
s.bind(1, key);
s.bind(2, std::to_string(turn_id));
s.exec();
}
// Maximal runs of consecutive (by turn_id) turns sharing the same
// non-NULL session_id, edge-detected via a running group id that
// increments whenever session_id changes from the previous row. The
// final/currently-open group (grp == MAX(grp)) is always excluded â
// it's still open because a future turn could extend it.
std::vector Backend::Impl::sessions_needing_close_boundary() {
std::vector out;
int64_t watermark = get_watermark(std::string(kSessionBoundaryWatermarkKey));
// Structural floor (Reid's invariant: "no automatic lookback to do
// any session summary before the last session summary"). A run is
// eligible only if NO level='session' summary already covers it --
// i.e. none exists for this session whose stored span end
// (updated_at == the run's last_ts at store time) reaches this run's
// last turn timestamp. This makes correctness independent of the
// watermark: even with watermark=0 (e.g. a migration that never
// stamped the key -> get_watermark() defaults to 0), an
// already-summarized run is never re-closed. The watermark remains as
// a cheap first-pass filter (`last_turn_id > ?`) so the common
// steady-state scan stays bounded, but it is no longer the sole
// guard against re-summarizing history.
Stmt s(db, R"(
WITH lagged AS (
SELECT t.turn_id, t.session_id, t.created_at,
LAG(t.session_id) OVER (ORDER BY t.turn_id) AS prev_sid
FROM turns t
),
numbered AS (
SELECT turn_id, session_id, created_at,
SUM(CASE WHEN session_id IS prev_sid THEN 0 ELSE 1 END)
OVER (ORDER BY turn_id) AS grp
FROM lagged
),
runs AS (
SELECT session_id, grp,
MIN(turn_id) AS first_turn_id, MAX(turn_id) AS last_turn_id,
MIN(created_at) AS first_ts, MAX(created_at) AS last_ts
FROM numbered
GROUP BY grp
)
SELECT ss.guid, r.first_ts, r.last_ts, r.first_turn_id, r.last_turn_id
FROM runs r
JOIN sessions ss ON r.session_id = ss.session_id
WHERE r.session_id IS NOT NULL
AND r.last_turn_id > ?
AND r.grp < (SELECT MAX(grp) FROM numbered)
AND NOT EXISTS (
SELECT 1 FROM summaries sm
WHERE sm.level = 'session'
AND sm.session_id = r.session_id
AND sm.updated_at >= r.last_ts
)
ORDER BY r.first_turn_id ASC
)");
s.bind(1, watermark);
while (s.step()) {
out.push_back({s.column_text(0), s.column_int64(1), s.column_int64(2),
s.column_int(3), s.column_int(4)});
}
return out;
}
// Same shape as sessions_needing_close_boundary(), but grouped by a
// time gap (>= gap_days days) between consecutive turns (by turn_id)
// rather than a session_id change, and NOT restricted to non-NULL
// session_id (project runs span all turns, anonymous included).
std::vector Backend::Impl::projects_needing_close_boundary(int gap_days) {
std::vector out;
if (gap_days <= 0) return out;
int64_t gap_seconds = static_cast(gap_days) * 86400;
int64_t watermark = get_watermark(std::string(kProjectBoundaryWatermarkKey));
Stmt s(db, R"(
WITH lagged AS (
SELECT t.turn_id, t.created_at,
LAG(t.created_at) OVER (ORDER BY t.turn_id) AS prev_ts
FROM turns t
),
numbered AS (
SELECT turn_id, created_at,
SUM(CASE
WHEN prev_ts IS NULL THEN 1
WHEN (created_at - prev_ts) >= ? THEN 1
ELSE 0
END) OVER (ORDER BY turn_id) AS grp
FROM lagged
),
runs AS (
SELECT grp,
MIN(turn_id) AS first_turn_id, MAX(turn_id) AS last_turn_id,
MIN(created_at) AS first_ts, MAX(created_at) AS last_ts
FROM numbered
GROUP BY grp
)
SELECT r.first_ts, r.last_ts, r.first_turn_id, r.last_turn_id
FROM runs r
WHERE r.last_turn_id > ?
AND r.grp < (SELECT MAX(grp) FROM numbered)
ORDER BY r.first_turn_id ASC
)");
s.bind(1, gap_seconds);
s.bind(2, watermark);
while (s.step()) {
out.push_back({"", s.column_int64(0), s.column_int64(1),
s.column_int(2), s.column_int(3)});
}
return out;
}
void Backend::Impl::advance_session_boundary_watermark(int turn_id) {
set_watermark(std::string(kSessionBoundaryWatermarkKey), turn_id);
}
void Backend::Impl::advance_project_boundary_watermark(int turn_id) {
set_watermark(std::string(kProjectBoundaryWatermarkKey), turn_id);
}
// Bounded text-gathering for a closed session run: episodes whose full
// span [created_at, COALESCE(updated_at,created_at)] lies entirely
// within [first_ts, last_ts] for this session_guid, plus trailing
// turn_summaries after the newest such episode's end (or from
// first_ts if there are none) up to last_ts. Unlike episode_texts()/
// l2_summaries_since() (unbounded above), this never reaches into a
// LATER run of the same session_guid.
std::vector<:string> Backend::Impl::bounded_session_rollup_texts(
const std::string& session_guid, int64_t first_ts, int64_t last_ts) {
std::vector<:string> out;
if (session_guid.empty()) return out;
// Episodes whose full span lies within the run, oldest-first.
Stmt e(db,
"SELECT s.text, COALESCE(s.updated_at, s.created_at) FROM summaries s "
"JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE ss.guid = ? AND s.level = 'episode' "
" AND s.created_at >= ? AND COALESCE(s.updated_at, s.created_at) <= ? "
"ORDER BY s.created_at ASC, s.summary_id ASC");
e.bind(1, session_guid);
e.bind(2, first_ts);
e.bind(3, last_ts);
int64_t newest_episode_end = first_ts - 1; // sentinel: no episodes yet
while (e.step()) {
auto t = e.column_text(0);
int64_t end_ts = e.column_int64(1);
if (!t.empty()) out.push_back(std::move(t));
if (end_ts > newest_episode_end) newest_episode_end = end_ts;
}
// Trailing turn_summaries strictly after the newest episode's end
// (or from first_ts-1 if no episodes), up to and including last_ts.
Stmt ts_q(db,
"SELECT ts.text FROM turn_summaries ts "
"JOIN sessions ss ON ts.session_id = ss.session_id "
"WHERE ss.guid = ? AND ts.text != '' "
" AND ts.turn_datetime > ? AND ts.turn_datetime <= ? "
"ORDER BY ts.turn_datetime ASC, ts.turn_summary_id ASC");
ts_q.bind(1, session_guid);
ts_q.bind(2, newest_episode_end);
ts_q.bind(3, last_ts);
while (ts_q.step()) {
auto t = ts_q.column_text(0);
if (!t.empty()) out.push_back(std::move(t));
}
return out;
}
// Bounded text-gathering for a closed project run: every level='session'
// row whose full span [created_at, COALESCE(updated_at,created_at)]
// lies entirely within [first_ts, last_ts], session-unscoped (a project
// run spans sessions), oldest-first.
std::vector<:string> Backend::Impl::bounded_project_rollup_texts(
int64_t first_ts, int64_t last_ts) {
std::vector<:string> out;
Stmt s(db,
"SELECT text FROM summaries "
"WHERE level = 'session' "
" AND created_at >= ? AND COALESCE(updated_at, created_at) <= ? "
"ORDER BY created_at ASC, summary_id ASC");
s.bind(1, first_ts);
s.bind(2, last_ts);
while (s.step()) {
auto t = s.column_text(0);
if (!t.empty()) out.push_back(std::move(t));
}
return out;
}
// Insert one immutable level='session' row spanning a closed run.
// Mirrors store_episode's shape exactly, except created_at/updated_at
// are bound directly as epoch ints (ClosedRun already carries real
// epoch seconds â no resolve_epoch() round-trip needed).
int Backend::Impl::store_session_summary(const std::string& text, const std::string& model_name,
const std::string& session_guid,
int64_t first_ts, int64_t last_ts) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(model_name);
int session_id = get_or_create_session(session_guid);
auto emb = embedder->encode(t);
Stmt s(db,
"INSERT INTO summaries (model_id, session_id, text, embedding_version, embedding, "
"level, tags, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?)");
if (model_id) s.bind(1, model_id); else s.bind_null(1);
if (session_id) s.bind(2, session_id); else s.bind_null(2);
s.bind(3, t);
bind_embedding_version(s.raw(), 4, emb);
bind_embedding(s.raw(), 5, emb);
s.bind(6, std::string("session"))
.bind(7, std::string(""));
s.bind(8, first_ts);
s.bind(9, last_ts);
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
invalidate_cache();
int sid = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("summaries", sid, t);
return sid;
}
// Insert one immutable level='project' row spanning a closed run.
// Session-unscoped (session_id NULL). Otherwise mirrors store_episode.
int Backend::Impl::store_project_summary(const std::string& text, const std::string& model_name,
int64_t first_ts, int64_t last_ts) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(model_name);
auto emb = embedder->encode(t);
Stmt s(db,
"INSERT INTO summaries (model_id, session_id, text, embedding_version, embedding, "
"level, tags, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?)");
if (model_id) s.bind(1, model_id); else s.bind_null(1);
s.bind_null(2);
s.bind(3, t);
bind_embedding_version(s.raw(), 4, emb);
bind_embedding(s.raw(), 5, emb);
s.bind(6, std::string("project"))
.bind(7, std::string(""));
s.bind(8, first_ts);
s.bind(9, last_ts);
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
invalidate_cache();
int pid = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("summaries", pid, t);
return pid;
}
std::vector Backend::Impl::turns_by_session_desc(
const std::string& session_guid, int limit) {
return turns_by_session_impl(session_guid, false, limit);
}
// Shared query: summaries for a session filtered by level, newest-first.
std::vector Backend::Impl::summaries_by_level_desc(
const std::string& session_guid, const std::string& level, int limit) {
std::vector out;
if (session_guid.empty()) return out;
std::string sql =
"SELECT s.summary_id, s.text, s.created_at "
"FROM summaries s "
"JOIN sessions ss ON s.session_id = ss.session_id "
"WHERE ss.guid = ? AND s.level = ? "
"ORDER BY s.created_at DESC, s.summary_id DESC";
if (limit > 0) sql += " LIMIT ?";
Stmt s(db, sql);
s.bind(1, session_guid).bind(2, level);
if (limit > 0) s.bind(3, limit);
while (s.step()) {
out.push_back({s.column_int(0), s.column_text(1),
std::string(), s.column_text(2)});
}
return out;
}
// L2 (turn) summaries for a session, newest-first.
std::vector Backend::Impl::turn_summaries_by_session_desc(
const std::string& session_guid, int limit) {
return summaries_by_level_desc(session_guid, "turn", limit);
}
// L3 (session) summaries for a session, newest-first.
std::vector Backend::Impl::session_summaries_desc(
const std::string& session_guid, int limit) {
return summaries_by_level_desc(session_guid, "session", limit);
}
// Exact-match existence check for import idempotency: same text +
// same created_at timestamp already present in summaries. Used by
// `ragger import conversations`/`import summaries` so re-running an
// import (or feeding overlapping exports) doesn't duplicate rows.
// Normalizes `text` the same way store_summary() does before storing
// (absolute home-dir paths -> "~/") â otherwise a chunk containing
// e.g. "/Users/reid/..." never matches what's actually in the table
// (already normalized to "~/...") and gets re-inserted on every run.
bool Backend::Impl::summary_exists_exact(const std::string& text, const std::string& created_at) {
std::string t = normalize_path(text);
Stmt s(db, "SELECT 1 FROM summaries WHERE text = ? AND created_at = ? LIMIT 1");
s.bind(1, t);
s.bind(2, created_at);
return s.step();
}
// Mirrors summary_exists_exact for the decisions table (same
// normalize-before-compare fix applies here too â plus the
// decision-number-prefix strip, since store_decision() strips that
// before storing too).
bool Backend::Impl::decision_exists_exact(const std::string& text, const std::string& created_at) {
std::string t = normalize_path(strip_decision_number_prefix(text));
Stmt s(db, "SELECT 1 FROM decisions WHERE text = ? AND created_at = ? LIMIT 1");
s.bind(1, t);
s.bind(2, created_at);
return s.step();
}
// Fuzzy dedup against live-captured turns: exact user_text, timestamp
// within ±window_seconds. Catches import overlap with turns Ragger
// captured live (identical content, a few seconds of clock skew â
// message send time vs. capture time, plus any tz-conversion rounding).
bool Backend::Impl::turn_exists_fuzzy(const std::string& user_text, const std::string& ts,
int window_seconds) {
Stmt s(db,
"SELECT 1 FROM turns WHERE user_text = ? "
"AND created_at BETWEEN datetime(?, ?) AND datetime(?, ?) LIMIT 1");
std::string neg = "-" + std::to_string(window_seconds) + " seconds";
std::string pos = "+" + std::to_string(window_seconds) + " seconds";
s.bind(1, user_text);
s.bind(2, ts).bind(3, neg);
s.bind(4, ts).bind(5, pos);
return s.step();
}
// Collapse whitespace, drop zero-width/variation-selector/control
// characters, and case-fold so the same exchange pasted through two
// different clients (Telegram markdown escaping vs. Claude.ai's raw
// text) compares equal. Comparison-only â never used for storage.
std::string Backend::Impl::normalize_for_compare(const std::string& s) {
std::string out;
out.reserve(s.size());
bool prev_space = false;
for (unsigned char c : s) {
// Drop ASCII control chars outright (keep the rest of UTF-8
// multibyte sequences as-is; this is a coarse cross-source
// comparison, not a full Unicode normalizer).
if (c < 0x20 && c != '\n') continue;
if (c == '\n' || c == '\t' || c == ' ') {
if (!prev_space && !out.empty()) out.push_back(' ');
prev_space = true;
continue;
}
prev_space = false;
out.push_back(static_cast(std::tolower(c)));
}
while (!out.empty() && out.back() == ' ') out.pop_back();
return out;
}
// Cross-source overlap lookup: scan turns for a user_text that
// normalizes-equal (see normalize_for_compare), ignoring timestamp
// entirely. Used to reconcile the same exchange captured from two
// different importers whose timestamps have no reason to agree.
std::optional Backend::Impl::find_turn_by_text(const std::string& user_text) {
std::string target = normalize_for_compare(user_text);
if (target.empty()) return std::nullopt;
Stmt s(db,
"SELECT t.turn_id, t.user_text, t.assistant_text, m.name, "
"t.created_at, ss.guid "
"FROM turns t "
"LEFT JOIN models m ON t.model_id = m.model_id "
"LEFT JOIN sessions ss ON t.session_id = ss.session_id "
"WHERE t.user_text LIKE '%' || substr(?, 1, 40) || '%'");
// Narrow with a cheap substring prefilter (avoids a full table
// scan through normalize_for_compare on every row), then confirm
// with the real normalized comparison in C++.
s.bind(1, user_text.substr(0, 40));
while (s.step()) {
std::string cand = s.column_text(1);
if (normalize_for_compare(cand) == target) {
return TurnRecord{
s.column_int(0), cand, s.column_text(2),
s.column_text(3), s.column_text(4), s.column_text(5)
};
}
}
return std::nullopt;
}
// Upgrade an existing turn's timestamp/session_guid in place. Empty
// args leave that field untouched. session_guid resolves/creates a
// sessions row same as store_turn.
bool Backend::Impl::update_turn_meta(int turn_id, const std::string& timestamp,
const std::string& session_guid) {
if (timestamp.empty() && session_guid.empty()) return true;
std::string sql = "UPDATE turns SET ";
bool first = true;
if (!timestamp.empty()) { sql += "created_at = ?"; first = false; }
if (!session_guid.empty()) {
if (!first) sql += ", ";
sql += "session_id = ?";
}
sql += " WHERE turn_id = ?";
Stmt s(db, sql);
int idx = 1;
if (!timestamp.empty()) s.bind(idx++, timestamp);
if (!session_guid.empty())
s.bind(idx++, get_or_create_session(session_guid));
s.bind(idx, turn_id);
return s.exec() && sqlite3_changes(db) > 0;
}
// Recipe ingredients (issue #23): recent summaries of a given level, and
// current decisions â fetched by recency (not semantic search) for the
// default tiered payload. Returned newest-first.
std::vector<:string> Backend::Impl::recent_summaries(const std::string& level, int limit) {
std::vector<:string> out;
if (limit <= 0) return out;
Stmt s(db,
"SELECT text FROM summaries WHERE level = ? "
"ORDER BY created_at DESC, summary_id DESC LIMIT ?");
s.bind(1, level).bind(2, limit);
while (s.step()) {
auto text = s.column_text(0);
if (!text.empty()) out.push_back(std::move(text));
}
return out;
}
std::vector<:string> Backend::Impl::current_decisions(int limit) {
std::vector<:string> out;
if (limit <= 0) return out;
Stmt s(db,
"SELECT text FROM decisions WHERE status = 'current' "
"ORDER BY created_at DESC, decision_id DESC LIMIT ?");
s.bind(1, limit);
while (s.step()) {
auto text = s.column_text(0);
if (!text.empty()) out.push_back(std::move(text));
}
return out;
}
// Set a decision's status (e.g. "roadmap" -> "current" once planned
// work is done, or -> "superseded"/"deprecated" once stale).
bool Backend::Impl::set_decision_status(int decision_id, const std::string& status) {
Stmt s(db, "UPDATE decisions SET status = ? WHERE decision_id = ?");
s.bind(1, status).bind(2, decision_id);
bool ok = s.exec() && sqlite3_changes(db) > 0;
if (ok) invalidate_dec_cache();
return ok;
}
// Decisions with an arbitrary status, most recent first â e.g.
// status="roadmap" to list planned/future work explicitly (roadmap
// entries are deliberately excluded from current_decisions()'s
// recall-pipeline query so unfinished plans don't clutter every
// session's context).
std::vector<:string> Backend::Impl::decisions_by_status(const std::string& status, int limit) {
std::vector<:string> out;
if (limit <= 0) return out;
Stmt s(db,
"SELECT text FROM decisions WHERE status = ? "
"ORDER BY created_at DESC, decision_id DESC LIMIT ?");
s.bind(1, status);
s.bind(2, limit);
while (s.step()) {
auto text = s.column_text(0);
if (!text.empty()) out.push_back(std::move(text));
}
return out;
}
// Replace a summary's text + embedding, update its model. False if absent.
bool Backend::Impl::update_summary_text(int summary_id, const std::string& text,
const std::string& model_name) {
std::string t = normalize_path(text);
int model_id = get_or_create_model(model_name);
auto emb = embedder->encode(t);
Stmt s(db,
"UPDATE summaries SET text = ?, embedding_version = ?, embedding = ?, "
"model_id = COALESCE(?, model_id) WHERE summary_id = ?");
s.bind(1, t);
bind_embedding_version(s.raw(), 2, emb);
bind_embedding(s.raw(), 3, emb);
if (model_id) s.bind(4, model_id); else s.bind_null(4);
s.bind(5, summary_id);
bool ok = s.exec() && sqlite3_changes(db) > 0;
if (ok) {
invalidate_cache();
text_index_->index_record("summaries", summary_id, t);
}
return ok;
}
// Replace a summary's tags column. Used by the summarizer to clear
// "draft" once a row has been rewritten with a real summary.
bool Backend::Impl::set_summary_tags(int summary_id, const std::string& tags) {
Stmt s(db,
"UPDATE summaries SET tags = ? WHERE summary_id = ?");
s.bind(1, tags).bind(2, summary_id);
return s.exec() && sqlite3_changes(db) > 0;
}
bool Backend::Impl::update_text(int memory_id, const std::string& raw_text, json metadata, bool defer_embedding) {
if (metadata.is_null()) metadata = json::object();
// Refuse to mutate protected rows for parity with delete_memory.
Stmt check_stmt(db, "SELECT tags FROM summaries WHERE summary_id = ?");
check_stmt.bind(1, memory_id);
if (!check_stmt.step()) return false; // row not found
if (check_stmt.column_text(0).find("keep") != std::string::npos) return false;
// Lean v2 summaries: only text/embedding/tags are mutable here.
// keep/bad flags fold into tags (parity with store()).
std::string tags_str = tags_from_metadata(metadata);
std::string text = normalize_path(raw_text);
std::vector emb;
if (!defer_embedding) {
emb = embedder->encode(text);
}
// Custom index: re-tokenize the record on the new text (v0.16).
Stmt stmt(db,
"UPDATE summaries SET text = ?, embedding_version = ?, embedding = ?, tags = ? "
"WHERE summary_id = ?");
stmt.bind(1, text);
if (defer_embedding) {
stmt.bind_null(2);
stmt.bind_null(3);
} else {
bind_embedding_version(stmt.raw(), 2, emb);
bind_embedding(stmt.raw(), 3, emb);
}
stmt.bind(4, tags_str);
stmt.bind(5, memory_id);
if (!stmt.exec()) return false;
invalidate_cache();
text_index_->index_record("summaries", memory_id, text);
return true;
}
} // namespace ragger::sqlite