/**
* RaggerMemory - high-level facade implementation
*/
#include "memory.h"
#include "sqlite/backend.h"
#include "embedder.h"
#include "config.h"
#include "util/fs.h"
#include "Logger.h"
#include "lang.h"
#include "recipe.h"
#include "summarizer.h"
#include "vector_codec.h"
#ifdef RAGGER_STATS
#include "stats_logger.h"
#endif
#include
#include
#include
#include
#include
#include
#include
#include
namespace ragger {
using sqlite::Backend;
RaggerMemory::RaggerMemory(const std::string& db_path,
bool skip_embedding_guard)
{
if (db_path.empty()) {
throw std::invalid_argument("RaggerMemory: db_path must not be empty");
}
// Model directory always comes from config â hardcoded relative to
// ragger_base_dir() (see config.cpp), same as every other Ragger path.
// Engine choice: "external" uses a remote /v1/embeddings endpoint;
// "internal" (default) loads an ONNX model from the models directory.
// A bad embedding-model configuration must NOT stop the daemon. Ragger's
// job is to record conversations; losing vector search is a degradation,
// losing capture is data loss. So on failure we log a loud, actionable
// error and fall back to a disabled Embedder: turns keep being recorded,
// their embedding column is left NULL, and the housekeeping backfill
// re-embeds them the moment the configuration is corrected.
// Ragger-managed ONNX models live at ~/.ragger/models///,
// so a valid internal model name is always "provider/model". The dashboard
// only ever offers paths that exist in that layout, so a bare name means
// the value was hand-edited. Diagnose it explicitly â the generic load
// failure below would just report a missing directory â but never repair
// it by assuming a provider: a wrong guess resolves to a real model that
// is not the one whose vectors are in the DB.
if (config().embedding_engine != "external") {
const std::string& m = config().embedding_model;
if (!m.empty() && m.find('/') == std::string::npos) {
Diskerror::Logger::critical(std::format(
lang::ERR_EMBED_MODEL_NO_PROVIDER, m, m));
}
}
try {
if (config().embedding_engine == "external") {
embedder_ = std::make_unique(
config().embedding_external_host,
config().embedding_external_port,
config().embedding_external_model,
config().embedding_external_api_key,
config().embedding_dimensions);
} else {
std::string resolved_model_dir = config().resolved_model_dir();
embedder_ = std::make_unique(resolved_model_dir);
}
} catch (const std::exception& e) {
Diskerror::Logger::critical(std::format(
lang::ERR_EMBED_MODEL_UNUSABLE,
config().embedding_model,
config().resolved_model_dir(), e.what()));
embedder_ = std::make_unique(); // disabled
embeddings_degraded_ = true;
}
// The resolved path â used for both the storage backend and the user store.
const std::string resolved_db = expand_path(db_path);
backend_ = std::make_unique<:backend>(*embedder_, resolved_db);
user_store_ = std::make_unique(resolved_db);
// NOTE: no legacy "prepend a provider" migration here. A stored value that
// is not in provider/model form is reported (above) and left alone; the
// drift guard below then degrades to text-only search rather than
// re-embedding the corpus against a model nobody chose.
// --- Embedding identity drift guard (model + dtype + dimensions) -------
// The settings table records what built this DB. On startup we compare it
// to the current config; thereafter the stored values are authoritative.
// - first use (no stored value) â record it
// - match â fine
// - mismatch + DB has embeddings â STOP (rebuild or revert the value)
// - mismatch + empty DB â re-adopt the new value (nothing to
// invalidate yet)
// - skip_embedding_guard (rebuild) â don't touch settings; the
// rebuild path rewrites them after
// re-encoding (so an aborted rebuild
// leaves settingsâ config and the
// guard still catches it next time).
const std::string current_model = config().embedding_model;
const std::string current_vtype =
vector_codec::canonical(config().embedding_vector_type);
const std::string current_dims = std::to_string(config().embedding_dimensions);
if (!skip_embedding_guard) {
const bool has_data = backend_->has_embeddings();
auto guard = [&](const char* key, const std::string& current,
const std::string& err) {
auto stored = user_store_->get_setting(key);
if (!stored.has_value() || *stored == current) {
if (!stored.has_value()) user_store_->set_setting(key, current);
return;
}
if (has_data) throw std::runtime_error(err);
user_store_->set_setting(key, current); // empty DB: re-adopt
};
try {
guard("embedding_model", current_model,
std::format(lang::ERR_EMBEDDING_MISMATCH,
user_store_->get_setting("embedding_model").value_or(""), current_model));
guard("vector_type", current_vtype,
std::format(lang::ERR_VECTOR_TYPE_MISMATCH,
user_store_->get_setting("vector_type").value_or(""), current_vtype));
guard("dimensions", current_dims,
std::format(lang::ERR_DIMENSIONS_MISMATCH,
user_store_->get_setting("dimensions").value_or(""), current_dims));
} catch (const std::runtime_error& e) {
// Degrade gracefully: log the error and disable vector search.
// The custom terms index still works.
Diskerror::Logger::error(std::format(
"Embedding drift detected â vector search disabled, "
"falling back to custom terms index search only. {}", e.what()));
embeddings_degraded_ = true;
}
}
// NOTE: embedding backfill no longer runs here. A large backfill (e.g.
// after a blob-format or version change touching every row) used to run
// synchronously in this constructor and could block daemon startup for
// minutes â long enough that deploy health checks reported failure while
// the daemon was actually fine. Backfill is now owned by the server's
// housekeeping tick (background, ~60s after start). CLI paths that need
// it immediately use `ragger re-embed`.
#ifdef RAGGER_STATS
// Opt-in retrieval instrumentation. Construction never throws into the
// caller; if the stats DB can't be opened the logger disables itself.
stats_ = std::make_unique();
#endif
}
RaggerMemory::~RaggerMemory() {
close();
}
std::vector RaggerMemory::turns_by_session_desc(
const std::string& session_guid, int limit) {
return backend_->turns_by_session_desc(session_guid, limit);
}
std::optional<:string> RaggerMemory::get_setting(const std::string& key) {
return user_store_->get_setting(key);
}
void RaggerMemory::set_setting(const std::string& key, const std::string& value) {
user_store_->set_setting(key, value);
}
std::string RaggerMemory::store(const std::string& text, json metadata,
bool defer_embedding) {
return backend_->store(text, std::move(metadata), defer_embedding);
}
int RaggerMemory::store_document(const DocumentChunk& chunk, bool defer_embedding) {
return backend_->store_document(chunk, defer_embedding);
}
int RaggerMemory::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) {
return backend_->store_turn(user_text, assistant_text, model_name,
defer_embedding, session_guid, source_timestamp,
session_name, name_source);
}
bool RaggerMemory::finalize_turn(int turn_id, const std::string& assistant_text,
const std::string& model_name) {
return backend_->finalize_turn(turn_id, assistant_text, model_name);
}
bool RaggerMemory::update_document_embedding(int document_id,
const std::vector& emb) {
return backend_->update_document_embedding(document_id, emb);
}
int RaggerMemory::store_decision(const std::string& text,
const std::string& status,
const std::string& tags,
const std::string& source_timestamp,
bool defer_embedding) {
return backend_->store_decision(text, status, tags, source_timestamp, defer_embedding);
}
KnowledgeResult RaggerMemory::store_knowledge(const KnowledgeInput& in) {
return backend_->store_knowledge(in);
}
json RaggerMemory::list_document_sources() {
return backend_->list_document_sources();
}
json RaggerMemory::delete_document_source(int document_source_id) {
return backend_->delete_document_source(document_source_id);
}
json RaggerMemory::find_document_source(const std::string& path) {
return backend_->find_document_source(path);
}
std::vector RaggerMemory::agent_search(const AgentSearchQuery& q) {
return backend_->agent_search(q);
}
json RaggerMemory::agent_get(const AgentGetQuery& q) {
return backend_->agent_get(q);
}
json RaggerMemory::agent_timeline(const TimelineQuery& q) {
return backend_->agent_timeline(q);
}
json RaggerMemory::agent_update(const UpdateRequest& req) {
return backend_->agent_update(req);
}
json RaggerMemory::agent_review(const std::string& action,
const std::vector<:string>& ids) {
return backend_->agent_review(action, ids);
}
std::vector<:pair>> RaggerMemory::possible_supersedes_for_text(
const std::string& text) {
return backend_->possible_supersedes_for_text(text);
}
bool RaggerMemory::update_decision_embedding(int decision_id,
const std::vector& emb) {
return backend_->update_decision_embedding(decision_id, emb);
}
bool RaggerMemory::update_turn_embedding(int turn_id,
const std::vector& emb) {
return backend_->update_turn_embedding(turn_id, emb);
}
int RaggerMemory::cleanup_old_conversations(float max_age_hours) {
return backend_->cleanup_old_conversations(max_age_hours);
}
int RaggerMemory::store_note(const std::string& text, const std::string& tags,
const std::string& source_timestamp,
const std::string& model_name,
const std::string& session_guid) {
KnowledgeInput in;
in.kind = "note";
in.text = text;
in.tags = tags.empty() ? "imported" : tags + ",imported";
in.source_timestamp = source_timestamp;
in.model_name = model_name;
in.session_guid = session_guid;
return backend_->store_knowledge(in).id;
}
std::vector<:string> RaggerMemory::current_decisions(int limit) {
return backend_->current_decisions(limit);
}
bool RaggerMemory::set_decision_status(int decision_id, const std::string& status) {
return backend_->set_decision_status(decision_id, status);
}
std::vector<:string> RaggerMemory::decisions_by_status(const std::string& status, int limit) {
return backend_->decisions_by_status(status, limit);
}
bool RaggerMemory::note_exists_exact(const std::string& text, const std::string& created_at) {
return backend_->note_exists_exact(text, created_at);
}
bool RaggerMemory::decision_exists_exact(const std::string& text, const std::string& created_at) {
return backend_->decision_exists_exact(text, created_at);
}
bool RaggerMemory::turn_exists_fuzzy(const std::string& user_text, const std::string& ts,
int window_seconds) {
return backend_->turn_exists_fuzzy(user_text, ts, window_seconds);
}
std::optional RaggerMemory::find_turn_by_text(const std::string& user_text) {
return backend_->find_turn_by_text(user_text);
}
bool RaggerMemory::update_turn_meta(int turn_id, const std::string& timestamp,
const std::string& session_guid) {
return backend_->update_turn_meta(turn_id, timestamp, session_guid);
}
// PHASE8: update_text is gone (text is never rewritten in place).
SearchResponse RaggerMemory::search(const std::string& query,
int limit,
float min_score,
std::vector<:string> collections) {
// While a re-embed is in progress the vectors are being rewritten, so a
// vector query would mix old/new spaces or hit half-updated rows. Fall
// back to text-only search (custom terms index) exactly like the
// degraded path below, rather than returning a stub record â callers
// (MCP clients especially) get real, if unranked, results instead of a
// single unusable row.
if (is_reembedding() || repair_pending()) {
SearchResponse busy = backend_->search_text_only(query, limit);
if (!busy.timing.is_object()) busy.timing = json::object();
busy.timing["reembedding"] = true;
return busy;
}
// When embeddings are degraded (drift mismatch at startup), fall back to
// text-only search (custom terms index). Vector similarity is
// unavailable but lookups still work.
if (embeddings_degraded_ || !embedder_->ready()) {
return backend_->search_text_only(query, limit);
}
SearchResponse resp = backend_->search(query, limit, min_score, std::move(collections));
#ifdef RAGGER_STATS
if (stats_ && stats_->enabled()) {
double elapsed = -1.0;
try {
if (resp.timing.contains("total_ms") && resp.timing["total_ms"].is_number())
elapsed = resp.timing["total_ms"].get();
} catch (...) {}
stats_->log_lookup(query, limit, min_score, resp.results, elapsed);
}
#endif
return resp;
}
int RaggerMemory::count() const {
return backend_->count();
}
int RaggerMemory::count_embeddable_rows() const {
return backend_->count_embeddable_rows();
}
std::vector RaggerMemory::load_all(const std::string& collection) {
return backend_->load_all(collection);
}
int RaggerMemory::rebuild_embeddings(bool progress, const std::string& table) {
return backend_->rebuild_embeddings(*embedder_, progress, table);
}
RaggerMemory::EmbeddingStatus RaggerMemory::embedding_status() {
EmbeddingStatus s;
// Current = what the stored vectors are (drift-guard settings keys).
s.current_model = user_store_->get_setting("embedding_model")
.value_or(config().embedding_model);
s.current_vtype = user_store_->get_setting("vector_type")
.value_or(config().embedding_vector_type);
s.current_dims = std::stoi(user_store_->get_setting("dimensions")
.value_or(std::to_string(config().embedding_dimensions)));
s.current_engine = config().embedding_engine;
// Desired = staged config target (seeded from current when unset).
s.desired_model = config().desired_embedding_model.empty()
? s.current_model
: config().desired_embedding_model;
s.desired_vtype = config().desired_embedding_vector_type.empty()
? s.current_vtype : config().desired_embedding_vector_type;
s.desired_dims = config().desired_embedding_dimensions == 0
? s.current_dims : config().desired_embedding_dimensions;
s.desired_engine = config().desired_embedding_engine.empty()
? s.current_engine : config().desired_embedding_engine;
s.needs_update = (s.current_model != s.desired_model) ||
(s.current_vtype != s.desired_vtype) ||
(s.current_dims != s.desired_dims) ||
(s.current_engine != s.desired_engine);
s.reembedding = is_reembedding();
s.repair_pending = repair_pending();
return s;
}
bool RaggerMemory::embeddings_unavailable() const {
return !embedder_ || !embedder_->ready();
}
bool RaggerMemory::try_recover_embeddings() {
if (!embeddings_degraded_) return false; // nothing to recover
const std::string current_model = config().embedding_model;
const std::string current_vtype =
vector_codec::canonical(config().embedding_vector_type);
const std::string current_dims = std::to_string(config().embedding_dimensions);
auto stored_model = user_store_->get_setting("embedding_model");
auto stored_vtype = user_store_->get_setting("vector_type");
auto stored_dims = user_store_->get_setting("dimensions");
// All three must match for recovery.
if (stored_model.value_or("") != current_model ||
stored_vtype.value_or("") != current_vtype ||
stored_dims.value_or("") != current_dims) {
return false; // still mismatched
}
// If we came up with a disabled embedder (bad model path at startup), the
// identity matching again is not enough â there is still nothing that can
// embed. Build the real one now. Until this succeeds we stay degraded, so
// a config that is merely self-consistent but still wrong does not flip
// semantic search back on and start writing vectors nobody can use.
if (!embedder_->ready()) {
try {
if (config().embedding_engine == "external") {
embedder_ = std::make_unique(
config().embedding_external_host,
config().embedding_external_port,
config().embedding_external_model,
config().embedding_external_api_key,
config().embedding_dimensions);
} else {
embedder_ = std::make_unique(config().resolved_model_dir());
}
// The old Embedder object is destroyed by the assignment above;
// the backend holds a raw pointer â repoint it NOW (C §1.2).
backend_->set_embedder(*embedder_);
Diskerror::Logger::warn(std::format(
lang::MSG_EMBED_MODEL_RECOVERED, current_model));
} catch (const std::exception& e) {
Diskerror::Logger::error(std::format(
lang::ERR_EMBED_MODEL_UNUSABLE, current_model,
config().resolved_model_dir(), e.what()));
return false; // still unusable â stay degraded
}
}
// Settings match config â embeddings are valid again.
embeddings_degraded_ = false;
Diskerror::Logger::info(
"Embedding drift resolved â semantic search re-enabled.");
// Backfill any rows left without embeddings.
try {
int filled = backend_->backfill_embeddings(*embedder_);
if (filled > 0) {
Diskerror::Logger::info(
std::format(lang::MSG_BACKFILLED_EMBEDDINGS, filled));
}
} catch (const std::exception& e) {
Diskerror::Logger::warn(std::format(
"Backfill after recovery failed: {}", e.what()));
}
return true;
}
bool RaggerMemory::is_reembedding() {
auto v = user_store_->get_setting("reembedding");
if (!v.has_value() || *v != "true") return false;
// The flag is persisted so a crash mid-update is visible on restart â but
// that also means a killed process leaves it set forever, wedging search
// (which short-circuits on it) and /embedding/update (which 409s on it).
// Nothing else clears it, so self-heal here: the writer records its PID
// alongside the flag, and a flag whose owner is gone is stale by
// definition. Checking the PID rather than clearing unconditionally at
// startup matters because several processes share the DB (daemon, CLI,
// `ragger mcp`) â an MCP subprocess starting up must not clear a flag the
// daemon legitimately holds.
auto owner = user_store_->get_setting("reembedding_pid");
if (owner.has_value() && !owner->empty()) {
pid_t pid = 0;
try { pid = static_cast(std::stol(*owner)); } catch (...) { pid = 0; }
if (pid > 0 && pid != ::getpid()) {
// kill(pid, 0) succeeds for a live process we own; EPERM means it
// exists but belongs to someone else â also "alive".
if (::kill(pid, 0) == 0 || errno == EPERM) return true;
} else if (pid == ::getpid()) {
return true; // this process is the one doing the work
}
}
Diskerror::Logger::error(std::format(
lang::WARN_REEMBED_STALE_FLAG, owner.value_or("")));
user_store_->set_setting("reembedding", "false");
user_store_->set_setting("reembedding_pid", "");
// The run was interrupted, so rows are half-converted. Mark the repair as
// owed: search degrades to keyword-only and housekeeping finishes the job.
user_store_->set_setting("reembed_repair_pending", "true");
return false;
}
int RaggerMemory::update_embeddings() {
// Guard: the flag is persisted so a crash mid-update is visible on
// restart, and search short-circuits for anyone reading concurrently.
// The PID goes with it so is_reembedding() can tell "in progress" from
// "the owner died and never cleared it" â see is_reembedding().
user_store_->set_setting("reembedding", "true");
user_store_->set_setting("reembedding_pid", std::to_string(::getpid()));
// Set once the identity has been promoted. After that point the old
// embedder must NOT be restored on failure â settings now name the new
// model, so putting the old one back would make the live embedder
// disagree with the recorded identity.
bool promoted = false;
std::unique_ptr previous_embedder;
try {
const std::string desired_model_name =
config().desired_embedding_model.empty()
? config().embedding_model
: config().desired_embedding_model;
const std::string desired_model_dir =
ragger_base_dir() + "/models/" + desired_model_name;
const std::string desired_engine =
config().desired_embedding_engine.empty()
? config().embedding_engine
: config().desired_embedding_engine;
// Build the embedder BEFORE touching any persisted state. A missing or
// unreadable model must fail here, with nothing promoted â promoting
// first would record an identity the daemon cannot load, and the next
// startup would throw out of the constructor and refuse to boot.
std::unique_ptr desired_embedder;
if (desired_engine == "external") {
desired_embedder = std::make_unique(
config().embedding_external_host,
config().embedding_external_port,
config().embedding_external_model,
config().embedding_external_api_key,
config().desired_embedding_dimensions > 0
? config().desired_embedding_dimensions : 0);
} else {
desired_embedder = std::make_unique(desired_model_dir);
}
Embedder& active = *desired_embedder;
const std::string vtype = vector_codec::canonical(
config().desired_embedding_vector_type.empty()
? config().embedding_vector_type
: config().desired_embedding_vector_type);
const int dims = config().desired_embedding_dimensions == 0
? active.dimensions() : config().desired_embedding_dimensions;
// Increment the embedding version so every existing blob reads stale.
backend_->increment_embedding_version();
// --- Promote the identity UP FRONT, before re-encoding a single row.
//
// This is what makes an interrupted run resumable. The rebuild is a
// "re-encode every row whose version byte is old" operation, and the
// only thing that says which model those rows should be re-encoded
// WITH is the recorded identity. Promote afterwards and an interrupted
// run leaves the DB half-converted while the settings still name the
// OLD model â so the daemon restarts on the old model and the
// housekeeping backfill happily "resumes" by writing old-model vectors
// under the new version byte. Same width, same blob format, no way to
// tell them apart afterwards: it would quietly poison every row the
// interrupted run hadn't reached yet.
//
// Promoting first also swaps the live embedder before any re-encoding,
// so turns captured *during* the rebuild land in the new space too.
//
// The safety this gives up â an aborted rebuild used to leave
// settings != config so the startup drift guard degraded to text-only
// search â is replaced by the repair_pending marker below, which
// degrades search the same way but is also actionable: housekeeping
// knows to finish the job instead of waiting for a human.
user_store_->set_setting("reembed_repair_pending", "true");
user_store_->set_setting("embedding_model", desired_model_name);
user_store_->set_setting("vector_type", vtype);
user_store_->set_setting("dimensions", std::to_string(dims));
// Write the same identity to both the settings table and the live
// config: the startup drift guard compares the stored embedding_model
// against the live one, and if they record the same model in different
// spellings it degrades. Models are stored canonically (provider/model,
// LM Studio-style) under base_dir/models, so desired_model_name is
// already the canonical form â just keep both sides symmetric.
mutable_config().embedding_model = desired_model_name;
mutable_config().embedding_vector_type = vtype;
// The backend's frozen dtype must follow the config BEFORE any
// rebuild, or rebuild writes old-dtype blobs under the new name.
backend_->set_vector_type(
vector_codec::parse(vtype).value_or(vector_codec::VectorType::F16));
mutable_config().embedding_dimensions = dims;
mutable_config().embedding_engine = desired_engine;
previous_embedder = std::exchange(embedder_, std::move(desired_embedder));
// Repoint the backend at the new Embedder before the first re-encode
// (the previous one is held in previous_embedder, still alive).
backend_->set_embedder(*embedder_);
promoted = true;
// warn, not info: the daemon's default log_level is "warn", and an
// operation that takes vector search offline for minutes should not be
// invisible at the default level. The 1000-row progress lines stay at
// info for anyone who turns the level up.
Diskerror::Logger::warn(std::format(
lang::MSG_REEMBED_STARTED,
desired_model_name, desired_engine));
int count = backend_->rebuild_embeddings(active, /*progress=*/false);
// Verify rather than trust. A rebuild that stops partway leaves rows
// at the previous embedding version â same width, same blob format,
// silently a different vector space. backfill_embeddings() re-encodes
// exactly those rows, so a non-zero return means the main pass came up
// short and we just repaired it. Say so loudly; a quiet partial
// rebuild is what made this failure so hard to see.
int repaired = backend_->backfill_embeddings(active);
if (repaired > 0) {
Diskerror::Logger::warn(std::format(
lang::WARN_REEMBED_REPAIRED, repaired));
count += repaired;
}
user_store_->set_setting("reembed_repair_pending", "false");
user_store_->set_setting("reembedding", "false");
user_store_->set_setting("reembedding_pid", "");
Diskerror::Logger::warn(std::format(
lang::MSG_REEMBED_FINISHED, count,
desired_model_name, vtype, dims));
return count;
} catch (...) {
// Only unwind the embedder if we never promoted. Once promoted, the
// new model IS the recorded identity and repair_pending stays set so
// housekeeping finishes the remaining rows.
if (!promoted) {
if (previous_embedder) {
embedder_ = std::move(previous_embedder);
backend_->set_embedder(*embedder_); // restore the binding too
}
user_store_->set_setting("reembed_repair_pending", "false");
}
user_store_->set_setting("reembedding", "false");
user_store_->set_setting("reembedding_pid", "");
try { throw; }
catch (const std::exception& e) {
Diskerror::Logger::critical(std::format(lang::ERR_REEMBED_FAILED, e.what()));
}
catch (...) {
Diskerror::Logger::critical(lang::ERR_REEMBED_FAILED_UNKNOWN);
}
throw;
}
}
bool RaggerMemory::repair_pending() {
auto v = user_store_->get_setting("reembed_repair_pending");
return v.has_value() && *v == "true";
}
int RaggerMemory::resume_interrupted_reembed() {
if (!repair_pending() || is_reembedding()) return 0;
// Resume, don't restart. Every row the interrupted run finished already
// carries the current version byte; backfill_embeddings() re-encodes only
// the ones that don't, so the work left is exactly the work that remains.
// The identity was promoted before the rebuild began, so embedder_ is
// already the right model to finish with.
Diskerror::Logger::warn(lang::MSG_REEMBED_RESUMING);
int total = 0;
// backfill is idempotent and converges: the first pass fixes the stale
// rows, a second confirms none are left. Loop rather than wait for the
// next housekeeping tick, but bound it so a row that somehow never
// stabilises can't spin forever.
constexpr int kMaxPasses = 5;
for (int pass = 0; pass < kMaxPasses; ++pass) {
int n = backend_->backfill_embeddings(*embedder_);
total += n;
if (n == 0) {
user_store_->set_setting("reembed_repair_pending", "false");
Diskerror::Logger::warn(std::format(
lang::MSG_REEMBED_RESUME_DONE, total));
return total;
}
}
Diskerror::Logger::error(std::format(
lang::ERR_REEMBED_RESUME_STUCK, kMaxPasses, total));
return total;
}
int RaggerMemory::backfill_embeddings() {
return backend_->backfill_embeddings(*embedder_);
}
std::vector<:string> RaggerMemory::collections() const {
return backend_->collections();
}
bool RaggerMemory::delete_memory(int memory_id) {
return backend_->delete_memory(memory_id);
}
int RaggerMemory::delete_batch(const std::vector& memory_ids) {
return backend_->delete_batch(memory_ids);
}
std::vector RaggerMemory::search_by_metadata(const json& metadata_filter, int limit,
const std::string& after,
const std::string& before) {
return backend_->search_by_metadata(metadata_filter, limit, after, before);
}
void RaggerMemory::close() {
if (backend_) backend_->close();
}
CaptureResult capture_turn(RaggerMemory& memory,
const std::string& user,
const std::string& assistant,
const std::string& model,
const std::string& session_id,
const std::string& session_name,
const std::string& name_source) {
// Gate: turn capture is opt-in. Agent-driven store/search are unaffected.
if (!config().capture_turns) return {false, -1, {}};
// Strip any leading Hermes system-injected annotation (interruption
// notices, model-switch notes, compaction handoffs, bg-process reports)
// from the user side at capture time, so the raw `turns` row stores only
// what the human actually typed. "[System note: ...]\n\nstop" â "stop";
// a note with no trailing message â "". This is the single capture entry
// point for both HTTP /turn and the MCP capture_turn tool.
const std::string clean_user = strip_system_injected_prefix(user);
// Skip empty pushes. A turn needs real content on at least one side: keep
// assistant-only turns (interrupted turn the agent still answered), but
// drop a turn that was nothing but a system note with no assistant output.
if (clean_user.empty() && assistant.empty()) return {false, -1, {}};
int turn_id = memory.store_turn(clean_user, assistant, model,
/*defer_embedding=*/false, session_id,
/*source_timestamp=*/"",
session_name, name_source);
return {true, turn_id, clean_user};
}
namespace {
// Process-local cache of loaded recipes. Loaded lazily on first call;
// re-loaded when the config's recipes_dir changes (covers SIGHUP).
struct RecipeCache {
std::mutex mu;
std::vector recipes;
std::string loaded_dir; // path the cache was built from
bool loaded = false;
};
RecipeCache& recipe_cache() {
static RecipeCache c;
return c;
}
const std::vector& cached_recipes() {
auto& c = recipe_cache();
std::lock_guard<:mutex> lk(c.mu);
const std::string dir = config().resolved_recipes_dir();
if (!c.loaded || c.loaded_dir != dir) {
c.recipes = load_recipes_from_dir(dir);
if (c.recipes.empty()) c.recipes = builtin_recipes();
c.loaded_dir = dir;
c.loaded = true;
}
return c.recipes;
}
// Format a raw turn into "User: ...\n\nAssistant: ..." for the chunk text.
std::string format_raw_turn(const TurnRecord& t) {
return "User: " + t.user_text + "\n\nAssistant: " + t.assistant_text;
}
// Approximate token cost using the recipe's chars_per_token. Cheap and
// good enough for ceiling enforcement; precise tokenization would tie
// us to a specific model's tokenizer.
int approx_tokens(const std::string& s, float chars_per_token) {
if (chars_per_token <= 0) chars_per_token = 4.0f;
return static_cast(static_cast(s.size()) / chars_per_token);
}
} // namespace
SessionContext build_context(RaggerMemory& memory,
const std::string& session_id,
const std::string& recipe_name) {
// Gate: the read side requires both capture (something to read) and the
// build-context toggle. Off â enabled=false, no chunks.
if (!config().capture_turns || !config().build_context) {
return {false, "", {}};
}
const auto& recipes = cached_recipes();
// Resolve the effective default:
// 1. DB `settings.recipe` (set by `ragger recipe`). The sentinel
// value "default" means "track the configured default" â
// equivalent to having no row.
// 2. The configured `default_recipe` (compiled-in default, overlaid
// by the `settings` table).
// 3. First built-in (last-resort fallback).
// One keyed read per build_context call â cheap, and the user's
// choice takes effect without a daemon restart.
std::string effective_default = config().default_recipe;
if (auto stored = memory.get_setting("recipe");
stored && !stored->empty() && *stored != "default") {
effective_default = *stored;
}
// Resolve recipe: explicit > effective default > first built-in.
const Recipe* recipe = find_recipe(recipes, recipe_name);
if (!recipe) recipe = find_recipe(recipes, effective_default);
if (!recipe && !recipes.empty()) recipe = &recipes.front();
if (!recipe) return {true, "", {}};
SessionContext out{true, recipe->name, {}};
if (session_id.empty()) return out;
auto* backend = memory.backend();
if (!backend) return out;
// The walk: turns + L2 summaries newest-first. raw_turn / turn_summary
// layers consume from these in chronological order (newest popped
// first). Other layers (session/project/decisions) pull recent rows
// independent of the walk.
auto turns_desc = backend->turns_by_session_desc(session_id, 0);
auto turn_summaries = backend->turn_summaries_by_session_desc(session_id, 0);
// Walk pointers â index into the newest-first arrays. Turn summaries
// for raw turns already consumed are skipped via the timestamp
// overlap check (the chronology link).
size_t walk_turn = 0;
size_t walk_summary = 0;
// Collect chunks newest-first; we reverse at the end so the agent
// reads chronologically.
std::vector rev;
auto consumed_turn_ts = std::vector<:string>{}; // for overlap skip
for (const auto& layer : recipe->layers) {
switch (layer.kind) {
case LayerKind::RawTurn: {
int taken = 0;
while (walk_turn < turns_desc.size() &&
(layer.limit == 0 || taken < layer.limit)) {
const auto& t = turns_desc[walk_turn++];
rev.push_back({"raw_turn", format_raw_turn(t), t.timestamp});
consumed_turn_ts.push_back(t.timestamp);
++taken;
}
break;
}
case LayerKind::TurnSummary: {
int taken = 0;
while (walk_summary < turn_summaries.size() &&
(layer.limit == 0 || taken < layer.limit)) {
const auto& s = turn_summaries[walk_summary++];
// Skip a summary whose source turn was already
// emitted raw â chronology link by timestamp.
if (std::find(consumed_turn_ts.begin(),
consumed_turn_ts.end(),
s.timestamp) != consumed_turn_ts.end()) {
continue;
}
rev.push_back({"turn_summary", s.text, s.timestamp});
// Also advance the raw-turn walk past this turn's
// timestamp so a later RawTurn layer doesn't re-emit
// it. (Layers can appear in any order; preserve the
// chronological exclusion in both directions.)
while (walk_turn < turns_desc.size() &&
turns_desc[walk_turn].timestamp >= s.timestamp) {
consumed_turn_ts.push_back(
turns_desc[walk_turn].timestamp);
++walk_turn;
}
++taken;
}
break;
}
case LayerKind::Episode: {
// This session's episodes, newest-first (PHASE5 D.3).
int wanted = layer.limit > 0 ? layer.limit : 1;
int taken = 0;
for (const auto& e : backend->episodes_by_session_desc(session_id, wanted)) {
rev.push_back({"episode", e.text, e.timestamp});
++taken;
}
break;
}
case LayerKind::ProjectSummary: {
// Parses for compatibility with user recipe copies; emits
// nothing â the project rollup layer was removed at 0.17.
break;
}
case LayerKind::Decisions: {
int wanted = layer.limit > 0 ? layer.limit : 3;
for (const auto& text : backend->current_decisions(wanted)) {
rev.push_back({"decision", text, ""});
}
break;
}
case LayerKind::GeneralSearch: {
// Session-AGNOSTIC relevance pass: surface the most relevant
// context from anywhere in the corpus (all summaries +
// documents) that earlier session-scoped layers did NOT
// already emit. "Insight by association."
int wanted = layer.limit > 0 ? layer.limit : 5;
// Query = current session's latest user turn + up to the 2
// most recent turn summaries, concatenated into one string.
std::string query;
if (!turns_desc.empty() && !turns_desc[0].user_text.empty()) {
query += turns_desc[0].user_text;
}
for (size_t i = 0; i < turn_summaries.size() && i < 2; ++i) {
if (turn_summaries[i].text.empty()) continue;
if (!query.empty()) query += "\n";
query += turn_summaries[i].text;
}
if (query.empty()) break; // nothing to search with â skip
// Over-fetch so that after dedup we still have enough to emit
// `wanted` survivors. K is a small buffer.
const int K = 8;
auto resp = memory.search(query, wanted + K, 0.0f, {});
// Collect survivors best-first. Dedup against text already
// pushed by earlier layers into `rev`.
std::vector survivors;
for (const auto& r : resp.results) {
if (static_cast(survivors.size()) >= wanted) break;
bool dup = false;
for (const auto& c : rev) {
if (c.text == r.text) { dup = true; break; }
}
if (dup) continue;
survivors.push_back(r);
}
// ORDERING: `rev` is built newest-first and the final step
// assigns out.chunks = rev.rbegin()..rev.rend(), i.e. the
// order within rev is FLIPPED. We want the general_search
// block to read best-first in the final output, so push its
// results worst-first into rev (the flip then yields
// best-first). survivors is best-first, so iterate it in
// reverse when pushing.
for (auto it = survivors.rbegin(); it != survivors.rend(); ++it) {
rev.push_back({"general_search", it->text, it->timestamp});
}
break;
}
}
}
// Apply max_tokens by dropping from the *oldest* end (the back of
// rev, since rev is newest-first). This keeps the most recent
// context when we have to trim.
if (recipe->max_tokens > 0) {
int total = 0;
size_t kept = 0;
for (; kept < rev.size(); ++kept) {
total += approx_tokens(rev[kept].text, recipe->chars_per_token);
if (total > recipe->max_tokens) break;
}
if (kept < rev.size()) rev.resize(kept);
}
// Reverse to chronological (oldest-first) for the prompt-friendly
// output. Within a layer the order matches the newest-first walk
// and gets reversed; across layers the recipe author's intended
// ordering is preserved (because earlier layers pushed first).
out.chunks.assign(rev.rbegin(), rev.rend());
return out;
}
} // namespace ragger