#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) {
// Fast reject (review 3C): the pattern is anchored, so a match
// requires the first non-whitespace token (after at most two '*')
// to begin with "Decision" or "Design" (case-insensitive). Most
// stored texts don't â skip the regex engine for them.
{
size_t i = 0;
while (i < text.size() && (text[i] == ' ' || text[i] == '\t' ||
text[i] == '\n' || text[i] == '\r'))
++i;
for (size_t stars = 0; i < text.size() && text[i] == '*' && stars < 2;
++i, ++stars) {}
auto ci_at = [&text](size_t at, const char* word) {
for (const char* w = word; *w; ++w, ++at) {
if (at >= text.size()) return false;
char c = text[at];
if (c >= 'A' && c <= 'Z') c = static_cast(c + 32);
if (c != *w) return false;
}
return true;
};
if (!ci_at(i, "decision") && !ci_at(i, "design"))
return 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 (behind /store, MCP `store`, CLI `ragger
// store`). PHASE8: it writes a knowledge row of kind='note' â the
// summaries table is gone. Caller fields map onto the note:
// status <- metadata["status"] (default current; must be creatable)
// tags <- metadata["tags"] (+ keep/bad flag folding, as ever)
// level <- metadata["level"]: the column no longer exists, but
// nothing a caller sent is discarded â a level other than
// "note" is appended as a `level:` tag
// created <- metadata["timestamp"] (historical imports)
// session <- metadata["session"] GUID, lookup only (unknown -> NULL)
// model <- metadata["model"]
// The returned id is a knowledge_id (string, as before).
std::string Backend::Impl::store(const std::string& raw_text, json metadata, bool defer_embedding) {
if (metadata.is_null()) metadata = json::object();
std::string status = metadata.value("status", std::string("current"));
{
bool ok = false;
for (auto sv : status::kCreatable) if (status == sv) ok = true;
if (!ok)
throw std::invalid_argument(
"status must be one of: current, provisional, pending");
}
// 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 deletion (see delete_memory).
std::string tags_str = tags_from_metadata(metadata);
const std::string level = metadata.value("level", std::string());
if (!level.empty() && level != "note") {
if (!tags_str.empty()) tags_str += ",";
tags_str += "level:" + level;
}
// 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>();
// Session provenance by GUID, lookup only â never create a session
// from here (same rule as store_knowledge).
std::optional session_id;
const std::string guid = metadata.value("session", std::string());
if (!guid.empty()) {
Stmt st(db, "SELECT session_id FROM sessions WHERE guid = ?");
st.bind(1, guid);
if (st.step()) session_id = st.column_int(0);
}
std::string text = normalize_path(raw_text);
if (text.empty())
throw std::invalid_argument("note text is required");
std::vector emb;
if (!defer_embedding)
emb = require_embedder().encode(text); // trigger NULL -> text
std::optional model_id;
const std::string model_name = metadata.value("model", std::string());
if (!model_name.empty())
model_id = get_or_create_model(model_name);
Transaction txn(db);
const int id = insert_knowledge_row(
"note", text, status, tags_str,
/*trigger=*/"", /*rationale=*/"", /*scope=*/"",
/*supersedes=*/std::nullopt, session_id, model_id,
resolve_epoch(ts_override), emb, defer_embedding);
text_index_->index_record("knowledge", id, text);
txn.commit();
invalidate_knowledge_cache();
return std::to_string(id);
}
// ---- store_document: write Level 5 RAG chunk ----------------------
// Imported documents with chunk counts, by primary key. Gaps left by
// deletions are shown as they are (the key is never renumbered).
json Backend::Impl::list_document_sources() {
Stmt s(db,
"SELECT ds.document_source_id, ds.title, ds.path, ds.year, ds.tags, "
" datetime(ds.imported_at,'unixepoch','localtime'), "
" (SELECT COUNT(*) FROM documents d "
" WHERE d.document_source_id = ds.document_source_id) "
"FROM document_sources ds ORDER BY ds.document_source_id");
json out = json::array();
while (s.step()) {
out.push_back({
{"id", s.column_int64(0)},
{"title", s.is_null(1) ? json(nullptr) : json(s.column_text(1))},
{"path", s.is_null(2) ? json(nullptr) : json(s.column_text(2))},
{"year", s.is_null(3) ? json(nullptr) : json(s.column_int(3))},
{"tags", s.column_text(4)},
{"imported_at", s.column_text(5)},
{"chunks", s.column_int64(6)},
});
}
return out;
}
// Delete a document: every chunk (documents_terms rows go with them via
// ON DELETE CASCADE; shared `terms` entries stay by design) and then the
// source row, in ONE transaction so a failure leaves nothing half-removed.
json Backend::Impl::delete_document_source(int document_source_id) {
json info;
{
Stmt s(db,
"SELECT title, path, "
" (SELECT COUNT(*) FROM documents WHERE document_source_id = ?1) "
"FROM document_sources WHERE document_source_id = ?1");
s.bind(1, document_source_id);
if (!s.step())
throw std::invalid_argument(std::format(
"no document with id {}", document_source_id));
info = {{"id", document_source_id},
{"title", s.is_null(0) ? json(nullptr) : json(s.column_text(0))},
{"path", s.is_null(1) ? json(nullptr) : json(s.column_text(1))},
{"chunks", s.column_int64(2)}};
}
Stmt(db, "BEGIN").exec();
try {
Stmt dc(db, "DELETE FROM documents WHERE document_source_id = ?");
dc.bind(1, document_source_id);
if (!dc.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
Stmt ds(db, "DELETE FROM document_sources WHERE document_source_id = ?");
ds.bind(1, document_source_id);
if (!ds.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
Stmt(db, "COMMIT").exec();
} catch (...) {
Stmt(db, "ROLLBACK").exec();
throw;
}
invalidate_doc_cache();
return info;
}
// Document source by its unique path (0.17: path alone identifies a
// document; title is label metadata). Null json when absent.
json Backend::Impl::find_document_source(const std::string& path) {
Stmt s(db,
"SELECT document_source_id, ifnull(title,''), "
" datetime(imported_at,'unixepoch','localtime') "
"FROM document_sources WHERE path = ?");
s.bind(1, path);
if (!s.step()) return json(nullptr);
return json{{"id", s.column_int64(0)}, {"title", s.column_text(1)},
{"imported_at", s.column_text(2)}};
}
// Resolve a document (by its unique path) 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 path = ?");
s.bind(1, path);
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);
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) {
// documents index real files: path is the unique identity (0.17).
if (chunk.path.empty())
throw std::invalid_argument("document path is required");
// 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 = require_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);
}
int doc_id = -1;
{
Transaction tx(db); // row + postings atomic (B §1.3)
if (!s.exec()) {
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
}
doc_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("documents", doc_id, signal_text);
tx.commit();
}
invalidate_doc_cache(); // after commit, not before (B §1.3)
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).
void Backend::Impl::touch_session_span(int session_id, int64_t epoch) {
if (session_id <= 0) return; // turn has no session
// NOTE: two-arg scalar MIN()/MAX() are NOT used â they return NULL
// when either side is NULL, which would pin a NULL bound forever.
Stmt s(db,
"UPDATE sessions SET "
"started_on = CASE WHEN started_on IS NULL OR ?1 < started_on THEN ?1 ELSE started_on END, "
"ended_on = CASE WHEN ended_on IS NULL OR ?1 > ended_on THEN ?1 ELSE ended_on END "
"WHERE session_id = ?2");
s.bind(1, epoch);
s.bind(2, session_id);
if (!s.exec())
throw std::runtime_error(std::format(
"touch_session_span (session {}): {}", session_id, sqlite3_errmsg(db)));
}
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 = require_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();
// Row write + postings in one transaction (B §1.3); the
// embedder call above stays outside on purpose.
Transaction tx(db);
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())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
text_index_->index_record("turns", prev_id, u + "\n" + a);
// Span follows the row's effective session (the UPDATE
// keeps the old session when no new guid was given).
touch_session_span(session_id ? session_id : p.column_int(3), new_ts);
tx.commit();
return prev_id;
}
}
}
std::vector emb;
bool have_emb = false;
if (!defer_embedding && !a.empty()) {
emb = require_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);
}
const int64_t ts = resolve_epoch(source_timestamp);
s.bind(7, ts);
int new_id = -1;
{
Transaction tx(db); // row + postings atomic (B §1.3)
if (!s.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
new_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("turns", new_id, a.empty() ? u : (u + "\n" + a));
touch_session_span(session_id, ts);
tx.commit();
}
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 = require_embedder().encode(turn_embed_text(u, a));
Transaction tx(db); // row + postings atomic (B §1.3)
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; // tx rolls back on scope exit
text_index_->index_record("turns", turn_id, u + "\n" + a);
tx.commit();
return true;
}
// PHASE8: the old summary-store entry point is gone â importers write rows of
// kind='note' through store_knowledge (caller tags + `imported`); the
// generic store() above covers the free-text path.
// Shared INSERT for both write paths (PHASE7 B.3): the structured path
// (store_knowledge) and the legacy store_decision wrapper. Returns the new
// knowledge_id. `trigger` NULL for the legacy path â the trigger-required
// rule lives in store_knowledge's validation, NOT here, exactly as the
// old store_decision bypassed store_structured_row's checks.
int Backend::Impl::insert_knowledge_row(const std::string& kind,
const std::string& text,
const std::string& status,
const std::string& tags,
const std::string& trigger,
const std::string& rationale,
const std::string& scope,
std::optional supersedes,
std::optional session_id,
std::optional model_id,
int64_t created_at,
const std::vector& emb,
bool defer_embedding) {
Stmt ins(db,
"INSERT INTO knowledge (kind, text, status, tags, trigger, rationale, scope,"
" supersedes, session_id, model_id, created_at, embedding_version, embedding)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?, ?,?)");
ins.bind(1, kind);
ins.bind(2, text);
ins.bind(3, status);
ins.bind(4, tags);
if (trigger.empty()) ins.bind_null(5); else ins.bind(5, trigger);
if (rationale.empty()) ins.bind_null(6); else ins.bind(6, rationale);
if (scope.empty()) ins.bind_null(7); else ins.bind(7, scope);
if (supersedes) ins.bind(8, *supersedes); else ins.bind_null(8);
if (session_id) ins.bind(9, *session_id); else ins.bind_null(9);
if (model_id) ins.bind(10, *model_id); else ins.bind_null(10);
ins.bind(11, created_at);
if (defer_embedding) {
ins.bind_null(12);
ins.bind_null(13);
} else {
bind_embedding_version(ins.raw(), 12, emb);
bind_embedding(ins.raw(), 13, emb);
}
if (!ins.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
return static_cast(sqlite3_last_insert_rowid(db));
}
// ---- store_decision: legacy Level 6 curated decision/lesson ----------
// Thin wrapper over knowledge(kind='decision') WITHOUT the trigger rule
// (used by /store category=decision, the CLI, and importers â none of
// which ever supplied a trigger). defer_embedding leaves embedding NULL.
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));
std::vector emb;
if (!defer_embedding) emb = require_embedder().encode(t);
const int did = insert_knowledge_row(
"decision", t, status.empty() ? "current" : status, tags,
/*trigger=*/"", /*rationale=*/"", /*scope=*/"",
/*supersedes=*/std::nullopt, /*session_id=*/std::nullopt,
/*model_id=*/std::nullopt, resolve_epoch(source_timestamp),
emb, defer_embedding);
invalidate_knowledge_cache();
text_index_->index_record("knowledge", did, t);
return did;
}
// ---- store_knowledge: the ONE structured write path (PHASE7 B.1) ------
// Replaces store_structured_decision + store_insight (and their
// StructuredRowSpec): kind selects the rules via status::kKinds â
// decision requires a trigger, insight requires evidence, note neither.
// supersedes may point at ANY kind (insight -> decision promotion).
// Order matters (EXACTLY as the old path):
// 1. validate everything that can be checked without writing,
// 2. embed OUTSIDE the transaction (the embedder can take 100+ ms and
// we must not hold a write lock across it),
// 3. Transaction: INSERT; flip the superseded row; link evidence;
// index; commit().
// Any throw inside step 3 rolls back, so a failed evidence link never
// leaves a half-written row or an orphaned 'superseded' flip.
KnowledgeResult Backend::Impl::store_knowledge(const KnowledgeInput& in) {
auto trim = [](const std::string& s) {
size_t a = s.find_first_not_of(" \t\r\n");
if (a == std::string::npos) return std::string();
size_t b = s.find_last_not_of(" \t\r\n");
return s.substr(a, b - a + 1);
};
const std::string text = normalize_path(strip_decision_number_prefix(trim(in.text)));
const std::string trigger = trim(in.trigger);
const std::string status = in.status.empty() ? "current" : in.status;
// ---- 1. validate ----------------------------------------------
const status::KindRule* rule = status::find_kind(in.kind);
if (!rule)
throw std::invalid_argument("kind must be one of: decision, insight, note");
const std::string_view noun = rule->noun;
if (text.empty())
throw std::invalid_argument(std::format("{} text is required", noun));
if (rule->trigger_required && trigger.empty())
throw std::invalid_argument(std::format("{} trigger is required", noun));
if (rule->evidence_required && in.evidence.empty())
throw std::invalid_argument(std::format(
"{} requires at least one evidence turn_summary_id", noun));
// Statuses a NEW row may start in. 'superseded' and 'retired' are
// reached only by curation, never on create.
{
bool ok = false;
for (auto sv : ragger::sqlite::status::kCreatable) if (status == sv) ok = true;
if (!ok)
throw std::invalid_argument(
"status must be one of: current, provisional, pending");
}
if (in.supersedes) {
Stmt st(db, "SELECT status FROM knowledge WHERE knowledge_id = ?");
st.bind(1, *in.supersedes);
if (!st.step())
throw std::invalid_argument(std::format(
"supersedes: knowledge {} does not exist", *in.supersedes));
const std::string old_status = st.column_text(0);
if (old_status == "superseded" || old_status == "retired")
throw std::invalid_argument(std::format(
"supersedes: knowledge {} is already {}", *in.supersedes, old_status));
}
for (int ts_id : in.evidence) {
Stmt st(db, "SELECT 1 FROM turn_summaries "
"WHERE turn_summary_id = ? AND text != ''");
st.bind(1, ts_id);
if (!st.step())
throw std::invalid_argument(std::format(
"evidence: turn_summary {} does not exist or is empty", ts_id));
}
std::optional session_id;
if (!in.session_guid.empty()) {
// Look up only -- never create a session from here. An unknown
// GUID just means no provenance link (NULL), not an error.
Stmt st(db, "SELECT session_id FROM sessions WHERE guid = ?");
st.bind(1, in.session_guid);
if (st.step()) session_id = st.column_int(0);
}
// ---- 2. embed (outside the transaction) ------------------------
// MUST equal kKnowledgeEmbedTextSQL's result for this row, so a
// housekeeping re-embed produces the same vector. (The SQL CASE
// collapses to bare `text` when trigger is NULL/empty â insights
// and notes allow that; decisions never reach the empty branch.)
const std::string embed_text =
trigger.empty() ? text : trigger + "\n" + text;
auto emb = require_embedder().encode(embed_text);
// ---- 3. write ---------------------------------------------------
KnowledgeResult out;
out.kind = in.kind;
{
Transaction txn(db);
out.id = insert_knowledge_row(
in.kind, text, status, in.tags, trigger, trim(in.rationale),
trim(in.scope), in.supersedes, session_id,
in.model_name.empty() ? std::optional{}
: std::optional(get_or_create_model(in.model_name)),
resolve_epoch(in.source_timestamp), emb, /*defer=*/false);
if (in.supersedes) {
Stmt up(db, "UPDATE knowledge SET status = 'superseded' WHERE knowledge_id = ?");
up.bind(1, *in.supersedes);
if (!up.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
out.superseded = *in.supersedes;
}
for (int ts_id : in.evidence) {
// OR IGNORE: a caller listing the same id twice is harmless.
Stmt ev(db, "INSERT OR IGNORE INTO knowledge_turn_evidence "
"(knowledge_id, turn_summary_id) VALUES (?,?)");
ev.bind(1, out.id);
ev.bind(2, ts_id);
if (!ev.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
}
text_index_->index_record("knowledge", out.id, embed_text);
txn.commit();
}
invalidate_knowledge_cache();
// ---- possible_supersedes hint (Phase 2 step 5) -------------------
// Flag only: up to 3 OTHER active rows of kind decision|insight (a
// note is never a hint target) whose embedding cosine with the new
// row reaches decision_supersede_threshold. The row the call already
// superseded is skipped. NO row is ever changed here.
if (in.kind != "note") {
std::vector exclude{out.id};
if (out.superseded) exclude.push_back(*in.supersedes);
out.possible_supersedes = supersede_hints(
emb, exclude, "kind IN ('decision','insight')");
}
return out;
}
// Cosine near-duplicate scan over active knowledge rows (Phase 2 step 5;
// PHASE7: one table, optional kind restriction). Purely
// vector-based on purpose: a near-duplicate ruling is exactly what this
// looks like in embedding space, and lexical overlap is already guaranteed
// by the shared trigger vocabulary. Returns up to 3 (id, cosine) pairs at
// or above decision_supersede_threshold, highest first. Never changes a row.
std::vector<:pair>> Backend::Impl::supersede_hints(
const std::vector& emb,
const std::vector& exclude_ids,
const char* kind_filter) {
std::vector<:pair>> scored;
if (emb.empty()) return scored;
Eigen::Map v(emb.data(), static_cast(emb.size()));
float vnorm = v.norm();
if (vnorm <= 1e-12f) return scored;
const float threshold = config().decision_supersede_threshold;
const int expected_dims = config().embedding_dimensions;
bool blob_warned = false;
std::string sql =
"SELECT knowledge_id, embedding FROM knowledge WHERE status IN (" +
status::in_list_sql(status::kSearchActive) + ") ";
if (kind_filter) sql += std::format(" AND ({}) ", kind_filter);
for (size_t i = 0; i < exclude_ids.size(); ++i)
sql += std::format(" AND knowledge_id != ? ");
sql += " AND embedding IS NOT NULL";
Stmt s(db, sql);
for (size_t i = 0; i < exclude_ids.size(); ++i)
s.bind(static_cast(i) + 1, exclude_ids[i]);
while (s.step()) {
std::vector other = decode_embedding_blob(
s.column_blob(1), s.column_bytes(1), expected_dims,
"knowledge", s.column_int(0), blob_warned);
if (static_cast(other.size()) != expected_dims) continue;
Eigen::Map<:vectorxf> o(other.data(), expected_dims);
float onorm = o.norm();
if (onorm <= 1e-12f) continue; // zero/undecodable vector
float cos = (v.dot(o)) / (vnorm * onorm);
if (cos >= threshold) scored.emplace_back(s.column_int(0), cos);
}
std::sort(scored.begin(), scored.end(),
[](const auto& a, const auto& b) { return a.second > b.second; });
if (scored.size() > 3) scored.resize(3);
return scored;
}
std::vector<:pair>> Backend::Impl::possible_supersedes_for_text(
const std::string& text) {
if (!embedder || !embedder->ready() || text.empty())
return {}; // degraded: no hints (per P2 rule)
return supersede_hints(require_embedder().encode(text), {});
}
// ---- 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
// a real model name or the "(skipped)" pseudo-model (PHASE6 3), 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 = '' AND ts.turn_id IS NOT NULL "
"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]);
if (!d.exec())
throw std::runtime_error(std::format("reset_abandoned_turn_summaries delete: {}",
sqlite3_errmsg(db)));
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 = require_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);
// The index addresses turn_summaries by turn_summary_id, NOT turn_id
// (they diverge on any real DB). The INSERT+index pair must be atomic:
// a failed index rolls the summary row back instead of leaving an
// unindexed or mis-indexed row.
Transaction tx(db);
bool ok = s.exec();
if (ok) {
// Read the new rowid BEFORE any other statement runs on db.
const int ts_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("turn_summaries", ts_id, t);
tx.commit();
invalidate_turn_cache();
}
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);
// Keying + atomicity: see finalize_turn_summary above. The empty text
// indexes to zero terms but set_counts must still land on THIS row.
Transaction tx(db);
bool ok = s.exec();
if (ok) {
// Read the new rowid BEFORE any other statement runs on db.
const int ts_id = static_cast(sqlite3_last_insert_rowid(db));
text_index_->index_record("turn_summaries", ts_id, "");
tx.commit();
invalidate_turn_cache();
}
return ok;
}
// ---- episode layer (db 0.17) ----------------------------------------
// Most recent episode's span_end for a session, rendered "%F %T" local
// so callers/tests keep the historic shape. Empty when no episodes.
std::string Backend::Impl::last_episode_end(const std::string& session_guid) {
if (session_guid.empty()) return {};
Stmt s(db,
"SELECT datetime(e.span_end,'unixepoch','localtime') "
"FROM episodes e "
"JOIN sessions ss ON e.session_id = ss.session_id "
"WHERE ss.guid = ? "
"ORDER BY e.span_start DESC, e.episode_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).
// PHASE6 6: ONE predicate for "pending content L2 of session X after
// epoch Y". Both episodes_needing_close() and episode_candidate_turns()
// build their WHERE clauses from this, so they cannot drift apart again
// (the old disagreement: the scan counted any L2 past the last episode,
// the candidate query additionally required embeddings â a session whose
// newest L2 had no embedding yet was re-queued every tick forever).
// Requires `turn_summaries ts JOIN turns t ON t.turn_id = ts.turn_id`.
// Rows whose source turn was culled (ts.turn_id NULL) can never be
// candidates â the JOIN drops them; they cannot close an episode either.
std::string pending_episode_l2_conditions(const std::string& epoch_expr) {
return " AND ts.text != '' "
" AND ts.episode_id IS NULL "
" AND ts.turn_datetime > " + epoch_expr + " "
" AND ts.embedding IS NOT NULL "
" AND t.embedding IS NOT NULL ";
}
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;
// PHASE6 6: WHERE clause from the shared predicate. episode_id IS
// NULL means rows already inside a closed episode are never returned
// again, even when their turn_datetime falls after `since` (a late
// L2 landing inside a span that was closed around it).
const std::string epoch_expr = since_ts.empty() ? "0" : "?";
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 = ? " +
pending_episode_l2_conditions(epoch_expr) +
"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;
}
// Close an episode (PHASE5 B.1): one atomic episode + membership record.
// The contract ORDER is the point: validate -> embed (outside the txn) ->
// INSERT -> per-member UPDATE (all-or-nothing) -> index -> commit ->
// invalidate. See storage_backend.h for the full contract.
int Backend::Impl::store_episode_close(const EpisodeClose& close) {
if (close.text.empty())
throw std::invalid_argument("store_episode_close: empty text");
if (close.member_turn_summary_ids.empty())
throw std::invalid_argument("store_episode_close: no members");
if (close.span_end < close.span_start)
throw std::invalid_argument("store_episode_close: span_end < span_start");
const int session_id = get_or_create_session(close.session_guid);
if (!session_id)
throw std::invalid_argument("store_episode_close: unknown session '" +
close.session_guid + "'");
const std::string t = normalize_path(close.text);
const int model_id = get_or_create_model(close.model_name);
// Compute the embedding OUTSIDE the transaction: encoding is slow and
// holding the write lock across a model call would block every other
// writer.
auto emb = require_embedder().encode(t);
Transaction tx(db);
Stmt ins(db,
"INSERT INTO episodes (text, tags, session_id, model_id, "
"span_start, span_end, embedding_version, embedding) "
"VALUES (?,?,?,?,?,?,?,?)");
ins.bind(1, t).bind(2, std::string(""));
ins.bind(3, session_id);
if (model_id) ins.bind(4, model_id); else ins.bind_null(4);
ins.bind(5, close.span_start).bind(6, close.span_end);
bind_embedding_version(ins.raw(), 7, emb);
bind_embedding(ins.raw(), 8, emb);
if (!ins.exec()) {
if (sqlite3_extended_errcode(db) == SQLITE_CONSTRAINT_UNIQUE) {
// Someone already closed this (session, span_start): not an
// error. The Transaction destructor rolls back; the caller
// logs "already closed" and does not retry.
return 0;
}
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
}
const int episode_id = static_cast(sqlite3_last_insert_rowid(db));
// Membership: each member must exist in THIS session, be unassigned,
// and not be a marker row. Any straggler (foreign session, already
// closed, marker, missing) comes up short on the change count and the
// whole close rolls back â nothing half-written survives.
int assigned = 0;
for (const int ts_id : close.member_turn_summary_ids) {
Stmt u(db,
"UPDATE turn_summaries SET episode_id = ? "
"WHERE turn_summary_id = ? AND session_id = ? "
" AND episode_id IS NULL AND text != ''");
u.bind(1, episode_id).bind(2, ts_id).bind(3, session_id);
if (!u.exec())
throw std::runtime_error(std::format(lang::ERR_STORE_FAILED, sqlite3_errmsg(db)));
assigned += sqlite3_changes(db);
}
if (assigned != static_cast(close.member_turn_summary_ids.size()))
throw std::runtime_error(std::format(
"store_episode_close: {} of {} members assignable "
"(foreign session / already closed / marker / missing); rolled back",
assigned, close.member_turn_summary_ids.size()));
text_index_->index_record("episodes", episode_id, t);
tx.commit();
invalidate_episode_cache();
return episode_id;
}
// Sessions whose open episode is ready to close: >=1 non-empty L2 turn
// summary past the last episode's span_end (0 when none), 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);
// EXISTS(shared predicate): content L2 past the last episode end,
// embeddings present on both sides (verified-bug fix for ''-text
// marker rows included in the predicate). PHASE6 6 additionally
// requires that NO turn of the session is still unsummarized â an
// episode must not close while a late L2 could still land inside
// the span it just wrote. A bad/skip marker row counts as
// summarized (its turn_summaries row exists).
std::string sql =
"SELECT ss.guid "
"FROM sessions ss "
"WHERE EXISTS ("
" SELECT 1 FROM turn_summaries ts "
" JOIN turns t ON t.turn_id = ts.turn_id "
" WHERE ts.session_id = ss.session_id " +
pending_episode_l2_conditions(
"COALESCE((SELECT MAX(e.span_end) FROM episodes e "
"WHERE e.session_id = ss.session_id), 0)") +
") "
" AND NOT EXISTS ("
" SELECT 1 FROM turns t2 "
" WHERE t2.session_id = ss.session_id "
" AND t2.assistant_text IS NOT NULL AND t2.assistant_text <> '' "
" AND NOT EXISTS (SELECT 1 FROM turn_summaries x "
" WHERE x.turn_id = t2.turn_id)) "
" 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;
}
std::vector Backend::Impl::turns_by_session_desc(
const std::string& session_guid, int limit) {
return turns_by_session_impl(session_guid, false, limit);
}
// L2 (turn) summaries for a session, newest-first (PHASE5 B.5: reads
// turn_summaries directly â the summaries.level='turn' rows this used to
// query have been empty since 0.12).
std::vector Backend::Impl::turn_summaries_by_session_desc(
const std::string& session_guid, int limit) {
std::vector out;
if (session_guid.empty()) return out;
std::string sql =
"SELECT ts.turn_summary_id, ts.text, '', "
" datetime(ts.turn_datetime,'unixepoch','localtime') "
"FROM turn_summaries ts "
"JOIN sessions ss ON ts.session_id = ss.session_id "
"WHERE ss.guid = ? AND ts.text != '' "
"ORDER BY ts.turn_datetime DESC, ts.turn_summary_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)});
}
return out;
}
// This session's episodes, newest-first (span_start DESC). Timestamp
// carries the rendered span_end (the episode's "when it ended").
std::vector Backend::Impl::episodes_by_session_desc(
const std::string& session_guid, int limit) {
std::vector out;
if (session_guid.empty()) return out;
std::string sql =
"SELECT e.episode_id, e.text, '', "
" datetime(e.span_end,'unixepoch','localtime') "
"FROM episodes e "
"JOIN sessions ss ON e.session_id = ss.session_id "
"WHERE ss.guid = ? AND e.text != '' "
"ORDER BY e.span_start DESC, e.episode_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)});
}
return out;
}
// Exact-match existence check for import idempotency: same text +
// same created_at timestamp already present among knowledge notes.
// Used by the importers so re-running an import (or feeding overlapping
// exports) doesn't duplicate rows. Normalizes `text` the same way the
// note write path does (strip the Decision/Design-number label, trim,
// then 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::note_exists_exact(const std::string& text, const std::string& created_at) {
std::string t = normalize_path(strip_decision_number_prefix(text));
// created_at is an INTEGER epoch; binding the "%F %T" string never
// matched (SQLite does no implicit conversion), so the probe was
// always false and every re-import duplicated rows. Use the same
// resolve_epoch the store path uses, so probe and insert agree.
Stmt s(db, "SELECT 1 FROM knowledge WHERE kind = 'note' AND text = ? AND created_at = ? LIMIT 1");
s.bind(1, t);
s.bind(2, resolve_epoch(created_at));
return s.step();
}
// Mirrors note_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 knowledge WHERE kind = 'decision' AND text = ? AND created_at = ? LIMIT 1");
s.bind(1, t);
s.bind(2, resolve_epoch(created_at)); // INTEGER epoch, see note_exists_exact
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) {
// created_at is an INTEGER epoch â compare against resolved integer
// bounds (the old datetime(?,?) form compared TEXT to INTEGER and
// never matched).
const int64_t ep = resolve_epoch(ts);
Stmt s(db,
"SELECT 1 FROM turns WHERE user_text = ? "
"AND created_at BETWEEN ? AND ? LIMIT 1");
s.bind(1, user_text);
s.bind(2, ep - window_seconds);
s.bind(3, ep + window_seconds);
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;
// Bounds are DERIVED here, not touched: a timestamp move can leave
// the old span stale in either direction, so both affected sessions
// get a full MIN/MAX recompute from their remaining turns. A session
// left with no turns goes back to NULL/NULL.
auto recompute_span = [&](int sid) {
if (sid <= 0) return;
Stmt q(db, "SELECT MIN(created_at), MAX(created_at) FROM turns WHERE session_id = ?");
q.bind(1, sid);
Stmt u(db, "UPDATE sessions SET started_on = ?, ended_on = ? WHERE session_id = ?");
if (q.step() && !q.is_null(0)) {
u.bind(1, q.column_int64(0));
u.bind(2, q.column_int64(1));
} else {
u.bind_null(1);
u.bind_null(2);
}
u.bind(3, sid);
if (!u.exec())
throw std::runtime_error(std::format(
"update_turn_meta recompute (session {}): {}", sid, sqlite3_errmsg(db)));
};
int old_session_id = 0;
{
Stmt f(db, "SELECT session_id FROM turns WHERE turn_id = ?");
f.bind(1, turn_id);
if (f.step() && !f.is_null(0)) old_session_id = f.column_int(0);
}
std::string sql = "UPDATE turns SET ";
bool first = true;
int new_session_id = 0;
if (!timestamp.empty()) { sql += "created_at = ?"; first = false; }
if (!session_guid.empty()) {
if (!first) sql += ", ";
sql += "session_id = ?";
}
sql += " WHERE turn_id = ?";
Transaction tx(db);
Stmt s(db, sql);
int idx = 1;
if (!timestamp.empty()) s.bind(idx++, resolve_epoch(timestamp)); // INTEGER epoch
if (!session_guid.empty()) {
new_session_id = get_or_create_session(session_guid);
s.bind(idx++, new_session_id);
}
s.bind(idx, turn_id);
const bool changed = s.exec() && sqlite3_changes(db) > 0;
if (changed) {
recompute_span(old_session_id);
if (new_session_id && new_session_id != old_session_id)
recompute_span(new_session_id);
}
tx.commit();
return changed;
}
// Recipe ingredients (issue #23): current decisions â fetched by recency
// (not semantic search) for the default tiered payload. Returned
// newest-first. (PHASE8: the old recent-summary fetch is gone with its
// table; recipes read episodes since PHASE5.)
std::vector<:string> Backend::Impl::current_decisions(int limit) {
std::vector<:string> out;
if (limit <= 0) return out;
Stmt s(db,
"SELECT text FROM knowledge WHERE kind = 'decision' AND status = 'current' "
"ORDER BY created_at DESC, knowledge_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 knowledge SET status = ? WHERE kind = 'decision' AND knowledge_id = ?");
s.bind(1, status).bind(2, decision_id);
bool ok = s.exec() && sqlite3_changes(db) > 0;
if (ok) invalidate_knowledge_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 knowledge WHERE kind = 'decision' AND status = ? "
"ORDER BY created_at DESC, knowledge_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;
}
// PHASE8: the old summary text/tag update paths are gone.
// Their only live callers were the draft path (removed in PHASE5) and the
// public facade; honest history wins â text is never rewritten in place
// (store a new row with supersedes), and tags/status change through
// agent_update on knowledge:.
} // namespace ragger::sqlite