use crate::Semlith;
use crate::fleet::Fleet;
use crate::store;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
pub const CHARS4: &str = "chars4";
pub const OFF_ENV: &str = "SEMLITH_LEDGER";
pub fn enabled() -> bool {
off_reason().is_none()
}
pub fn env_on() -> bool {
!matches!(
std::env::var(OFF_ENV).as_deref(),
Ok("0") | Ok("off") | Ok("false")
)
}
static SESSION_OFF: AtomicBool = AtomicBool::new(false);
static PAUSED: AtomicBool = AtomicBool::new(false);
pub fn set_session_off(off: bool) {
SESSION_OFF.store(off, Ordering::Relaxed);
}
pub fn set_paused(paused: bool) {
PAUSED.store(paused, Ordering::Relaxed);
}
pub fn off_reason() -> Option<&'static str> {
if !env_on() {
Some("env")
} else if SESSION_OFF.load(Ordering::Relaxed) {
Some("flag")
} else if PAUSED.load(Ordering::Relaxed) {
Some("paused")
} else {
None
}
}
static UNRECORDED: std::sync::RwLock<Vec<PathBuf>> = std::sync::RwLock::new(Vec::new());
pub fn set_unrecorded(dirs: Vec<PathBuf>) {
*UNRECORDED.write().unwrap_or_else(|e| e.into_inner()) =
dirs.iter().map(|d| crate::canonical(d)).collect();
}
fn write(db: &rusqlite::Connection, row: &store::NewRetrieval<'_>) -> anyhow::Result<()> {
let off = UNRECORDED.read().unwrap_or_else(|e| e.into_inner());
if !off.is_empty()
&& let Some(dir) = db.path().and_then(|p| Path::new(p).parent())
&& off.contains(&crate::canonical(dir))
{
return Ok(());
}
drop(off);
store::record_retrieval(db, row)
}
pub fn recording_state() -> serde_json::Value {
let reason = off_reason();
serde_json::json!({ "on": reason.is_none(), "reason": reason })
}
pub fn estimate(text: &str) -> i64 {
text.len().div_ceil(4) as i64
}
#[derive(Clone, Copy)]
pub enum Counter<'a> {
Model(&'a tokenizers::Tokenizer),
Chars4,
}
impl Counter<'_> {
pub fn count(&self, text: &str) -> i64 {
match self {
Counter::Model(tokenizer) => match tokenizer.encode(text, false) {
Ok(encoded) => encoded.len() as i64,
Err(_) => estimate(text),
},
Counter::Chars4 => estimate(text),
}
}
pub fn count_bytes(&self, bytes: u64) -> i64 {
bytes.div_ceil(4) as i64
}
pub fn label(&self) -> &'static str {
match self {
Counter::Model(_) => MODEL,
Counter::Chars4 => CHARS4,
}
}
}
pub const MODEL: &str = "model";
fn query_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{at:x}-{:x}", SEQ.fetch_add(1, Ordering::Relaxed))
}
pub fn search(
fleet: &Fleet,
who: &Who<'_>,
query: &str,
hits: &[crate::Hit],
elapsed: std::time::Duration,
) {
let counter = fleet.counter();
if !enabled() {
return;
}
let id = query_id();
let labelled = hits.iter().any(|h| h.store.is_some());
let mut first = true;
for (label, store) in fleet.each() {
let mine: Vec<&crate::Hit> = if labelled {
hits.iter()
.filter(|h| h.store.as_deref() == Some(label))
.collect()
} else if first {
hits.iter().collect()
} else {
Vec::new()
};
first = false;
if mine.is_empty() && !hits.is_empty() {
continue;
}
let excerpt: i64 = mine.iter().map(|h| counter.count(&h.text)).sum();
let paths: std::collections::BTreeSet<&str> =
mine.iter().map(|h| h.path.as_str()).collect();
let whole: i64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| counter.count_bytes(m.len()))
.sum();
let stale = mine.iter().filter(|h| !h.fresh).count() as i64;
let _ = write(
store.db(),
&store::NewRetrieval {
client: who.client,
session: who.session,
client_version: who.version,
tool: "search",
query,
hits: mine.len() as i64,
micros: elapsed.as_micros() as i64,
excerpt_tokens: excerpt,
whole_file_tokens: whole,
stale_hits: stale,
tokenizer: counter.label(),
query_id: &id,
},
);
if hits.is_empty() {
break;
}
}
}
pub fn reply(
fleet: &Fleet,
who: &Who<'_>,
tool: &str,
query: &str,
body: &str,
scope: &[String],
elapsed: std::time::Duration,
) {
if !enabled() {
return;
}
let counter = fleet.counter();
let id = query_id();
let write = |db: &rusqlite::Connection, paths: &BTreeSet<String>, text: &str| {
let whole: i64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| counter.count_bytes(m.len()))
.sum();
let _ = write(
db,
&store::NewRetrieval {
client: who.client,
session: who.session,
client_version: who.version,
tool,
query,
hits: paths.len() as i64,
micros: elapsed.as_micros() as i64,
excerpt_tokens: counter.count(text),
whole_file_tokens: whole,
stale_hits: text.matches("ยท stale").count() as i64,
tokenizer: counter.label(),
query_id: &id,
},
);
};
let members: Vec<(&str, &Semlith)> = fleet.each().collect();
let parts = shares(&members, &fleet.roots(), body);
if parts.is_empty() {
let searched = fleet
.selected(Some(scope))
.ok()
.and_then(|chosen| chosen.into_iter().next())
.or_else(|| members.first().copied());
if let Some((_, first)) = searched {
write(first.db(), &BTreeSet::new(), body);
}
return;
}
for share in &parts {
write(members[share.member].1.db(), &share.paths, &share.text);
}
}
#[derive(Debug)]
struct Share {
member: usize,
paths: BTreeSet<String>,
text: String,
}
fn shares(members: &[(&str, &Semlith)], roots: &[PathBuf], body: &str) -> Vec<Share> {
let mut out: Vec<Share> = Vec::new();
let mut current: Option<usize> = None;
let mut preamble = String::new();
for line in body.lines() {
if let Some((member, path)) = heading(members, roots, line) {
let at = match out.iter().position(|s| s.member == member) {
Some(at) => at,
None => {
out.push(Share {
member,
paths: BTreeSet::new(),
text: String::new(),
});
out.len() - 1
}
};
out[at].paths.insert(path);
current = Some(at);
}
let text = match current {
Some(at) => &mut out[at].text,
None => &mut preamble,
};
text.push_str(line);
text.push('\n');
}
if let Some(first) = out.first_mut() {
first.text.insert_str(0, &preamble);
}
out
}
fn heading(members: &[(&str, &Semlith)], roots: &[PathBuf], line: &str) -> Option<(usize, String)> {
if members.is_empty() || line.starts_with(char::is_whitespace) {
return None;
}
let mut rest = line.trim_end();
if let Some((n, tail)) = rest.strip_prefix('[').and_then(|r| r.split_once("] "))
&& !n.is_empty()
&& n.bytes().all(|b| b.is_ascii_digit())
{
rest = tail;
}
let mut named = None;
for (i, (label, _)) in members.iter().enumerate() {
let tail = rest
.strip_prefix('[')
.and_then(|r| r.strip_prefix(*label))
.and_then(|r| r.strip_prefix("] "))
.or_else(|| rest.strip_prefix(*label).and_then(|r| r.strip_prefix(' ')));
if let Some(tail) = tail {
named = Some(i);
rest = tail;
break;
}
}
let path = match rest
.char_indices()
.find(|&(i, c)| c == ':' && rest[i + 1..].starts_with(|d: char| d.is_ascii_digit()))
{
Some((i, _)) => &rest[..i],
None => rest.split(" \u{2014} image").next().unwrap_or(rest),
};
if path.is_empty() || (named.is_none() && path.contains(char::is_whitespace)) {
return None;
}
let given = Path::new(path);
let candidates: Vec<PathBuf> = if given.is_absolute() {
vec![given.to_path_buf()]
} else {
roots
.iter()
.map(|r| r.join(given))
.chain([given.to_path_buf()])
.collect()
};
let files: Vec<String> = candidates
.iter()
.filter(|c| c.is_file())
.map(|c| crate::canonical(c).to_string_lossy().into_owned())
.collect();
let held = |m: usize, file: &str| {
store::file_hash(members[m].1.db(), file)
.ok()
.flatten()
.is_some()
};
let (member, file) = match named {
Some(m) => (
m,
files
.iter()
.find(|f| held(m, f.as_str()))
.or(files.first())?,
),
None => files
.iter()
.find_map(|f| {
(0..members.len())
.find(|&m| held(m, f.as_str()))
.map(|m| (m, f))
})
.or_else(|| files.first().map(|f| (0, f)))?,
};
Some((member, file.clone()))
}
pub fn graph(
fleet: &Fleet,
who: &Who<'_>,
tool: &str,
name: &str,
reply: &str,
found: bool,
elapsed: std::time::Duration,
) {
if !enabled() {
return;
}
let counter = fleet.counter();
let id = query_id();
for (_, store) in fleet.each() {
let whole = match store::grep_cost(store.db(), name) {
Ok(bytes) => counter.count_bytes(bytes as u64),
Err(_) => 0,
};
if whole == 0 {
continue;
}
let _ = write(
store.db(),
&store::NewRetrieval {
client: who.client,
session: who.session,
client_version: who.version,
tool,
query: name,
hits: i64::from(found),
micros: elapsed.as_micros() as i64,
excerpt_tokens: counter.count(reply),
whole_file_tokens: whole,
stale_hits: 0,
tokenizer: counter.label(),
query_id: &id,
},
);
}
}
pub fn refusal(fleet: &Fleet, who: &Who<'_>, tool: &str) {
if !enabled() {
return;
}
let Some((_, store)) = fleet.each().next() else {
return;
};
let _ = write(
store.db(),
&store::NewRetrieval {
client: who.client,
session: who.session,
client_version: who.version,
tool: store::REFUSED_TOOL,
query: tool,
hits: 0,
micros: 0,
excerpt_tokens: 0,
whole_file_tokens: 0,
stale_hits: 0,
tokenizer: fleet.counter().label(),
query_id: &query_id(),
},
);
}
pub fn brief(
fleet: &Fleet,
who: &Who<'_>,
question: &str,
brief: &crate::brief::Brief,
body: &str,
elapsed: std::time::Duration,
) {
if !enabled() {
return;
}
let counter = fleet.counter();
let paths: std::collections::BTreeSet<&str> =
brief.spans.iter().map(|s| s.path.as_str()).collect();
let whole: i64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| counter.count_bytes(m.len()))
.sum();
if let Some((_, store)) = fleet.each().next() {
let _ = write(
store.db(),
&store::NewRetrieval {
client: who.client,
session: who.session,
client_version: who.version,
tool: "brief",
query: question,
hits: paths.len() as i64,
micros: elapsed.as_micros() as i64,
excerpt_tokens: counter.count(body),
whole_file_tokens: whole,
stale_hits: 0,
tokenizer: counter.label(),
query_id: &query_id(),
},
);
}
}
pub const RAW_READ: &str = "raw-read";
pub fn raw_read(fleet: &Fleet, client: &str, session: &str, path: &str) -> bool {
if !enabled() {
return false;
}
let counter = fleet.counter();
let whole = std::fs::metadata(path)
.map(|m| counter.count_bytes(m.len()))
.unwrap_or(0);
for (_, store) in fleet.each() {
if !store::holds_path(store.db(), path).unwrap_or(false) {
continue;
}
let recorded = write(
store.db(),
&store::NewRetrieval {
client_version: "",
client,
session,
tool: RAW_READ,
query: path,
hits: 0,
micros: 0,
excerpt_tokens: 0,
whole_file_tokens: whole,
stale_hits: 0,
tokenizer: CHARS4,
query_id: &query_id(),
},
);
return recorded.is_ok();
}
false
}
pub fn acceptance(
db: &rusqlite::Connection,
a: &store::Acceptance,
action: &str,
) -> anyhow::Result<()> {
if !enabled() {
return Ok(());
}
let query = serde_json::json!({
"path": crate::plain(&a.path),
"class": a.class,
"mode": a.mode,
"confidence": a.confidence,
})
.to_string();
store::record_retrieval(
db,
&store::NewRetrieval {
client_version: "",
client: &a.source,
session: "",
tool: action,
query: &query,
hits: 0,
micros: 0,
excerpt_tokens: 0,
whole_file_tokens: 0,
stale_hits: 0,
tokenizer: CHARS4,
query_id: &query_id(),
},
)
}
#[derive(Debug, Clone, Copy)]
pub struct Who<'a> {
pub client: &'a str,
pub session: &'a str,
pub version: &'a str,
}
#[cfg(test)]
mod tests {
use super::*;
fn store_holding(dir: &Path, files: &[PathBuf]) -> Semlith {
std::fs::create_dir_all(dir).unwrap();
let s = Semlith::open(dir, None).unwrap();
store::read_only(s.db(), false).unwrap();
for f in files {
s.db()
.execute(
"INSERT INTO files (path, hash, bytes, indexed_at) VALUES (?1, 'h', 1, 0)",
[crate::canonical(f).to_string_lossy().into_owned()],
)
.unwrap();
}
store::read_only(s.db(), true).unwrap();
s
}
fn canon(p: &Path) -> String {
crate::canonical(p).to_string_lossy().into_owned()
}
fn two_roots(tmp: &Path) -> (PathBuf, PathBuf) {
let (a, b) = (tmp.join("a"), tmp.join("b"));
for root in [&a, &b] {
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src").join("lib.rs"), "fn x() {}\n").unwrap();
}
std::fs::write(a.join("notes.md"), "# notes\n").unwrap();
(a, b)
}
#[test]
fn a_fleet_reply_is_split_by_the_store_each_heading_names() {
let tmp = tempfile::tempdir().unwrap();
let (a, b) = two_roots(tmp.path());
let alpha = store_holding(
&tmp.path().join("alpha"),
&[a.join("src").join("lib.rs"), a.join("notes.md")],
);
let beta = store_holding(&tmp.path().join("beta"), &[b.join("src").join("lib.rs")]);
let members = [("alpha", &alpha), ("beta", &beta)];
let roots = [a.clone(), b.clone()];
let body = "roots a, b\n\
identifier ยท keyword\n\
beta src/lib.rs\n\
\x20 1-1 x fn ยท stale | fn x() {}\n\
alpha src/lib.rs\n\
\x20 1-1 x fn | fn x() {}\n\
alpha notes.md\n\
\x20 1-1 | # notes";
let got = shares(&members, &roots, body);
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[0].member, 1, "beta answered first: {got:?}");
assert_eq!(
got[0].paths,
BTreeSet::from([canon(&b.join("src").join("lib.rs"))]),
"beta's src/lib.rs is the one under beta's root"
);
assert!(got[0].text.contains("ยท stale"));
assert!(
got[0].text.starts_with("roots a, b\n"),
"the lines above the first heading go to the first store: {:?}",
got[0].text
);
assert_eq!(got[1].member, 0);
assert_eq!(
got[1].paths,
BTreeSet::from([
canon(&a.join("src").join("lib.rs")),
canon(&a.join("notes.md"))
])
);
assert!(!got[1].text.contains("stale"));
assert_eq!(
got[0].text.len() + got[1].text.len(),
body.len() + 1,
"every line of the reply is in exactly one share"
);
}
#[test]
fn every_reply_shape_names_its_store_and_prose_does_not() {
let tmp = tempfile::tempdir().unwrap();
let (a, b) = two_roots(tmp.path());
let alpha = store_holding(
&tmp.path().join("alpha"),
&[a.join("src").join("lib.rs"), a.join("notes.md")],
);
let beta = store_holding(&tmp.path().join("beta"), &[b.join("src").join("lib.rs")]);
let members = [("alpha", &alpha), ("beta", &beta)];
let roots = [a.clone(), b.clone()];
let abs = crate::plain(&a.join("notes.md").to_string_lossy());
let body = format!(
"[beta] src/lib.rs:1-1 x @1 [vector]\n\
\x20 fn x() {{}}\n\
[1] alpha {abs}:1-1 (score 0.500 via vector)\n\
# notes\n\
see src/lib.rs for the rest"
);
let got = shares(&members, &roots, &body);
let by = |m: usize| got.iter().find(|s| s.member == m).map(|s| s.paths.clone());
assert_eq!(
by(1),
Some(BTreeSet::from([canon(&b.join("src").join("lib.rs"))]))
);
assert_eq!(by(0), Some(BTreeSet::from([canon(&a.join("notes.md"))])));
let one = [("alpha", &alpha)];
let got = shares(
&one,
std::slice::from_ref(&a),
"src/lib.rs\n 1-1 | x\nnotes.md:1-1 notes\n",
);
assert_eq!(got.len(), 1);
assert_eq!(got[0].paths.len(), 2, "{got:?}");
assert!(shares(&members, &roots, "No match for that query.").is_empty());
}
#[test]
fn a_fleet_reply_writes_a_row_per_store_that_answered() {
let tmp = tempfile::tempdir().unwrap();
let (a, b) = two_roots(tmp.path());
let (alpha_dir, beta_dir) = (tmp.path().join("alpha"), tmp.path().join("beta"));
drop(store_holding(&alpha_dir, &[a.join("notes.md")]));
drop(store_holding(&beta_dir, &[b.join("src").join("lib.rs")]));
let fleet = Fleet::open(&[alpha_dir.clone(), beta_dir.clone()]).unwrap();
assert_eq!(fleet.labels(), vec!["alpha", "beta"]);
let body = format!(
"alpha {}\n 1-1 | # notes\nbeta {}\n 1-1 x fn ยท stale | fn x() {{}}",
crate::plain(&a.join("notes.md").to_string_lossy()),
crate::plain(&b.join("src").join("lib.rs").to_string_lossy()),
);
let who = Who {
client: "harness",
session: "s",
version: "",
};
reply(
&fleet,
&who,
"search",
"notes",
&body,
&[],
std::time::Duration::from_millis(1),
);
drop(fleet);
let mut ids = Vec::new();
for dir in [&alpha_dir, &beta_dir] {
let s = Semlith::open(dir, None).unwrap();
let rows = store::retrievals(s.db(), 10).unwrap();
assert_eq!(rows.len(), 1, "{}: {rows:?}", dir.display());
assert_eq!(rows[0].hits, 1, "{}: {rows:?}", dir.display());
assert!(rows[0].whole_file_tokens > 0);
assert_eq!(store::ledger_break(s.db()).unwrap(), None);
ids.push(rows[0].query_id.clone());
}
assert_eq!(ids[0], ids[1], "one reply is one retrieval");
}
#[test]
fn a_zero_hit_reply_is_one_row_in_the_store_searched() {
let tmp = tempfile::tempdir().unwrap();
let (alpha_dir, beta_dir) = (tmp.path().join("alpha"), tmp.path().join("beta"));
drop(store_holding(&alpha_dir, &[]));
drop(store_holding(&beta_dir, &[]));
let fleet = Fleet::open(&[alpha_dir.clone(), beta_dir.clone()]).unwrap();
let who = Who {
client: "harness",
session: "s",
version: "",
};
let nothing = "No match for that query.";
let at = std::time::Duration::from_millis(1);
reply(
&fleet,
&who,
"search",
"q1",
nothing,
&["beta".to_string()],
at,
);
reply(&fleet, &who, "search", "q2", nothing, &[], at);
drop(fleet);
let queries = |dir: &Path| -> Vec<(String, i64)> {
let s = Semlith::open(dir, None).unwrap();
store::retrievals(s.db(), 10)
.unwrap()
.into_iter()
.map(|r| (r.query, r.hits))
.collect()
};
assert_eq!(queries(beta_dir.as_path()), vec![("q1".to_string(), 0)]);
assert_eq!(queries(alpha_dir.as_path()), vec![("q2".to_string(), 0)]);
}
}