/**
* Import utilities â heading-aware paragraph chunking and conversation /
* summary ingest from external sources.
*/
#include "import.h"
#include "nlohmann_json.hpp"
#include "embed_executor.h"
#include "lang.h"
#include "memory.h"
#include "storage_types.h"
#include "util/fs.h"
#include "util/time.h"
#include "Logger.h"
#include
#include
#include
#include
#include
#include
#include
#include
namespace ragger {
using json = nlohmann::json;
namespace fs = std::filesystem;
int heading_level(const std::string& line) {
int level = 0;
while (level < 6 && level < (int)line.size() && line[level] == '#') ++level;
if (level > 0 && level < (int)line.size() && line[level] == ' ') return level;
return 0;
}
std::string heading_text(const std::string& line) {
auto pos = line.find(' ');
return (pos != std::string::npos) ? line.substr(pos + 1) : line;
}
std::vector chunk_markdown(const std::string& raw_text, int min_chunk_size) {
// Clean the text
std::string text = raw_text;
// Strip base64 image data
text = std::regex_replace(text, std::regex(R"(!\[[^\]]*\]\(data:[^)]+\))"), "");
text = std::regex_replace(text, std::regex(R"(data:image/[^;]+;base64,[A-Za-z0-9+/=]+)"), "");
// Collapse multi-space artifacts per line
std::istringstream lines_stream(text);
std::string line;
std::string cleaned;
while (std::getline(lines_stream, line)) {
line = std::regex_replace(line, std::regex(R"( +)"), " ");
cleaned += line + "\n";
}
text = std::regex_replace(cleaned, std::regex(R"(\n{3,})"), "\n\n");
// Split on paragraph boundaries
std::vector<:string> paragraphs;
{
std::istringstream ss(text);
std::string buffer;
while (std::getline(ss, line)) {
if (line.empty() || (line.size() == 1 && line[0] == '\r')) {
if (!buffer.empty()) {
while (!buffer.empty() && (buffer.back() == '\n' || buffer.back() == '\r' || buffer.back() == ' '))
buffer.pop_back();
paragraphs.push_back(buffer);
buffer.clear();
}
} else {
if (!buffer.empty()) buffer += "\n";
buffer += line;
}
}
if (!buffer.empty()) {
while (!buffer.empty() && (buffer.back() == '\n' || buffer.back() == '\r' || buffer.back() == ' '))
buffer.pop_back();
paragraphs.push_back(buffer);
}
}
// Heading-aware chunking
struct HeadingEntry { int level; std::string text; };
std::vector heading_stack;
struct Annotated { std::string body; std::string heading_block; std::string section; };
std::vector annotated;
auto current_section = [&]() -> std::string {
std::string s;
for (auto& h : heading_stack) {
if (!s.empty()) s += " \xC2\xBB "; // UTF-8 »
s += h.text;
}
return s;
};
auto current_heading_block = [&]() -> std::string {
std::string s;
for (auto& h : heading_stack) {
if (!s.empty()) s += "\n\n";
s += std::string(h.level, '#') + " " + h.text;
}
return s;
};
for (auto& para : paragraphs) {
int level = heading_level(para);
if (level > 0) {
while (!heading_stack.empty() && heading_stack.back().level >= level)
heading_stack.pop_back();
heading_stack.push_back({level, ragger::heading_text(para)});
} else {
annotated.push_back({para, current_heading_block(), current_section()});
}
}
// Merge short paragraphs into chunks
std::vector chunks;
std::string current;
std::string current_sec;
for (auto& a : annotated) {
if (current.empty()) {
current = a.heading_block.empty() ? a.body : (a.heading_block + "\n\n" + a.body);
current_sec = a.section;
} else if ((int)current.size() >= min_chunk_size) {
chunks.push_back({current, current_sec});
current = a.heading_block.empty() ? a.body : (a.heading_block + "\n\n" + a.body);
current_sec = a.section;
} else {
if (a.section != current_sec && !a.heading_block.empty()) {
current += "\n\n" + a.heading_block + "\n\n" + a.body;
} else {
current += "\n\n" + a.body;
}
current_sec = a.section;
}
}
if (!current.empty()) {
chunks.push_back({current, current_sec});
}
return chunks;
}
/// Replace horizontal whitespace runs (including tabs, NBSP, other Unicode
/// spaces) with a single regular space. Also removes zero-width and invisible
/// characters that don't contribute to content. Works on multi-byte UTF-8 by
/// treating any byte >= 128 as part of a non-whitespace character unless it's
/// explicitly one of the known whitespace sequences.
static std::string normalize_whitespace(const std::string& s) {
// Use Unicode property class [:space:] to match all space-like characters,
// including tabs, newlines, NBSP, etc. This is portable and handles multi-byte UTF-8.
static const std::regex hspace_re(R"(\s+)");
return std::regex_replace(s.c_str(), hspace_re, " ");
}
/// Cap any run of dashes on a single line to at most 3 characters. This
/// prevents extremely long sequences (from formatted tables or separators)
/// from dominating chunks while still keeping valid Markdown table-separator
/// syntax (`---`).
static void cap_dashes(std::string& s) {
static const std::regex many_dashes(R"(-----+)"); // 5+ dashes -> replace
s = std::regex_replace(s.c_str(), many_dashes, "---");
}
std::string clean_document_text(const std::string& text) {
// Phase 1: line-level cleanup.
std::istringstream lines(text);
std::string line;
std::vector<:string> cleaned_lines;
while (std::getline(lines, line)) {
// Normalize horizontal whitespace on the line (handles tabs, NBSP, etc.).
line = normalize_whitespace(line);
// Cap long runs of dashes for MD table safety.
cap_dashes(line);
// Strip leading spaces and '#' characters, in any mix.
size_t start = line.find_first_not_of(" #");
line = (start == std::string::npos) ? std::string() : line.substr(start);
// Strip trailing spaces.
while (!line.empty() && line.back() == ' ') line.pop_back();
cleaned_lines.push_back(line);
}
// Phase 2: join all lines with exactly one newline between them.
// No blank lines â double newlines are collapsed to single.
std::string out;
bool first = true;
for (const auto& l : cleaned_lines) {
if (!first) {
out += "\n";
}
out += l;
first = false;
}
return out;
}
namespace {
// Structural/metadata keys we never want as prose â skip their string
// values entirely rather than dumping ids/timestamps/flags into the text.
bool is_noise_key(const std::string& key) {
static const std::vector<:string> noise = {
"uuid", "id", "account_uuid", "session_id", "chat_id", "user_id",
"type", "status", "sender", "role", "created_at", "updated_at",
"timestamp", "start_timestamp", "stop_timestamp", "date",
"is_private", "is_starter_project", "verified_phone_number",
"email_address", "phone_number", "attachments", "files", "citations",
"parent_message_uuid", "flags", "prompt_template"
};
std::string lower;
lower.reserve(key.size());
for (char c : key) lower.push_back(static_cast(std::tolower(static_cast(c))));
for (const auto& n : noise) if (lower == n) return true;
return false;
}
// Recursively collect string leaves worth keeping. `key` is the parent
// object key this value was found under (empty at the array/root level).
void collect_json_text(const json& node, const std::string& key,
std::vector<:string>& out) {
if (node.is_string()) {
if (!key.empty() && is_noise_key(key)) return;
std::string s = node.get<:string>();
// Skip short/opaque strings (e.g. stray uuids under unlisted keys,
// "human"/"assistant" sender labels that slipped through) â prose
// worth chunking is generally more than a few words.
if (s.size() < 3) return;
out.push_back(std::move(s));
return;
}
if (node.is_object()) {
for (auto it = node.begin(); it != node.end(); ++it) {
collect_json_text(it.value(), it.key(), out);
}
return;
}
if (node.is_array()) {
for (const auto& elem : node) collect_json_text(elem, key, out);
return;
}
// numbers/bools/null: never prose, always skip.
}
} // namespace
std::string extract_text_from_json(const std::string& text) {
json doc;
try {
doc = json::parse(text);
} catch (...) {
return ""; // not JSON â caller treats input as plain text/markdown
}
std::vector<:string> pieces;
collect_json_text(doc, "", pieces);
std::string out;
for (auto& p : pieces) {
if (!out.empty()) out += "\n\n";
out += p;
}
return out;
}
namespace {
// Local trim â the shared `trim()` helper lives further down in this file
// (used by the conversation parsers) and isn't visible yet at this point.
std::string trim_ws(std::string s) {
auto issp = [](unsigned char c) { return std::isspace(c) != 0; };
while (!s.empty() && issp((unsigned char)s.front())) s.erase(s.begin());
while (!s.empty() && issp((unsigned char)s.back())) s.pop_back();
return s;
}
// True if `line` (trimmed) is entirely wrapped in **bold** or *italic*
// markers with nothing else â i.e. it's a bare section header line like
// "**Work context**" or "*Recent months*", not prose that merely
// contains bold/italic spans. Returns the marker length (2 for **, 1 for
// *) via `marker_len`, or 0 if the line isn't a bare header.
int bare_emphasis_marker_len(const std::string& line) {
if (line.size() >= 4 && line.substr(0, 2) == "**" &&
line.substr(line.size() - 2) == "**" &&
line.find("**", 2) == line.size() - 2) {
return 2;
}
if (line.size() >= 2 && line.front() == '*' && line.back() == '*' &&
line.find('*', 1) == line.size() - 1) {
return 1;
}
return 0;
}
std::string strip_emphasis(const std::string& line, int marker_len) {
if ((int)line.size() < 2 * marker_len) return line;
return line.substr(marker_len, line.size() - 2 * marker_len);
}
} // namespace
std::vector split_memory_narrative(const std::string& text) {
std::vector out;
std::string bold_heading; // current **bold** section
std::string italic_heading; // current *italic* sub-section
auto breadcrumb = [&]() -> std::string {
if (bold_heading.empty()) return italic_heading;
if (italic_heading.empty()) return bold_heading;
return bold_heading + " \xC2\xBB " + italic_heading; // UTF-8 »
};
std::istringstream in(text);
std::string line;
std::string current;
auto flush = [&]() {
std::string t = trim_ws(current);
if (!t.empty()) out.push_back({t, breadcrumb()});
current.clear();
};
while (std::getline(in, line)) {
std::string t = trim_ws(line);
if (t.empty()) { flush(); continue; }
int mlen = bare_emphasis_marker_len(t);
if (mlen > 0) {
flush();
std::string heading = trim_ws(strip_emphasis(t, mlen));
if (mlen == 2) { bold_heading = heading; italic_heading.clear(); }
else { italic_heading = heading; }
continue;
}
if (!current.empty()) current += " ";
current += t;
}
flush();
return out;
}
bool memory_chunk_is_decision_like(const std::string& heading_path,
const std::string& text,
bool default_decision_like) {
std::string h = heading_path;
for (char& c : h) c = static_cast(std::tolower(static_cast(c)));
// Heading signal first.
static const std::vector<:string> session_headings = {
"history", "recent", "earlier", "background", "timeline"
};
static const std::vector<:string> decision_headings = {
"context", "top of mind", "preference"
};
for (auto& k : session_headings) if (h.find(k) != std::string::npos) return false;
for (auto& k : decision_headings) if (h.find(k) != std::string::npos) return true;
// Fall back to a keyword scan of the text itself.
std::string t = text;
for (char& c : t) c = static_cast(std::tolower(static_cast(c)));
static const std::vector<:string> session_verbs = {
"worked on", "explored", "set up", "asked about", "troubleshot",
"troubleshooting", "iterating", "iterated", "debugging", "investigated",
"started with", "began", "set out"
};
static const std::vector<:string> decision_verbs = {
"prefers", "preferring", "favors", "favoring", "is a ", "is an ",
"uses ", "runs a ", "works as", "has a strong interest"
};
int session_hits = 0, decision_hits = 0;
for (auto& k : session_verbs) if (t.find(k) != std::string::npos) ++session_hits;
for (auto& k : decision_verbs) if (t.find(k) != std::string::npos) ++decision_hits;
if (session_hits != decision_hits) return decision_hits > session_hits;
// No signal either way: fall back to the caller's default. For
// Claude's memories.json narrative this stays decision-like (a
// curated memory blob skews toward stable facts). For flat daily-log
// memory files this is false â a dated log entry with no other
// signal is an episode that never got a session id, not a standing
// fact, so it belongs with session summaries.
return default_decision_like;
}
std::string extract_date_from_filename(const std::string& filename) {
static const std::regex date_re(R"(^(\d{4}-\d{2}-\d{2}))");
std::smatch m;
if (std::regex_search(filename, m, date_re)) return m[1].str();
return "";
}
bool is_agent_scaffolding_filename(const std::string& filename) {
std::string lower = filename;
for (char& c : lower) c = static_cast(std::tolower(static_cast(c)));
static const std::vector<:string> scaffolding = {
"soul.md", "user.md", "agents.md", "tools.md",
"identity.md", "heartbeat.md", "bootstrap.md"
};
for (auto& s : scaffolding) if (lower == s) return true;
return false;
}
std::vector parse_flat_markdown_memory(const std::string& path,
int min_chunk_size,
int min_chunk_chars) {
std::vector out;
namespace fs = std::filesystem;
fs::path p(path);
std::string filename = p.filename().string();
if (is_agent_scaffolding_filename(filename)) return out;
std::ifstream in(path, std::ios::binary);
if (!in) return out;
std::ostringstream ss;
ss << in.rdbuf();
std::string text = ss.str();
if (text.empty()) return out;
std::string date = extract_date_from_filename(filename);
if (date.empty()) {
// No date in the filename (e.g. a MEMORY.md snapshot) â fall back
// to the file's mtime, truncated to a bare date. No time-of-day
// component: we don't actually know one, so don't invent it.
auto ftime = fs::last_write_time(path);
auto sctp = std::chrono::time_point_cast<:chrono::system_clock::duration>(
ftime - fs::file_time_type::clock::now() + std::chrono::system_clock::now());
std::time_t tt = std::chrono::system_clock::to_time_t(sctp);
char buf[16];
std::strftime(buf, sizeof(buf), "%Y-%m-%d", std::localtime(&tt));
date = buf;
}
for (auto& chunk : chunk_markdown(text, min_chunk_size)) {
if (chunk.text.empty()) continue;
// chunk_markdown() bakes heading lines (e.g. "# Session: 2026-04-13
// 21:43:46 UTC" / "## Conversation Summary") into the literal text
// â not just a leading block, but also mid-chunk whenever its
// merge pass stitches paragraphs from different sub-sections
// together. Useful for chunk_markdown's own general-purpose
// callers, but here it's pure duplication: the row's created_at
// already carries the date, and the section breadcrumb is kept
// separately below. Strip every '#'-prefixed heading line
// (anywhere in the chunk) so none of this gets embedded/FTS-
// indexed as content â repeated across every chunk in a file,
// it would dilute search.
std::string body;
body.reserve(chunk.text.size());
{
std::istringstream ls(chunk.text);
std::string line;
bool first_line = true;
while (std::getline(ls, line)) {
if (heading_level(line) > 0) continue;
if (!first_line) body += "\n";
body += line;
first_line = false;
}
}
// Collapse any now-consecutive blank lines left behind by removed
// headings, and trim.
body = std::regex_replace(body, std::regex(R"(\n{3,})"), "\n\n");
while (!body.empty() && (body.front() == '\n' || body.front() == ' '))
body.erase(body.begin());
while (!body.empty() && (body.back() == '\n' || body.back() == ' '))
body.pop_back();
if (body.empty()) continue;
if ((int)body.size() < min_chunk_chars) continue;
FlatMemoryChunk fc;
fc.text = body;
fc.heading_path = chunk.section;
fc.date = date;
fc.is_decision_like = memory_chunk_is_decision_like(chunk.section, body,
/*default_decision_like=*/false);
out.push_back(std::move(fc));
}
return out;
}
ConversationFormat detect_conversation_format(const std::string& path) {
std::ifstream in(path);
if (!in) return ConversationFormat::Unknown;
// JSONL check first: peek at the first non-blank line and see if it
// parses standalone as a Claude Code event â a whole-file json::parse
// would fail on real JSONL (multiple top-level values), so this has
// to happen before attempting the full-document parse below.
{
std::string first_line;
while (std::getline(in, first_line)) {
auto t = first_line;
size_t a = t.find_first_not_of(" \t\r\n");
if (a == std::string::npos) continue; // blank, keep scanning
t = t.substr(a);
try {
json ev = json::parse(t);
if (ev.is_object() && ev.contains("type") &&
(ev.value("type", "") == "user" || ev.value("type", "") == "assistant") &&
ev.contains("message")) {
return ConversationFormat::ClaudeCode;
}
} catch (...) {
// not a standalone-parseable line; fall through to whole-
// document JSON parse below.
}
break;
}
}
in.clear();
in.seekg(0);
json doc;
try {
in >> doc;
} catch (...) {
return ConversationFormat::Unknown;
}
// Claude web export: array of conversation objects, or {"conversations":[...]}.
json convs;
if (doc.is_array()) convs = doc;
else if (doc.is_object() && doc.contains("conversations") && doc["conversations"].is_array())
convs = doc["conversations"];
if (convs.is_array() && !convs.empty() && convs[0].is_object() &&
(convs[0].contains("chat_messages") || convs[0].contains("messages")) &&
convs[0].contains("uuid")) {
return ConversationFormat::ClaudeWeb;
}
if (doc.is_object()) {
// Telegram: single-chat export (top-level "messages") or full
// multi-chat export ("chats":{"list":[...]}).
if (doc.contains("messages") && doc["messages"].is_array()) {
return ConversationFormat::Telegram;
}
if (doc.contains("chats") && doc["chats"].is_object() &&
doc["chats"].contains("list")) {
return ConversationFormat::Telegram;
}
// Claude memories.json: single object, no message/conversation
// structure at all, just a narrative blob + account id.
if (doc.contains("conversations_memory")) {
return ConversationFormat::ClaudeMemories;
}
}
if (doc.is_array() && !doc.empty() && doc[0].is_object() &&
doc[0].contains("conversations_memory")) {
return ConversationFormat::ClaudeMemories;
}
return ConversationFormat::Unknown;
}
// =========================================================================
// Conversation parsers
// =========================================================================
namespace fs = std::filesystem;
namespace {
/// Trim ASCII whitespace from both ends.
std::string trim(std::string s) {
auto issp = [](unsigned char c) { return std::isspace(c) != 0; };
while (!s.empty() && issp((unsigned char)s.front())) s.erase(s.begin());
while (!s.empty() && issp((unsigned char)s.back())) s.pop_back();
return s;
}
/// Strip inline tool-call trace lines that some Claude clients/hooks render
/// as literal text inside an assistant text block â lines like
/// `âï¸ ragger_search: "..."` or `ð session_search: "..."`. These are
/// UI chrome for a tool invocation, not conversational content, and
/// duplicate what the actual tool_use/tool_result blocks already carry
/// (which extract_claude_text already excludes). Matched by the leading
/// glyph + "name: " shape rather than a fixed tool-name list, so it covers
/// any current or future tool without needing updates here.
std::string strip_tool_trace_lines(const std::string& text) {
static const std::regex trace_line(
R"(^[ \t]*(?:âï¸|ð|ð§|ð ï¸)[ \t]*[A-Za-z0-9_]+:.*$\n?)",
std::regex::multiline);
return std::regex_replace(text, trace_line, "");
}
/// Extract text from a Claude message body. The body may be a raw string or
/// an array of content blocks; only type=="text" blocks contribute (tool
/// blocks / thinking blocks aren't conversational and would pollute search).
std::string extract_claude_text(const json& content) {
if (content.is_string()) return trim(strip_tool_trace_lines(content.get<:string>()));
if (!content.is_array()) return "";
std::vector<:string> parts;
for (const auto& block : content) {
if (!block.is_object()) continue;
if (block.value("type", "") == "text") {
auto t = trim(strip_tool_trace_lines(block.value("text", "")));
if (!t.empty()) parts.push_back(t);
}
}
std::string joined;
for (size_t i = 0; i < parts.size(); ++i) {
if (i) joined += "\n\n";
joined += parts[i];
}
return joined;
}
/// "2024-10-15" â "2024-10-15 00:00:00" for lexical comparison against the
/// stored ts. The export timestamps are ISO-8601 (with "T" + zone); since we
/// only need <, ==, > we can compare the first 10 characters directly.
std::string date_only(const std::string& iso) {
if (iso.size() >= 10) return iso.substr(0, 10);
return iso;
}
} // anonymous
std::vector parse_claude_code(const std::string& path_str) {
std::vector out;
fs::path root(path_str);
std::vector<:path> files;
if (fs::is_directory(root)) {
for (auto& e : fs::directory_iterator(root)) {
if (e.is_regular_file() && e.path().extension() == ".jsonl") {
files.push_back(e.path());
}
}
std::sort(files.begin(), files.end());
} else {
files.push_back(root);
}
struct Pending { std::string ts, text, sid; };
for (const auto& jf : files) {
std::ifstream in(jf);
if (!in) continue;
std::string line;
std::optional pending;
const std::string default_sid = jf.stem().string();
while (std::getline(in, line)) {
line = trim(line);
if (line.empty()) continue;
json ev;
try { ev = json::parse(line); } catch (...) { continue; }
auto etype = ev.value("type", "");
if (etype != "user" && etype != "assistant") continue;
json msg = ev.value("message", json::object());
std::string text = extract_claude_text(msg.value("content", json()));
if (text.empty()) continue;
std::string ts = ev.value("timestamp", "");
std::string sid = ev.value("sessionId", default_sid);
if (etype == "user") {
if (pending) {
out.push_back({pending->ts, pending->text, "", pending->sid, "claude-code"});
}
pending = Pending{ts, text, sid};
} else { // assistant
if (pending) {
out.push_back({pending->ts, pending->text, text, pending->sid, "claude-code"});
pending.reset();
}
}
}
if (pending) {
out.push_back({pending->ts, pending->text, "", pending->sid, "claude-code"});
}
}
return out;
}
std::vector parse_claude_web(const std::string& path_str) {
std::vector out;
std::ifstream in(path_str);
if (!in) return out;
json doc;
try { in >> doc; } catch (...) { return out; }
// Some exports wrap the array in {"conversations": [...]}.
json convs;
if (doc.is_array()) convs = doc;
else if (doc.is_object() && doc.contains("conversations")) convs = doc["conversations"];
else return out;
struct Pending { std::string ts, text; };
for (const auto& conv : convs) {
if (!conv.is_object()) continue;
std::string sid = conv.value("uuid", "");
json msgs = conv.contains("chat_messages") ? conv["chat_messages"]
: conv.contains("messages") ? conv["messages"]
: json::array();
std::optional pending;
for (const auto& m : msgs) {
if (!m.is_object()) continue;
std::string sender = m.value("sender", m.value("role", ""));
std::string text;
if (m.contains("text") && m["text"].is_string()) text = trim(m.value("text", ""));
else text = extract_claude_text(m.value("content", json()));
if (text.empty()) continue;
std::string ts = m.value("created_at", m.value("timestamp", ""));
if (sender == "human" || sender == "user") {
if (pending) out.push_back({pending->ts, pending->text, "", sid, "claude-web"});
pending = Pending{ts, text};
} else if (sender == "assistant" || sender == "claude") {
if (pending) {
out.push_back({pending->ts, pending->text, text, sid, "claude-web"});
pending.reset();
}
}
}
if (pending) out.push_back({pending->ts, pending->text, "", sid, "claude-web"});
}
return out;
}
namespace {
/// Flatten a Telegram "text" field. It's either a plain string or an array
/// mixing plain strings with `{"type":..,"text":..}` entity objects (bold,
/// link, code, etc.) â we just want the concatenated text content.
std::string extract_telegram_text(const json& text_field) {
if (text_field.is_string()) return trim(text_field.get<:string>());
if (!text_field.is_array()) return "";
std::string out;
for (const auto& piece : text_field) {
if (piece.is_string()) {
out += piece.get<:string>();
} else if (piece.is_object()) {
out += piece.value("text", "");
}
}
return trim(out);
}
/// True if `text` is a bare control command that should never be stored as
/// conversation content: a slash-command (e.g. "/stop", "/model", "/reset",
/// with or without trailing arguments) or one of the bare no-slash keywords
/// users type as an interrupt ("stop", "wait" â case-insensitive, exact
/// match only, so "please wait" or "stopwatch" are NOT treated as commands).
bool is_bare_command(const std::string& text) {
if (text.empty()) return false;
if (text[0] == '/') return true;
std::string lower = text;
for (auto& c : lower) c = static_cast(std::tolower(static_cast(c)));
return lower == "stop" || lower == "wait";
}
/// Parse one chat object (has "messages"; may or may not have "name")
/// into ImportTurns, appending onto `out`. Session identity: Telegram has
/// no session concept, but the user's `/new` command messages mark exact
/// boundaries where the live agent minted a fresh session. We mint
/// synthetic GUIDs (telegram-import-0001, -0002, ...) and roll to the
/// next on every `/new`. The `/new` message itself is a command, not
/// conversation â it is dropped. Other bare commands (/stop, /model, plain
/// "stop"/"wait", etc.) are dropped too but do NOT roll the session.
void parse_telegram_chat(const json& chat, const std::string& self_name,
std::vector& out) {
if (!chat.is_object() || !chat.contains("messages")) return;
int session_no = 1;
auto session_guid = [&]() {
char buf[32];
std::snprintf(buf, sizeof(buf), "telegram-import-%04d", session_no);
return std::string(buf);
};
struct Block { bool is_self; std::string ts; std::string text; std::string sid; };
std::vector blocks;
for (const auto& m : chat["messages"]) {
if (!m.is_object()) continue;
if (m.value("type", "") != "message") continue; // skip service events
std::string text = extract_telegram_text(m.value("text", json("")));
if (text.empty()) continue;
std::string from = m.value("from", "");
bool is_self = (from == self_name);
std::string ts = m.value("date", "");
// Session boundary: a user message that is the /new command.
if (is_self && (text == "/new" || text.rfind("/new ", 0) == 0)) {
++session_no;
continue; // command, not conversation
}
// Other bare commands (/stop, /model, /reset, plain "stop"/"wait",
// etc.) are control chatter, not conversation content â drop them
// without touching session numbering.
if (is_self && is_bare_command(text)) continue;
// Merge consecutive same-sender bubbles â but never across a
// session boundary (sid mismatch breaks the merge naturally).
if (!blocks.empty() && blocks.back().is_self == is_self &&
blocks.back().sid == session_guid()) {
blocks.back().text += "\n" + text;
} else {
blocks.push_back({is_self, ts, text, session_guid()});
}
}
std::optional pending_user;
for (const auto& b : blocks) {
if (b.is_self) {
if (pending_user) {
// Two user blocks in a row shouldn't happen (merged above),
// but guard anyway: flush the stale one with no reply.
out.push_back({pending_user->ts, pending_user->text, "", pending_user->sid, "telegram"});
}
pending_user = b;
} else {
if (pending_user) {
// A reply from a different session than its prompt means a
// /new landed between them â don't pair across it.
if (pending_user->sid == b.sid) {
out.push_back({pending_user->ts, pending_user->text, b.text, b.sid, "telegram"});
} else {
out.push_back({pending_user->ts, pending_user->text, "", pending_user->sid, "telegram"});
}
pending_user.reset();
}
// Leading assistant-only blocks (no prior user turn) are dropped â
// nothing to anchor them to.
}
}
if (pending_user) {
out.push_back({pending_user->ts, pending_user->text, "", pending_user->sid, "telegram"});
}
}
} // anonymous
std::vector parse_telegram(const std::string& path_str, const std::string& self_name) {
std::vector out;
std::ifstream in(path_str);
if (!in) return out;
json doc;
try { in >> doc; } catch (...) { return out; }
if (!doc.is_object()) return out;
if (doc.contains("messages")) {
// Single-chat export: the document itself is the chat object.
parse_telegram_chat(doc, self_name, out);
} else if (doc.contains("chats") && doc["chats"].is_object() &&
doc["chats"].contains("list")) {
for (const auto& chat : doc["chats"]["list"]) {
parse_telegram_chat(chat, self_name, out);
}
}
return out;
}
std::vector filter_turns(const std::vector& turns,
const TurnFilter& f) {
std::vector out;
out.reserve(turns.size());
for (const auto& t : turns) {
if (!f.session.empty() && t.session_id != f.session) continue;
if (!f.since.empty() || !f.until.empty()) {
std::string d = date_only(t.timestamp);
if (d.empty()) continue;
if (!f.since.empty() && d < f.since) continue;
if (!f.until.empty() && d >= f.until) continue;
}
out.push_back(t);
}
return out;
}
bool is_synthetic_session_guid(const std::string& guid) {
if (guid.empty()) return true;
std::string lower;
lower.reserve(guid.size());
for (char c : guid) lower.push_back(static_cast(std::tolower(static_cast(c))));
if (lower == "telegram-import" || lower.rfind("telegram-import-", 0) == 0) return true;
if (lower.find("test") != std::string::npos) return true;
if (lower.find("debug") != std::string::npos) return true;
if (lower.find("synthetic") != std::string::npos) return true;
return false;
}
// =========================================================================
// L4 summary import
// =========================================================================
std::vector load_summary_files(const std::vector<:string>& paths) {
std::vector out;
for (const auto& p : paths) {
std::ifstream in(p);
if (!in) continue;
std::stringstream ss;
ss << in.rdbuf();
std::string body = trim(ss.str());
if (body.empty()) continue;
SummaryImport s;
s.text = body;
s.tags = fs::path(p).stem().string();
out.push_back(std::move(s));
}
return out;
}
std::vector load_summary_jsonl(const std::string& path) {
std::vector out;
std::ifstream in(path);
if (!in) return out;
std::string line;
while (std::getline(in, line)) {
std::string t = trim(line);
if (t.empty()) continue;
json j;
try { j = json::parse(t); } catch (...) { continue; }
if (!j.is_object() || !j.contains("text")) continue;
SummaryImport s;
s.text = trim(j.value("text", ""));
s.tags = j.value("tags", "");
s.timestamp = j.value("timestamp", "");
if (s.text.empty()) continue;
out.push_back(std::move(s));
}
return out;
}
void do_import(RaggerMemory &memory,
const std::string &filepath,
int min_chunk_size,
const std::string &title,
int year,
const std::string &tags) {
if (!fs::exists(filepath)) {
throw std::runtime_error(std::format(ragger::lang::ERR_FILE_NOT_FOUND, filepath));
}
// A document is identified by its path: refuse the same file twice,
// before reading or chunking anything. Re-importing means deleting the
// old document first.
const std::string doc_path = ragger::collapse_home(filepath);
if (auto existing = memory.find_document_source(doc_path); !existing.is_null()) {
std::println(ragger::lang::MSG_DOC_ALREADY_IMPORTED, doc_path,
existing["id"].get(),
existing["title"].get<:string>(),
existing["imported_at"].get<:string>());
return;
}
std::string text = ragger::read_file_to_string(filepath);
auto chunks = ragger::chunk_markdown(text, min_chunk_size);
auto filename = fs::path(filepath).filename().string();
std::println(ragger::lang::MSG_IMPORTING_CHUNKS, chunks.size(), filename);
// Single timestamp shared across every chunk of this import (issue #48).
std::string import_ts = ragger::db_timestamp();
const int total = static_cast(chunks.size());
// Title/year/tags group and prioritise documents (issue #23 search).
// `--title` overrides the default (filename stem); `--year`/`--tags`
// come from the caller. All chunks of one file share these.
const std::string doc_title = title.empty()
? fs::path(filepath).stem().string()
: title;
// Store every chunk first WITHOUT embedding (fast), then embed the bodies
// out-of-process with bounded concurrency + per-call timeout (issue #41).
// This keeps a large import from saturating the box: at most
// embed_max_workers `ragger embed` subprocesses run at once.
std::vector ids;
std::vector<:string> texts;
ids.reserve(total);
texts.reserve(total);
for (int i = 0; i < total; ++i) {
ragger::DocumentChunk doc;
doc.text = ragger::clean_document_text(chunks[i].text);
doc.title = doc_title;
doc.tags = tags;
doc.year = year;
doc.path = doc_path;
doc.chunk_index = i + 1;
doc.imported_at = import_ts;
int id = memory.store_document(doc, /*defer_embedding=*/true);
ids.push_back(id);
texts.push_back(doc.text);
std::println(ragger::lang::MSG_IMPORT_CHUNK, (i + 1), total, std::to_string(id));
}
ragger::EmbedExecutor embed_exec; // timeout / retries / workers from config
auto vecs = embed_exec.batch(texts);
int skipped = 0;
for (size_t i = 0; i < ids.size(); ++i) {
if (vecs[i]) memory.update_document_embedding(ids[i], *vecs[i]);
else ++skipped; // left NULL â `ragger re-embed` can retry
}
std::println(ragger::lang::MSG_IMPORT_DONE, chunks.size());
if (skipped > 0)
Diskerror::Logger::warn(std::format(ragger::lang::WARN_IMPORT_EMBED_SKIPPED,
skipped, total));
}
} // namespace ragger