// Cross-target pure helpers for session search.
//
// Split out of session_search.mbt so the truncation / formatting
// routines (and the timestamp renderer) compile on --target native.
// The Promise.all-based LLM concurrency orchestration and the
// top-level `session_search` async fn remain in session_search.mbt
// (JS-only) until we add a native concurrency story.
///|
fn srch_max(a : Int, b : Int) -> Int {
if a > b {
a
} else {
b
}
}
///|
fn srch_min(a : Int, b : Int) -> Int {
if a < b {
a
} else {
b
}
}
///|
fn srch_abs(a : Int) -> Int {
if a < 0 {
-a
} else {
a
}
}
///| Find every starting index of `pattern` inside `text` (both
///| case-folded to lowercase). Returns empty when pattern is empty.
///| Substring match — no regex needed, cross-target.
fn _find_all_positions(text : String, pattern : String) -> Array[Int] {
let out : Array[Int] = []
if pattern.is_empty() {
return out
}
let t = text.to_lower()
let p = pattern.to_lower()
let tv = t.view()
let pv = p.view()
let tl = tv.length()
let pl = pv.length()
if pl > tl {
return out
}
let mut i = 0
while i <= tl - pl {
let mut j = 0
let mut ok = true
while j < pl {
if tv.get_char(i + j) != pv.get_char(j) {
ok = false
break
}
j = j + 1
}
if ok {
out.push(i)
}
i = i + 1
}
out
}
///| Tokenize on ASCII whitespace, lowercase, and drop empties.
fn _split_words(s : String) -> Array[String] {
let tokens : Array[String] = []
let buf = StringBuilder::new()
let view = s.to_lower().view()
let flush = fn() {
let t = buf.to_string()
if !t.is_empty() {
tokens.push(t)
buf.reset()
}
}
let mut i = 0
while i < view.length() {
let ch = match view.get_char(i) {
Some(c) => c
None => ' '
}
if ch == ' ' ||
ch == '\t' ||
ch == '\n' ||
ch == '\r' ||
ch == '\u{000C}' ||
ch == '\u{000B}' {
flush()
} else {
buf.write_char(ch)
}
i = i + 1
}
flush()
tokens
}
///| Extract `name` field (or `function.name`) from each entry of a
///| JSON-serialized tool_calls array. Returns an empty array on parse
///| failure or non-array input. Uses moonbitlang/core/json so it stays
///| cross-target.
fn _parse_tool_call_names(json_str : String) -> Array[String] {
let out : Array[String] = []
let root = try {
@json.parse(json_str)
} catch {
_ => return out
}
match root {
Json::Array(items) =>
for item in items {
match item {
Json::Object(obj) => {
let top = match obj.get("name") {
Some(Json::String(s)) => Some(s)
_ => None
}
let name = match top {
Some(s) => s
None =>
match obj.get("function") {
Some(Json::Object(f)) =>
match f.get("name") {
Some(Json::String(s)) => s
_ => "?"
}
_ => "?"
}
}
out.push(name)
}
_ => ()
}
}
_ => ()
}
out
}
// ── truncate_around_matches ──
pub let max_session_chars : Int = 100000
///|
pub fn truncate_around_matches(
full_text : String,
query : String,
max_chars~ : Int = max_session_chars
) -> String {
if full_text.length() <= max_chars {
return full_text
}
let query_trimmed = query.to_lower().trim().to_string()
// 1. Full phrase search
let phrase_positions : Array[Int] = _find_all_positions(
full_text, query_trimmed,
)
let match_positions : Array[Int] = []
if phrase_positions.length() > 0 {
for p in phrase_positions {
match_positions.push(p)
}
}
// 2. Proximity co-occurrence (all terms within 200 chars of rarest)
if match_positions.length() == 0 {
let terms = _split_words(query)
if terms.length() > 1 {
let term_pos : Array[(String, Array[Int])] = terms.map(fn(t) {
(t, _find_all_positions(full_text, t))
})
let mut rarest_idx = 0
let mut rarest_len = term_pos[0].1.length()
let mut ti = 1
while ti < term_pos.length() {
let cur_len = term_pos[ti].1.length()
if cur_len < rarest_len {
rarest_len = cur_len
rarest_idx = ti
}
ti = ti + 1
}
let (rarest_term, rarest_positions) = term_pos[rarest_idx]
for pos in rarest_positions {
let mut all_near = true
let mut j = 0
while j < term_pos.length() {
let (t, positions) = term_pos[j]
if t != rarest_term {
let mut found_near = false
let mut k = 0
while k < positions.length() {
if srch_abs(positions[k] - pos) < 200 {
found_near = true
break
}
k = k + 1
}
if !found_near {
all_near = false
break
}
}
j = j + 1
}
if all_near {
match_positions.push(pos)
}
}
}
}
// 3. Individual term positions (last resort)
if match_positions.length() == 0 {
let terms = _split_words(query)
for t in terms {
let pos = _find_all_positions(full_text, t)
for p in pos {
match_positions.push(p)
}
}
}
// No hits — take head
if match_positions.length() == 0 {
let truncated = full_text[0:max_chars].to_string()
return truncated + "\n\n...[later conversation truncated]..."
}
// Sort positions ascending
let sorted = match_positions.copy()
sorted.sort_by(fn(a, b) { a - b })
// Pick window covering the most positions (25% bias before match)
let mut best_start = 0
let mut best_count = 0
for candidate in sorted {
let ws = srch_max(0, candidate - max_chars / 4)
let we = ws + max_chars
let mut count = 0
for p in sorted {
if p >= ws && p < we {
count = count + 1
}
}
if count > best_count {
best_count = count
best_start = ws
}
}
// Clamp to end of text
if best_start + max_chars > full_text.length() {
best_start = srch_max(0, full_text.length() - max_chars)
}
let start = best_start
let end = srch_min(full_text.length(), start + max_chars)
let truncated = full_text[start:end].to_string()
let prefix = if start > 0 {
"...[earlier conversation truncated]...\n\n"
} else {
""
}
let suffix = if end < full_text.length() {
"\n\n...[later conversation truncated]..."
} else {
""
}
prefix + truncated + suffix
}
// ── format_conversation ──
///|
pub struct MsgForFormat {
role : String
content : String
tool_name : String
tool_calls : String
}
///|
pub fn format_conversation(messages : Array[MsgForFormat]) -> String {
let parts : Array[String] = []
for msg in messages {
let role = msg.role.to_upper()
let content = msg.content
if role == "TOOL" && msg.tool_name != "" {
let c = if content.length() > 500 {
content[0:250].to_string() +
"\n...[truncated]...\n" +
content[(content.length() - 250):content.length()].to_string()
} else {
content
}
parts.push("[TOOL:\{msg.tool_name}]: \{c}")
} else if role == "ASSISTANT" {
if msg.tool_calls != "" {
let names = _parse_tool_call_names(msg.tool_calls)
if names.length() > 0 {
let joined = names.join(", ")
parts.push("[ASSISTANT]: [Called: \{joined}]")
}
}
if content != "" {
parts.push("[ASSISTANT]: \{content}")
}
} else {
parts.push("[\{role}]: \{content}")
}
}
parts.join("\n\n")
}
///|
pub struct SearchResult {
session_id : String
when : String
source : String
model : String
summary : String
}
///|
pub struct SessionSearchResponse {
ok : Bool
query : String
results : Array[SearchResult]
count : Int
sessions_searched : Int
}
///| Render an epoch-ms timestamp as "YYYY-MM-DD HH:MM UTC". Uses
///| moonbitlang/x/time so it runs on both backends. Returns "unknown"
///| if parsing fails.
fn _format_timestamp(ts_str : String) -> String {
let ms = (try? @string.parse_int64(ts_str.view())).unwrap_or(0L)
if ms == 0L {
return "unknown"
}
let secs = ms / 1000L
let zdt_res = (try? @time.unix(secs))
match zdt_res {
Ok(zdt) => {
let d = zdt.to_plain_date_time()
let pad2 = fn(n : Int) -> String {
if n < 10 {
"0" + n.to_string()
} else {
n.to_string()
}
}
d.year().to_string() +
"-" +
pad2(d.month()) +
"-" +
pad2(d.day()) +
" " +
pad2(d.hour()) +
":" +
pad2(d.minute()) +
" UTC"
}
Err(_) => ts_str
}
}
// ── inline tests ──
///|
test "truncate: returns full text when short" {
let text = "hello world"
let result = truncate_around_matches(text, "hello")
assert_eq(result, text)
}
///|
test "truncate: adds later marker when no match and long text" {
let text = "x".repeat(200)
let result = truncate_around_matches(text, "notfound", max_chars=50)
assert_eq(result.contains("[later conversation truncated]"), true)
}
///|
test "truncate: phrase fallback finds phrase" {
let prefix = "x".repeat(1000)
let suffix = "y".repeat(1000)
let text = prefix + "target phrase" + suffix
let result = truncate_around_matches(text, "target phrase", max_chars=200)
assert_eq(result.contains("target phrase"), true)
}
///|
test "truncate: proximity fallback finds terms near each other" {
let far = "z".repeat(2000)
let text = far + "alpha blah beta" + "z".repeat(2000)
let result = truncate_around_matches(text, "alpha beta", max_chars=500)
assert_eq(result.contains("alpha"), true)
assert_eq(result.contains("beta"), true)
}
///|
test "truncate: individual term fallback finds at least one term" {
let far = "z".repeat(2000)
let text = far + "foo" + "z".repeat(2000)
let result = truncate_around_matches(text, "foo bar", max_chars=200)
assert_eq(result.contains("foo"), true)
}
///|
test "format_conversation: user message" {
let msgs : Array[MsgForFormat] = [
{ role: "user", content: "hello", tool_name: "", tool_calls: "" },
]
let result = format_conversation(msgs)
assert_eq(result, "[USER]: hello")
}
///|
test "format_conversation: assistant with content" {
let msgs : Array[MsgForFormat] = [
{ role: "assistant", content: "I can help", tool_name: "", tool_calls: "" },
]
let result = format_conversation(msgs)
assert_eq(result, "[ASSISTANT]: I can help")
}
///|
test "format_conversation: tool truncates long content" {
let long_content = "x".repeat(600)
let msgs : Array[MsgForFormat] = [
{ role: "tool", content: long_content, tool_name: "bash", tool_calls: "" },
]
let result = format_conversation(msgs)
assert_eq(result.contains("[truncated]"), true)
assert_eq(result.length() < long_content.length(), true)
}
///|
test "format_conversation: multiple messages joined" {
let msgs : Array[MsgForFormat] = [
{ role: "user", content: "hi", tool_name: "", tool_calls: "" },
{ role: "assistant", content: "hello", tool_name: "", tool_calls: "" },
]
let result = format_conversation(msgs)
assert_eq(result.contains("[USER]: hi"), true)
assert_eq(result.contains("[ASSISTANT]: hello"), true)
}