pub mod add;
pub mod agentfiles;
pub mod brief;
pub mod chunk;
pub mod clientfile;
pub mod clients;
pub mod clock;
pub mod daemon;
pub mod doctor;
pub mod embed;
pub mod filter;
pub mod fleet;
mod formats;
pub mod graph;
pub mod home;
pub mod hook;
pub mod http;
pub mod image;
pub mod index;
pub mod ledger;
pub mod lock;
pub mod mcp;
pub mod pattern;
pub mod portal;
pub mod proxy;
pub mod replay;
pub mod report;
pub mod rerank;
pub mod routes;
pub mod service;
pub mod setup;
pub mod store;
pub mod system;
pub mod upgrade;
pub mod watch;
use anyhow::{Result, bail};
use embed::Model;
use fastembed::TextEmbedding;
use filter::Filter;
use index::{Allowlist, VectorIndex};
use rusqlite::Connection;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const BIT_WIDTH: usize = 4;
const EMBED_BATCH: usize = 8;
pub fn default_model() -> Model {
Model::Granite
}
const PENDING: &str = "";
const GENERATION: &str = "index_generation";
const RRF_K: f32 = 60.0;
const DEFINITION_LIFT: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Shape {
Identifier,
Question,
}
impl Shape {
pub fn as_str(self) -> &'static str {
match self {
Self::Identifier => "identifier-shaped",
Self::Question => "question-shaped",
}
}
pub fn weighting(self) -> &'static str {
match self {
Self::Identifier => "keyword weighted 2×",
Self::Question => "vector and keyword equal",
}
}
fn keyword_weight(self) -> f32 {
match self {
Self::Identifier => 2.0,
Self::Question => 1.0,
}
}
fn list_constant(self, list: &str) -> f32 {
const STEEP: f32 = 12.0;
match (self, list) {
(Self::Identifier, "keyword") => STEEP,
(Self::Question, "vector") => STEEP,
(_, "image") => STEEP,
_ => RRF_K,
}
}
}
type CandidateList = (&'static str, bool, Vec<(u64, f32)>);
type Scored = ((bool, u64), f32);
fn fuse(
lists: &[CandidateList],
constant_of: impl Fn(&str) -> f32,
) -> (Vec<Scored>, Vec<Vec<&'static str>>) {
let mut fused: Vec<((bool, u64), f32)> = Vec::new();
let mut seen: std::collections::HashMap<(bool, u64), usize> = std::collections::HashMap::new();
let mut badges: Vec<Vec<&'static str>> = Vec::new();
for (name, is_image, ranking) in lists {
let constant = constant_of(name);
let derived = *name == "graph";
for (rank, (id, weight)) in ranking.iter().enumerate() {
let key = (*is_image, *id);
let contribution = weight / (constant + rank as f32 + 1.0);
match seen.get(&key) {
Some(&slot) => {
if !derived {
fused[slot].1 += contribution;
}
badges[slot].push(name);
}
None => {
seen.insert(key, fused.len());
fused.push((key, contribution));
badges.push(vec![name]);
}
}
}
}
(fused, badges)
}
pub fn shape_of(query: &str) -> Shape {
let trimmed = query.trim();
if trimmed.is_empty() || trimmed.split_whitespace().count() > 1 {
return Shape::Question;
}
let identifier = trimmed
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '.' || c == '!');
if identifier && trimmed.chars().any(|c| c.is_alphabetic()) {
Shape::Identifier
} else {
Shape::Question
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Prefer {
#[default]
Any,
Code,
Docs,
}
impl Prefer {
pub fn as_str(self) -> &'static str {
match self {
Self::Any => "any",
Self::Code => "code",
Self::Docs => "docs",
}
}
pub fn parse(raw: &str) -> Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"any" | "" => Ok(Self::Any),
"code" => Ok(Self::Code),
"docs" | "doc" => Ok(Self::Docs),
other => anyhow::bail!("prefer is code, docs or any, not {other:?}"),
}
}
fn multiplier(self, is_code: bool) -> f32 {
const LIFT: f32 = 1.5;
match (self, is_code) {
(Self::Any, _) => 1.0,
(Self::Code, true) | (Self::Docs, false) => LIFT,
_ => 1.0,
}
}
}
const GRAPH_PROXIMITY: f32 = 0.15;
const STALE_PENALTY: f32 = 0.10;
type Candidate = (((bool, u64), f32), Vec<&'static str>);
const TEXT_LISTS: f32 = 3.0;
const WEAK_IMAGE: f32 = 0.25;
const IMAGE_FLOOR: f32 = 0.28;
pub const IMAGE_FLOOR_ENV: &str = "SEMLITH_IMAGE_FLOOR";
fn image_floor() -> f32 {
std::env::var(IMAGE_FLOOR_ENV)
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(IMAGE_FLOOR)
}
const RANK_DEPTH: usize = 4;
const CHECKPOINT_FILES: usize = 200;
const CHECKPOINT_FILES_ENV: &str = "SEMLITH_CHECKPOINT_FILES";
fn checkpoint_files() -> usize {
match std::env::var(CHECKPOINT_FILES_ENV)
.ok()
.and_then(|v| v.parse().ok())
{
Some(files) if files > 0 => files,
_ => CHECKPOINT_FILES,
}
}
const INFERRED_EXPANSION: f32 = 0.5;
struct Reached {
id: u64,
weight: f32,
tier: String,
proximity: f32,
}
fn provenance_of(graph: &[Reached], id: u64) -> Option<String> {
graph.iter().find(|r| r.id == id).map(|r| r.tier.clone())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Span { path: String, start: u32, end: u32 },
Symbol(String),
}
impl Target {
pub fn parse(raw: &str) -> Self {
let raw = raw.trim();
if let Some((path, lines)) = raw.rsplit_once(':')
&& !path.is_empty()
{
let (start, end) = match lines.split_once('-') {
Some((a, b)) => (a.parse::<u32>().ok(), b.parse::<u32>().ok()),
None => (lines.parse::<u32>().ok(), lines.parse::<u32>().ok()),
};
if let (Some(start), Some(end)) = (start, end) {
return Self::Span {
path: path.to_string(),
start: start.min(end),
end: start.max(end),
};
}
}
Self::Symbol(raw.to_string())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Span {
#[serde(serialize_with = "serialize_plain")]
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_kind: Option<String>,
pub fresh: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Read {
One(Span),
Choose(Vec<store::SymbolRow>),
}
#[derive(Debug, Clone, Serialize)]
pub struct Hit {
pub score: f32,
#[serde(serialize_with = "serialize_plain")]
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub lists: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<Pixels>,
pub fresh: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct Pixels {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum FileOutcome {
#[default]
Indexing,
Unchanged,
Skipped,
Removed,
Refused,
Writing,
Failed,
}
impl FileOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Indexing => "indexing",
Self::Writing => "writing",
Self::Unchanged => "unchanged",
Self::Skipped => "skipped",
Self::Removed => "removed",
Self::Refused => "refused",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
Empty,
TooLarge,
NotRegular,
Unreadable(String),
Binary,
NoText,
NotDecodableImage,
}
impl SkipReason {
pub fn as_str(&self) -> String {
match self {
Self::Empty => "empty".to_string(),
Self::TooLarge => format!("over {} MiB", chunk::MAX_FILE_BYTES / (1024 * 1024)),
Self::NotRegular => "not a regular file".to_string(),
Self::Unreadable(e) => format!("unreadable: {e}"),
Self::Binary => "binary".to_string(),
Self::NoText => "no text in this document".to_string(),
Self::NotDecodableImage => "not a decodable image".to_string(),
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Empty => "empty",
Self::TooLarge => "too large",
Self::NotRegular => "not a regular file",
Self::Unreadable(_) => "unreadable",
Self::Binary => "binary",
Self::NoText => "no text in this document",
Self::NotDecodableImage => "not a decodable image",
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Boundary {
pub roots: Option<Vec<PathBuf>>,
pub allow_secrets: bool,
}
impl Boundary {
pub fn within(roots: Vec<PathBuf>) -> Self {
Self {
roots: Some(roots),
allow_secrets: false,
}
}
pub fn refuses(&self, path: &Path, walked: bool, home: Option<&Path>) -> Option<Refusal> {
if let Some(roots) = &self.roots
&& !filter::within_boundary(path, roots)
{
let named = if roots.is_empty() {
"this store has no registered roots, so only the home directory is inside \
its boundary"
.to_string()
} else {
format!(
"this store's roots are {}",
roots
.iter()
.map(|r| r.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
};
return Some(Refusal {
why: format!(
"is outside this store's roots, so semlith will not index it into this \
store — {named}. Index it into the store that covers it, add it as a \
root first, or index it from the command line."
),
credential: false,
});
}
if !self.allow_secrets
&& let Some(why) = filter::denied_against(path, home)
{
if walked && why == filter::Denied::Hidden {
return None;
}
return Some(Refusal {
why: why.reason(),
credential: matches!(why, filter::Denied::Name(_) | filter::Denied::Directory(_)),
});
}
None
}
}
#[derive(Debug, Clone)]
pub struct Refusal {
pub why: String,
pub credential: bool,
}
#[derive(Debug, Default, Clone)]
pub struct IndexProgress {
pub outcome: FileOutcome,
pub scanned: usize,
pub indexed: usize,
pub chunks: usize,
pub total: usize,
pub symbols: usize,
pub why: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct IndexReport {
pub scanned: usize,
pub indexed: usize,
pub unchanged: usize,
pub skipped: usize,
pub removed: usize,
pub chunks: usize,
pub remaining: usize,
#[serde(skip)]
pub pending: Vec<PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub generated: Vec<String>,
pub rechunked: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub refused: Vec<(String, String)>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub failed: Vec<(String, String)>,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub skipped_reasons: std::collections::BTreeMap<String, usize>,
pub secrets_indexed: usize,
pub symbols: usize,
pub edges: usize,
pub images: usize,
pub stopped: bool,
pub written: Vec<String>,
}
fn say_file(
on_file: &mut impl FnMut(&Path, IndexProgress),
report: &IndexReport,
total: usize,
path: &Path,
outcome: FileOutcome,
why: Option<String>,
) {
debug_assert!(
why.is_some()
|| !matches!(
outcome,
FileOutcome::Skipped | FileOutcome::Refused | FileOutcome::Failed
),
"{} must say why",
outcome.as_str()
);
on_file(
path,
IndexProgress {
outcome,
scanned: report.scanned,
indexed: report.indexed,
chunks: report.chunks,
total,
symbols: report.symbols,
why,
},
);
}
fn skip(report: &mut IndexReport, why: &SkipReason) {
report.skipped += 1;
*report
.skipped_reasons
.entry(why.kind().to_string())
.or_insert(0) += 1;
}
fn failed(report: &mut IndexReport, path: &Path, e: &anyhow::Error) {
report
.failed
.push((path.display().to_string(), format!("{e:#}")));
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
#[serde(serialize_with = "serialize_plain")]
pub path: String,
pub why: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flow {
Run,
Pause,
Yield,
Stop,
}
const PAUSE_TICK: std::time::Duration = std::time::Duration::from_millis(120);
pub struct Semlith {
dir: PathBuf,
db: Connection,
index: VectorIndex,
images: VectorIndex,
exact: index::Exact,
model: Model,
dim: usize,
embedder: Option<TextEmbedding>,
tokenizer: Option<tokenizers::Tokenizer>,
clip: image::Clip,
generation: u64,
pub quiet: bool,
pub boundary: Boundary,
}
impl Semlith {
pub fn open(dir: impl AsRef<Path>, model: Option<Model>) -> Result<Self> {
let dir = dir.as_ref().to_path_buf();
home::secure_dir(&dir)?;
let db_path = dir.join("store.db");
let db = store::open(&db_path)?;
home::tighten_file(&db_path);
for beside in ["store.db-wal", "store.db-shm"] {
home::tighten_file(&dir.join(beside));
}
let model = match store::get_meta(&db, "model")? {
Some(existing) => {
let existing: Model = existing.parse().map_err(anyhow::Error::msg)?;
if let Some(want) = model
&& want != existing
{
bail!(
"store was built with {existing}, not {want}; \
delete {} to rebuild with a different model",
dir.display()
);
}
existing
}
None => {
let m = model.unwrap_or_else(default_model);
let _writing = store::Writing::begin(&db)?;
store::set_meta(&db, "model", &m.to_string())?;
store::set_meta(&db, store::FORMAT_KEY, &store::FORMAT_VERSION.to_string())?;
m
}
};
let dim = model.dim()?;
let sharded = store::format(&db)? >= store::SHARDED_FORMAT;
let index = VectorIndex::open(&dir, dim, BIT_WIDTH, sharded)?;
let images = VectorIndex::open(&dir.join(image::INDEX_DIR), image::DIM, BIT_WIDTH, true)?;
let generation = generation(&db)?;
Ok(Self {
exact: index::Exact::open(&dir, dim),
dir,
db,
index,
images,
model,
dim,
embedder: None,
tokenizer: None,
clip: image::Clip::default(),
generation,
quiet: false,
boundary: Boundary::default(),
})
}
pub fn open_existing(dir: impl AsRef<Path>) -> Result<Self> {
let dir = dir.as_ref();
if !dir.join("store.db").exists() {
bail!(
"{} is not a semlith store — no store.db in it; index it first",
dir.display()
);
}
Self::open(dir, None)
}
pub fn refresh(&mut self) -> Result<()> {
let current = generation(&self.db)?;
if current == self.generation {
return Ok(());
}
if !self.index.exists() {
return Ok(());
}
let was_resident = self.index.is_resident();
self.index.evict();
if was_resident {
self.index.prepare()?;
}
self.generation = current;
Ok(())
}
pub fn model(&self) -> &Model {
&self.model
}
pub fn dim(&self) -> usize {
self.dim
}
pub fn len(&self) -> usize {
if let Some(resident) = self.index.resident_len() {
return resident;
}
store::durable_chunks(&self.db).unwrap_or(0) as usize
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn db(&self) -> &Connection {
&self.db
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn shards(&self) -> Option<(usize, usize)> {
self.index
.max_resident()
.map(|max| (self.index.shards(), max))
}
pub fn evictions(&self) -> u64 {
self.index.evictions()
}
pub fn tokenizer(&self) -> Option<&tokenizers::Tokenizer> {
self.tokenizer.as_ref()
}
fn embedder(&mut self) -> Result<&mut TextEmbedding> {
if self.embedder.is_none() {
let cache = model_cache_dir()?;
self.tokenizer = self.model.tokenizer(&cache);
self.embedder = Some(self.model.load(cache, chunk::MAX_CHARS / 2, self.quiet)?);
}
Ok(self.embedder.as_mut().unwrap())
}
pub fn warm(&mut self) -> Result<()> {
self.embedder()?;
self.index.prepare()?;
Ok(())
}
pub fn warm_index(&mut self) -> Result<()> {
self.index.prepare()
}
fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>> {
let mut out = self
.embedder()?
.embed(texts, Some(EMBED_BATCH))
.map_err(|e| anyhow::anyhow!("{e}"))?;
for v in &mut out {
normalize(v);
}
Ok(out)
}
pub fn index_paths(
&mut self,
roots: &[PathBuf],
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
let _lock = lock::StoreLock::acquire(&self.dir)?;
self.index_walk(roots, on_file)
}
pub fn index_paths_within(
&mut self,
roots: &[PathBuf],
budget: std::time::Duration,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
let _lock = lock::StoreLock::acquire(&self.dir)?;
self.index_within_held(roots, budget, on_file)
}
pub(crate) fn index_within_held(
&mut self,
roots: &[PathBuf],
budget: std::time::Duration,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
let deadline = std::time::Instant::now() + budget;
self.index_set(walk(roots), true, Some(deadline), None, on_file)
}
pub(crate) fn index_walk_under(
&mut self,
roots: &[PathBuf],
control: &dyn Fn() -> Flow,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
self.index_set(walk(roots), true, None, Some(control), on_file)
}
pub(crate) fn index_within_held_under(
&mut self,
roots: &[PathBuf],
budget: std::time::Duration,
control: &dyn Fn() -> Flow,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
let deadline = std::time::Instant::now() + budget;
self.index_set(walk(roots), true, Some(deadline), Some(control), on_file)
}
pub(crate) fn index_rest_held_under(
&mut self,
files: Vec<PathBuf>,
budget: std::time::Duration,
control: &dyn Fn() -> Flow,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
let deadline = std::time::Instant::now() + budget;
self.index_set(
Walked {
files,
named: Vec::new(),
unreadable: Vec::new(),
generated: Vec::new(),
},
true,
Some(deadline),
Some(control),
on_file,
)
}
pub(crate) fn index_walk(
&mut self,
roots: &[PathBuf],
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
self.index_set(walk(roots), true, None, None, on_file)
}
pub(crate) fn index_changed(
&mut self,
paths: Vec<PathBuf>,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
self.index_set(
Walked {
files: paths,
named: Vec::new(),
unreadable: Vec::new(),
generated: Vec::new(),
},
false,
None,
None,
on_file,
)
}
fn index_set(
&mut self,
walked: Walked,
sweep: bool,
deadline: Option<std::time::Instant>,
control: Option<&dyn Fn() -> Flow>,
on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
self.writing(move |me| me.index_set_writing(walked, sweep, deadline, control, on_file))
}
fn writing<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
store::read_only(&self.db, false)?;
let out = body(self);
let _ = store::read_only(&self.db, true);
out
}
fn index_set_writing(
&mut self,
walked: Walked,
sweep: bool,
deadline: Option<std::time::Instant>,
control: Option<&dyn Fn() -> Flow>,
mut on_file: impl FnMut(&Path, IndexProgress),
) -> Result<IndexReport> {
self.index.clean();
let mut report = IndexReport::default();
let mut pending = Batch::default();
let mut written: Vec<String> = Vec::new();
let mut completed: Vec<(i64, String)> = Vec::new();
let checkpointing = matches!(self.index, index::VectorIndex::Sharded(_));
let every = checkpoint_files();
let mut since_checkpoint = 0usize;
let rechunk = store::format(&self.db)? < store::CODE_CONTEXT;
report.rechunked = rechunk;
let Walked {
files: walked_paths,
named,
unreadable: unwalkable,
generated,
} = walked;
let (paths, refused): (Vec<PathBuf>, Vec<(PathBuf, Refusal)>) = {
let mut allowed = Vec::with_capacity(walked_paths.len() + named.len());
let mut refused = Vec::new();
let home = crate::home::user_home().ok().map(|h| canonical(&h));
let all = named
.into_iter()
.map(|p| (p, false))
.chain(walked_paths.into_iter().map(|p| (p, true)));
for (path, walked) in all {
match self.boundary.refuses(&path, walked, home.as_deref()) {
Some(why) => refused.push((path, why)),
None => allowed.push(path),
}
}
allowed.sort();
(allowed, refused)
};
let total = paths.len() + refused.len() + unwalkable.len();
for (path, refusal) in &refused {
let evicted = if refusal.credential {
let key = path.to_string_lossy().into_owned();
let (chunks, images) = self.evict(&key)?;
chunks + images
} else {
0
};
report.removed += usize::from(evicted > 0);
let why = if evicted > 0 {
format!(
"{} Its earlier contents have been removed from this store.",
refusal.why
)
} else {
refusal.why.clone()
};
report
.refused
.push((path.display().to_string(), why.clone()));
report.scanned += 1;
say_file(
&mut on_file,
&report,
total,
path,
FileOutcome::Refused,
Some(why),
);
}
report.generated = generated.iter().map(|p| p.display().to_string()).collect();
for (path, why) in &unwalkable {
report
.failed
.push((path.display().to_string(), why.clone()));
report.scanned += 1;
say_file(
&mut on_file,
&report,
total,
path,
FileOutcome::Failed,
Some(why.clone()),
);
}
for (seen, path) in paths.iter().enumerate() {
let path = path.clone();
if let Some(deadline) = deadline
&& report.indexed > 0
&& std::time::Instant::now() >= deadline
{
report.remaining = total - seen;
report.pending = paths[seen..].to_vec();
break;
}
if let Some(ask) = control {
let mut stop = false;
let mut yielded = false;
loop {
match ask() {
Flow::Run => break,
Flow::Pause => std::thread::sleep(PAUSE_TICK),
Flow::Yield => {
yielded = true;
break;
}
Flow::Stop => {
stop = true;
break;
}
}
}
if yielded {
report.remaining = total - seen;
report.pending = paths[seen..].to_vec();
break;
}
if stop {
report.remaining = total - seen;
report.stopped = true;
break;
}
}
report.scanned += 1;
let key = path.to_string_lossy().into_owned();
let opened = std::fs::File::open(&path);
let measured = opened.as_ref().ok().and_then(|f| f.metadata().ok());
let unusable = match (&opened, &measured) {
(Err(e), _) => Some(SkipReason::Unreadable(e.to_string())),
(_, None) => Some(SkipReason::Unreadable(
"its metadata could not be read".to_string(),
)),
(_, Some(m)) if !m.is_file() => Some(SkipReason::NotRegular),
(_, Some(m)) if m.len() == 0 => Some(SkipReason::Empty),
(_, Some(m)) if m.len() > chunk::MAX_FILE_BYTES => Some(SkipReason::TooLarge),
_ => None,
};
if let Some(why) = unusable {
if !path.exists() {
let ids = store::delete_file(&self.db, &key, now())?;
if !ids.is_empty() {
for id in ids {
self.index.remove(id)?;
}
report.removed += 1;
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Removed,
None,
);
continue;
}
}
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
}
let read = opened.and_then(|file| {
use std::io::Read;
let mut bytes = Vec::new();
file.take(chunk::MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map(|_| bytes)
});
let bytes = match read {
Ok(bytes) if bytes.len() as u64 <= chunk::MAX_FILE_BYTES => bytes,
Ok(_) => {
let why = SkipReason::TooLarge;
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
}
Err(e) => {
let why = SkipReason::Unreadable(e.to_string());
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
}
};
let hash = blake3::hash(&bytes).to_hex().to_string();
if !rechunk && store::file_hash(&self.db, &key)?.as_deref() == Some(hash.as_str()) {
report.unchanged += 1;
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Unchanged,
None,
);
continue;
}
if image::is_image(&path) {
if let Some(why) = image::too_large(&bytes) {
report
.refused
.push((path.display().to_string(), why.clone()));
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Refused,
Some(why),
);
continue;
}
let Some((width, height)) = image::dimensions(&bytes) else {
let why = SkipReason::NotDecodableImage;
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
};
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Indexing,
None,
);
let vector = match self.clip.embed_image(&path, self.quiet) {
Ok(vector) => vector,
Err(e) => {
failed(&mut report, &path, &e);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Failed,
Some(format!("{e:#}")),
);
continue;
}
};
for id in store::image_ids_of(&self.db, &key)? {
self.images.remove(id as u64)?;
}
for id in store::delete_file(&self.db, &key, now())? {
self.index.remove(id)?;
}
let file_id =
store::insert_file(&self.db, &key, PENDING, bytes.len() as u64, now())?;
let image_id = store::insert_image(&self.db, file_id, width, height)?;
self.images.add(&vector, &[image_id as u64])?;
completed.push((file_id, hash));
written.push(key.clone());
report.indexed += 1;
report.images += 1;
continue;
}
let text = match chunk::extract(&path, &bytes) {
Ok(text) => text,
Err(why) => {
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
}
};
match filter::scan_text(&text) {
Some(found) if self.boundary.allow_secrets => {
let _ = found;
report.secrets_indexed += 1;
}
Some(found) => {
let why = found.reason();
let (gone, images) = self.evict(&key)?;
let evicted = gone + images;
report.removed += usize::from(evicted > 0);
let why = if evicted > 0 {
format!("{why}. Its earlier contents have been removed from this store.")
} else {
why
};
report
.refused
.push((path.display().to_string(), why.clone()));
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Refused,
Some(why),
);
continue;
}
None => {}
}
let extraction = match graph::extract(&path, &text) {
Ok(extraction) => extraction,
Err(e) => {
failed(&mut report, &path, &e);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Failed,
Some(format!("{e:#}")),
);
continue;
}
};
let chunks = chunk::chunk_file(
&path,
&text,
extraction.as_ref().map_or(&[][..], |e| &e.symbols),
);
if chunks.is_empty() {
let why = SkipReason::NoText;
skip(&mut report, &why);
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Skipped,
Some(why.as_str()),
);
continue;
}
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Indexing,
None,
);
for id in store::delete_file(&self.db, &key, now())? {
self.index.remove(id)?;
}
let file_id = store::insert_file(&self.db, &key, PENDING, bytes.len() as u64, now())?;
let parsed = match (&extraction, graph::language_of(&path)) {
(Some(_), _) => "parsed",
(None, Some(lang)) if graph::has_graph(lang) => "timeout",
(None, _) => "none",
};
store::set_file_graph(&self.db, file_id, parsed)?;
let mut spans: Vec<(u32, u32, i64)> = Vec::with_capacity(chunks.len());
for (ord, c) in chunks.iter().enumerate() {
let id =
store::insert_chunk(&self.db, file_id, ord, c.start_line, c.end_line, &c.text)?;
spans.push((c.start_line, c.end_line, id));
pending.ids.push(id as u64);
pending.texts.push(c.embedded());
if pending.ids.len() >= EMBED_BATCH {
self.flush(&mut pending)?;
}
}
let (symbols, edges) = self.write_graph(extraction, file_id, &spans)?;
report.symbols += symbols;
report.edges += edges;
completed.push((file_id, hash));
written.push(key.clone());
report.indexed += 1;
report.chunks += chunks.len();
since_checkpoint += 1;
if checkpointing && since_checkpoint >= every {
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Writing,
Some("writing the index to disk".to_string()),
);
self.flush(&mut pending)?;
self.checkpoint(&mut completed)?;
since_checkpoint = 0;
say_file(
&mut on_file,
&report,
total,
&path,
FileOutcome::Indexing,
None,
);
}
}
self.flush(&mut pending)?;
report.written = written;
if sweep && report.pending.is_empty() && !report.stopped {
for key in store::all_paths(&self.db)? {
if !Path::new(&key).exists() {
for id in store::delete_file(&self.db, &key, now())? {
self.index.remove(id)?;
}
report.removed += 1;
}
}
}
if report.indexed > 0 || report.removed > 0 || !self.index.exists() {
self.save()?;
}
self.commit_hashes(&mut completed)?;
if rechunk && sweep && report.pending.is_empty() && !report.stopped {
store::set_meta(
&self.db,
store::FORMAT_KEY,
&store::FORMAT_VERSION.to_string(),
)?;
}
Ok(report)
}
fn checkpoint(&mut self, completed: &mut Vec<(i64, String)>) -> Result<()> {
if completed.is_empty() {
return Ok(());
}
self.save()?;
self.commit_hashes(completed)
}
fn expansion_weight(confidence: &str) -> f32 {
match confidence {
graph::EXTRACTED | graph::RESOLVED => 1.0,
_ => INFERRED_EXPANSION,
}
}
fn rescored(&self, query: &[f32], scores: Vec<f32>, ids: Vec<u64>) -> (Vec<f32>, Vec<u64>) {
if ids.len() < 2 || !self.exact.exists() {
return (scores, ids);
}
let mut exact: Vec<(u64, f32)> = Vec::with_capacity(ids.len());
for id in &ids {
match self.exact.get(*id) {
Some(vector) => exact.push((*id, index::cosine(query, &vector))),
None => return (scores, ids),
}
}
exact.sort_by(|a, b| b.1.total_cmp(&a.1));
let (rescored_ids, rescored_scores): (Vec<u64>, Vec<f32>) = exact.into_iter().unzip();
(rescored_scores, rescored_ids)
}
fn graph_expansion(
&self,
dense: &[u64],
keyword: &[u64],
depth: usize,
filter: &Filter,
) -> Result<Vec<Reached>> {
const SEEDS: usize = 8;
let mut mass: std::collections::BTreeMap<u64, f32> = std::collections::BTreeMap::new();
for list in [dense, keyword] {
for (rank, id) in list.iter().take(SEEDS).enumerate() {
*mass.entry(*id).or_default() += 1.0 / (RRF_K + rank as f32 + 1.0);
}
}
if mass.is_empty() {
return Ok(Vec::new());
}
let mut personal: std::collections::BTreeMap<String, f32> =
std::collections::BTreeMap::new();
for (id, mass) in &mass {
let names = store::symbols_in_chunks(&self.db, &[*id])?;
if names.is_empty() {
continue;
}
let share = mass / names.len() as f32;
for name in names {
*personal.entry(name).or_default() += share;
}
}
if personal.is_empty() {
return Ok(Vec::new());
}
let kinds = graph::dependency_kinds();
let ranked = graph::expand(&personal, |name| {
let mut out = Vec::new();
for end in store::edges_out(&self.db, name, &kinds)?
.into_iter()
.chain(store::edges_in(&self.db, name, &kinds)?)
{
if end.confidence == graph::AMBIGUOUS {
continue;
}
if !crate::filter::is_code(&end.symbol.path) {
continue;
}
let weight = Self::expansion_weight(&end.confidence);
out.push((end.symbol.name, weight, end.confidence));
}
Ok(out)
})?;
let names: Vec<String> = ranked.iter().map(|(name, _, _)| name.clone()).collect();
let place: std::collections::HashMap<&str, usize> = names
.iter()
.enumerate()
.map(|(i, name)| (name.as_str(), i))
.collect();
let best = ranked.first().map(|(_, mass, _)| *mass);
let mut symbols = store::symbols_by_names(&self.db, &names, filter.groups())?;
symbols.sort_by_key(|s| place.get(s.name.as_str()).copied().unwrap_or(usize::MAX));
let mut ids: Vec<Reached> = Vec::new();
for symbol in symbols {
let Some(chunk_id) = symbol.chunk_id else {
continue;
};
let id = chunk_id as u64;
let found = ranked.iter().find(|(name, _, _)| *name == symbol.name);
let tier = found
.map(|(_, _, tier)| tier.clone())
.unwrap_or_else(|| graph::INFERRED.to_string());
let weight = Self::expansion_weight(&tier);
let proximity = match (found, best) {
(Some((_, mass, _)), Some(best)) if best > 0.0 => mass / best,
_ => 0.0,
};
if !ids.iter().any(|r| r.id == id) {
ids.push(Reached {
id,
weight,
tier,
proximity,
});
}
if ids.len() >= depth {
break;
}
}
Ok(ids)
}
fn write_graph(
&self,
extraction: Option<graph::Extraction>,
file_id: i64,
spans: &[(u32, u32, i64)],
) -> Result<(usize, usize)> {
let Some(extraction) = extraction else {
return Ok((0, 0));
};
let mut ids: std::collections::HashMap<&str, i64> = std::collections::HashMap::new();
for symbol in &extraction.symbols {
let chunk_id = spans
.iter()
.find(|(start, end, _)| symbol.start_line >= *start && symbol.start_line <= *end)
.map(|(_, _, id)| *id);
let id = store::insert_symbol(&self.db, file_id, chunk_id, symbol)?;
ids.entry(symbol.name.as_str()).or_insert(id);
}
let mut written = 0;
for edge in &extraction.edges {
let Some(src) = ids.get(edge.from.as_str()) else {
continue;
};
store::insert_edge(
&self.db,
*src,
&edge.to,
&edge.kind,
&edge.confidence,
edge.hint.as_deref(),
edge.line,
)?;
written += 1;
}
Ok((extraction.symbols.len(), written))
}
fn commit_hashes(&mut self, completed: &mut Vec<(i64, String)>) -> Result<()> {
if completed.is_empty() {
return Ok(());
}
let tx = self.db.unchecked_transaction()?;
for (file_id, hash) in completed.iter() {
tx.execute(
"UPDATE files SET hash = ?1 WHERE id = ?2",
rusqlite::params![hash, file_id],
)?;
}
tx.commit()?;
completed.clear();
Ok(())
}
fn flush(&mut self, batch: &mut Batch) -> Result<()> {
if batch.ids.is_empty() {
return Ok(());
}
let ids = std::mem::take(&mut batch.ids);
let texts = std::mem::take(&mut batch.texts);
let vectors = self.embed(texts)?;
let flat: Vec<f32> = vectors.into_iter().flatten().collect();
self.index.add(&flat, &ids)?;
self.exact.append(&flat, &ids)?;
Ok(())
}
pub fn forget(&mut self, path: &Path) -> Result<(usize, usize)> {
let _lock = lock::StoreLock::acquire(&self.dir)?;
self.forget_held(path)
}
pub(crate) fn forget_held(&mut self, path: &Path) -> Result<(usize, usize)> {
self.writing(|me| me.forget_writing(path))
}
fn forget_writing(&mut self, path: &Path) -> Result<(usize, usize)> {
let key = canonical(path).to_string_lossy().into_owned();
let (chunks, images) = self.evict(&key)?;
if chunks > 0 || images > 0 {
self.save()?;
}
Ok((chunks, images))
}
fn evict(&mut self, key: &str) -> Result<(usize, usize)> {
let images = store::image_ids_of(&self.db, key)?;
let ids = store::delete_file(&self.db, key, now())?;
for id in &ids {
self.index.remove(*id)?;
}
for id in &images {
self.images.remove(*id as u64)?;
}
Ok((ids.len(), images.len()))
}
pub fn out_of_root(&self, roots: &[PathBuf]) -> Result<Vec<String>> {
if roots.is_empty() {
return Ok(Vec::new());
}
Ok(store::all_paths(&self.db)?
.into_iter()
.filter(|key| !filter::within_boundary(Path::new(key), roots))
.collect())
}
pub fn prune_out_of_root(&mut self, roots: &[PathBuf]) -> Result<usize> {
let strays = self.out_of_root(roots)?;
if strays.is_empty() {
return Ok(0);
}
self.writing(|me| {
for key in &strays {
me.evict(key)?;
}
me.index.save()?;
Ok(())
})?;
Ok(strays.len())
}
pub fn scan(&self) -> Result<Vec<Finding>> {
let home = match crate::home::user_home() {
Ok(home) => canonical(&home),
Err(e) => bail!(
"semlith cannot tell where your home directory is, so it cannot say which \
of this store's files sit under a credential directory: {e}. Set \
SEMLITH_HOME, or HOME, and scan again."
),
};
let home = Some(home);
let mut out = Vec::new();
for key in store::all_paths(&self.db)? {
let path = Path::new(&key);
if let Some(why) = filter::denied_against(path, home.as_deref())
&& why != filter::Denied::Hidden
{
out.push(Finding {
path: key,
why: why.reason(),
});
continue;
}
if let Some(found) = filter::scan_text(&store::text_of(&self.db, &key)?) {
out.push(Finding {
path: key,
why: found.reason(),
});
}
}
Ok(out)
}
pub fn forget_findings(&mut self, findings: &[Finding]) -> Result<usize> {
self.writing(|me| {
let mut gone = 0;
for finding in findings {
let (chunks, images) = me.evict(&finding.path)?;
if chunks > 0 || images > 0 {
gone += 1;
}
}
if gone > 0 {
me.save()?;
}
Ok(gone)
})
}
pub fn search(&mut self, query: &str, k: usize) -> Result<Vec<Hit>> {
self.search_filtered(query, k, &Filter::default())
}
fn allowlist(&mut self, filter: &Filter) -> Result<Allowlist> {
if filter.is_empty() {
return Ok(Allowlist::All);
}
let candidates = store::filtered_chunk_ids(&self.db, filter.groups())?;
let mut ids = Vec::with_capacity(candidates.len());
for id in candidates {
if self.index.contains(id)? {
ids.push(id);
}
}
Ok(if ids.is_empty() {
Allowlist::Empty
} else if ids.len() == self.len() {
Allowlist::All
} else {
Allowlist::Subset(ids)
})
}
pub fn matching_files(&self, filter: &Filter) -> Result<i64> {
store::matching_files(&self.db, filter.groups())
}
pub fn matching_paths(&self, filter: &Filter) -> Result<Vec<String>> {
store::filtered_paths(&self.db, filter.groups())
}
pub fn search_filtered(&mut self, query: &str, k: usize, filter: &Filter) -> Result<Vec<Hit>> {
let vector = self.embed_query(query)?;
self.search_with_vector(query, &vector, k, filter)
}
pub fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
Ok(self.embed(vec![self.model.query_text(query)])?.remove(0))
}
pub fn search_with_vector(
&mut self,
query: &str,
vector: &[f32],
k: usize,
filter: &Filter,
) -> Result<Vec<Hit>> {
Ok(self
.search_ranked(query, vector, k, filter)?
.into_iter()
.map(|(hit, _)| hit)
.collect())
}
pub fn search_ranked(
&mut self,
query: &str,
vector: &[f32],
k: usize,
filter: &Filter,
) -> Result<Vec<(Hit, f32)>> {
self.search_preferring(query, vector, k, filter, Prefer::default())
}
pub fn search_preferring(
&mut self,
query: &str,
vector: &[f32],
k: usize,
filter: &Filter,
prefer: Prefer,
) -> Result<Vec<(Hit, f32)>> {
self.refresh()?;
if k == 0 || (self.is_empty() && store::image_count(&self.db)? == 0) {
return Ok(Vec::new());
}
let depth = (k * RANK_DEPTH).max(k);
let shape = shape_of(query);
let allowlist = self.allowlist(filter)?;
if matches!(allowlist, Allowlist::Empty) {
return Ok(Vec::new());
}
let definitions: Vec<u64> = if shape == Shape::Identifier {
let name = query.trim().to_string();
let mut ids = Vec::new();
for row in
store::symbols_by_names(&self.db, std::slice::from_ref(&name), filter.groups())?
{
if let Some(chunk) = row.chunk_id {
let id = chunk as u64;
if !ids.contains(&id) {
ids.push(id);
}
}
if ids.len() >= DEFINITION_LIFT {
break;
}
}
ids
} else {
Vec::new()
};
let (dense_scores, dense_ids) = self.index.search(vector, depth, &allowlist)?;
let seeds = dense_ids.clone();
let (dense_scores, dense_ids) = self.rescored(vector, dense_scores, dense_ids);
let keyword_ids = store::keyword_search(&self.db, query, depth, filter.groups())?;
let images = self.image_search(query, depth, filter)?;
let graph_ids = self.graph_expansion(&seeds, &keyword_ids, depth, filter)?;
let image_list: Vec<(u64, f32)> = images
.iter()
.map(|(id, similarity)| {
let weight = if *similarity >= image_floor() {
TEXT_LISTS
} else {
WEAK_IMAGE
};
(*id, weight)
})
.collect();
let candidate_lists = [
(
"definition",
false,
definitions.iter().map(|id| (*id, 0.0)).collect(),
),
("image", true, image_list),
(
"vector",
false,
dense_ids.iter().map(|id| (*id, 1.0)).collect(),
),
(
"keyword",
false,
keyword_ids
.iter()
.map(|id| (*id, shape.keyword_weight()))
.collect(),
),
(
"graph",
false,
graph_ids.iter().map(|r| (r.id, r.weight)).collect(),
),
];
let (mut fused, lists) = fuse(&candidate_lists, |name| shape.list_constant(name));
if !definitions.is_empty() {
let top = fused.iter().map(|(_, score)| *score).fold(0.0f32, f32::max);
for (at, id) in definitions.iter().enumerate() {
if let Some(slot) = fused.iter().position(|(key, _)| *key == (false, *id)) {
fused[slot].1 = top + (definitions.len() - at) as f32;
}
}
}
let mut ranked: Vec<Candidate> = fused.into_iter().zip(lists).collect();
ranked.sort_by(|a, b| b.0.1.total_cmp(&a.0.1).then_with(|| a.0.0.cmp(&b.0.0)));
ranked.truncate(depth.max(k));
let mut hits = Vec::with_capacity(ranked.len());
let mut proximity: Vec<f32> = Vec::with_capacity(ranked.len());
for (((is_image, id), score), found_by) in ranked {
if is_image {
if let Some(row) = store::image(&self.db, id as i64)? {
hits.push((
Hit {
score,
path: row.path,
start_line: 0,
end_line: 0,
text: String::new(),
store: None,
lists: found_by,
image: Some(Pixels {
width: row.width,
height: row.height,
}),
fresh: true,
symbol: None,
symbol_kind: None,
provenance: None,
},
0.0,
));
proximity.push(0.0);
}
continue;
}
if let Some(row) = store::chunk(&self.db, id)? {
let similarity = dense_ids
.iter()
.position(|d| *d == id)
.and_then(|i| dense_scores.get(i).copied())
.unwrap_or(0.0);
hits.push((
Hit {
score,
path: row.path,
start_line: row.start_line,
end_line: row.end_line,
text: row.text,
store: None,
lists: found_by,
image: None,
fresh: true,
symbol: None,
symbol_kind: None,
provenance: provenance_of(&graph_ids, id),
},
similarity,
));
proximity.push(
graph_ids
.iter()
.find(|r| r.id == id)
.map(|r| r.proximity)
.unwrap_or(0.0),
);
}
}
self.mark_freshness(&mut hits)?;
self.name_enclosing_symbols(&mut hits)?;
if shape != Shape::Identifier && rerank::enabled() && hits.len() > 1 {
let head = hits.len().min(rerank::RERANK_DEPTH);
let texts: Vec<String> = hits[..head]
.iter()
.map(|(hit, _)| {
let text: String = hit.text.chars().take(rerank::RERANK_CHARS).collect();
format!("{}\n{text}", hit.path)
})
.collect();
let quiet = self.quiet;
let cache = model_cache_dir().unwrap_or_default();
let order = rerank::with(&cache, quiet, |model| {
model.map(|model| rerank::order(model, query, &texts))
});
if let Some(order) = order {
match order {
Ok(order) if order.len() == head => {
let mut scores: Vec<f32> =
hits[..head].iter().map(|(hit, _)| hit.score).collect();
scores.sort_by(|a, b| b.total_cmp(a));
let mut by_fusion: Vec<usize> = (0..head).collect();
by_fusion.sort_by(|a, b| hits[*b].0.score.total_cmp(&hits[*a].0.score));
let mut fused_rank = vec![0usize; head];
for (rank, at) in by_fusion.into_iter().enumerate() {
fused_rank[at] = rank;
}
let mut blended: Vec<(usize, f32)> = order
.into_iter()
.enumerate()
.map(|(rescored, at)| {
let vote = 1.0 / (RRF_K + rescored as f32 + 1.0)
+ 1.0 / (RRF_K + fused_rank[at] as f32 + 1.0);
(at, vote)
})
.collect();
blended.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (rank, (at, _)) in blended.into_iter().enumerate() {
hits[at].0.score = scores[rank];
hits[at].0.lists.push("rerank");
}
}
Ok(_) => {}
Err(e) => {
if !quiet {
eprintln!("the rescoring pass failed, keeping the fused order: {e}");
}
}
}
}
}
for ((hit, _), proximity) in hits.iter_mut().zip(&proximity) {
hit.score *= 1.0 + GRAPH_PROXIMITY * proximity;
if !hit.fresh {
hit.score *= 1.0 - STALE_PENALTY;
}
hit.score *= prefer.multiplier(filter::is_code(&hit.path));
}
hits.sort_by(|a, b| {
b.0.score
.total_cmp(&a.0.score)
.then_with(|| a.0.path.cmp(&b.0.path))
.then_with(|| a.0.start_line.cmp(&b.0.start_line))
});
hits.truncate(k);
Ok(hits)
}
pub fn read(&self, target: &Target, filter: &Filter) -> Result<Option<Read>> {
let (path, start, end) = match target {
Target::Span { path, start, end } => {
let candidates = store::files_ending_with(&self.db, path, 8)?;
match candidates.len() {
0 => return Ok(None),
1 => (candidates[0].clone(), *start, *end),
_ => anyhow::bail!(
"{path:?} matches {} indexed files: {}. Name more of the path.",
candidates.len(),
candidates.join(", ")
),
}
}
Target::Symbol(name) => {
let found = store::symbols_named(&self.db, name, 200)?;
match found.len() {
0 => return Ok(None),
1 => {
let only = &found[0];
(only.path.clone(), only.start_line, only.end_line)
}
_ => return Ok(Some(Read::Choose(found))),
}
}
};
let allowed = if filter.is_empty() {
None
} else {
Some(store::filtered_chunk_ids(&self.db, filter.groups())?)
};
let chunks: Vec<store::ChunkRow> = store::chunks_overlapping(&self.db, &path, start, end)?
.into_iter()
.filter(|c| match &allowed {
Some(ids) => ids.contains(&(c.id as u64)),
None => true,
})
.collect();
if chunks.is_empty() {
return Ok(None);
}
let mut lines: Vec<(u32, String)> = Vec::new();
for chunk in &chunks {
for (offset, line) in chunk.text.lines().enumerate() {
let number = chunk.start_line + offset as u32;
if lines.iter().any(|(n, _)| *n == number) {
continue;
}
lines.push((number, line.to_string()));
}
}
lines.sort_by_key(|(number, _)| *number);
lines.retain(|(number, _)| *number >= start && *number <= end);
if lines.is_empty() {
return Ok(None);
}
let first = lines.first().map(|(n, _)| *n).unwrap_or(start);
let last = lines.last().map(|(n, _)| *n).unwrap_or(end);
let text = lines
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("\n");
let mut span = Span {
path,
start_line: first,
end_line: last,
text,
symbol: None,
symbol_kind: None,
fresh: true,
store: None,
};
self.name_enclosing_symbol(&mut span)?;
self.mark_span_freshness(&mut span)?;
Ok(Some(Read::One(span)))
}
fn name_enclosing_symbol(&self, span: &mut Span) -> Result<()> {
let by_file = store::symbols_in_files(&self.db, std::slice::from_ref(&span.path))?;
let Some(symbols) = by_file.get(&span.path) else {
return Ok(());
};
let contains = symbols
.iter()
.filter(|(start, end, _, _)| *start <= span.start_line && *end >= span.start_line)
.min_by_key(|(start, end, _, _)| end.saturating_sub(*start));
let best = contains.or_else(|| {
symbols
.iter()
.filter(|(start, end, _, _)| *start <= span.end_line && *end >= span.start_line)
.min_by_key(|(start, end, _, _)| end.saturating_sub(*start))
});
if let Some((_, _, name, kind)) = best {
span.symbol = Some(name.clone());
span.symbol_kind = Some(kind.clone());
}
Ok(())
}
fn mark_span_freshness(&self, span: &mut Span) -> Result<()> {
let stamps = store::file_stamps(&self.db, std::slice::from_ref(&span.path))?;
let Some((bytes, indexed_at)) = stamps.get(&span.path) else {
return Ok(());
};
span.fresh = match std::fs::metadata(&span.path) {
Ok(meta) => {
meta.len() as i64 == *bytes
&& meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.is_none_or(|d| d.as_secs() as i64 <= *indexed_at)
}
Err(_) => false,
};
Ok(())
}
fn name_enclosing_symbols(&self, hits: &mut [(Hit, f32)]) -> Result<()> {
let mut paths: Vec<String> = Vec::new();
for (hit, _) in hits.iter() {
if hit.image.is_none() && !paths.contains(&hit.path) {
paths.push(hit.path.clone());
}
}
let by_file = store::symbols_in_files(&self.db, &paths)?;
for (hit, _) in hits.iter_mut() {
let Some(symbols) = by_file.get(&hit.path) else {
continue;
};
let contains = symbols
.iter()
.filter(|(start, end, _, _)| *start <= hit.start_line && *end >= hit.start_line)
.min_by_key(|(start, end, _, _)| end.saturating_sub(*start));
let best = contains.or_else(|| {
symbols
.iter()
.filter(|(start, end, _, _)| *start <= hit.end_line && *end >= hit.start_line)
.min_by_key(|(start, end, _, _)| end.saturating_sub(*start))
});
if let Some((_, _, name, kind)) = best {
hit.symbol = Some(name.clone());
hit.symbol_kind = Some(kind.clone());
}
}
Ok(())
}
fn mark_freshness(&self, hits: &mut [(Hit, f32)]) -> Result<()> {
if hits.is_empty() {
return Ok(());
}
let paths: Vec<String> = {
let mut seen: Vec<String> = Vec::new();
for (hit, _) in hits.iter() {
if !seen.contains(&hit.path) {
seen.push(hit.path.clone());
}
}
seen
};
let stamps = store::file_stamps(&self.db, &paths)?;
let mut fresh: std::collections::HashMap<&str, bool> = std::collections::HashMap::new();
for path in &paths {
let Some((bytes, indexed_at)) = stamps.get(path) else {
fresh.insert(path.as_str(), true);
continue;
};
let state = match std::fs::metadata(path) {
Ok(meta) => {
let size_matches = meta.len() as i64 == *bytes;
let unmodified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.is_none_or(|d| d.as_secs() as i64 <= *indexed_at);
size_matches && unmodified
}
Err(_) => false,
};
fresh.insert(path.as_str(), state);
}
for (hit, _) in hits.iter_mut() {
hit.fresh = fresh.get(hit.path.as_str()).copied().unwrap_or(true);
}
Ok(())
}
fn image_search(
&mut self,
query: &str,
depth: usize,
filter: &Filter,
) -> Result<Vec<(u64, f32)>> {
if store::image_count(&self.db)? == 0 {
return Ok(Vec::new());
}
let allowlist = if filter.is_empty() {
index::Allowlist::All
} else {
let mut ids = Vec::new();
for id in store::filtered_image_ids(&self.db, filter.groups())? {
if self.images.contains(id as u64)? {
ids.push(id as u64);
}
}
if ids.is_empty() {
return Ok(Vec::new());
}
index::Allowlist::Subset(ids)
};
let mut vector = self.clip.embed_query(query, self.quiet)?;
normalize(&mut vector);
let (scores, ids) = self.images.search(&vector, depth, &allowlist)?;
Ok(ids
.into_iter()
.zip(scores.into_iter().chain(std::iter::repeat(0.0)))
.collect())
}
pub fn image_count(&self) -> Result<i64> {
store::image_count(&self.db)
}
pub fn stats(&self) -> Result<(i64, i64, i64)> {
store::stats(&self.db)
}
pub fn exact_bytes(&self) -> Option<u64> {
self.exact.exists().then(|| self.exact.bytes())
}
pub fn retired_symbols(&self) -> Result<i64> {
store::symbols_past_count(&self.db)
}
pub fn save(&mut self) -> Result<()> {
self.index.save()?;
self.images.save()?;
self.generation = generation(&self.db)? + 1;
store::set_meta(&self.db, GENERATION, &self.generation.to_string())?;
Ok(())
}
}
#[derive(Default)]
struct Batch {
ids: Vec<u64>,
texts: Vec<String>,
}
pub(crate) fn normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in v {
*x /= norm;
}
}
}
fn generation(db: &Connection) -> Result<u64> {
Ok(store::get_meta(db, GENERATION)?
.and_then(|v| v.parse().ok())
.unwrap_or(0))
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn human_bytes(bytes: i64) -> String {
const KB: f64 = 1024.0;
let b = bytes as f64;
if b >= KB * KB {
format!("{:.1} MB", b / (KB * KB))
} else if b >= KB {
format!("{:.0} KB", b / KB)
} else {
format!("{bytes} B")
}
}
pub fn serialize_plain<S: serde::Serializer>(path: &str, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&plain(path))
}
static CANONICAL_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn canonical_calls() -> u64 {
CANONICAL_CALLS.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn canonical(path: &Path) -> PathBuf {
CANONICAL_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn plain(text: &str) -> String {
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
match text.strip_prefix(r"\\?\") {
Some(rest)
if rest.len() >= 2
&& rest.as_bytes()[0].is_ascii_alphabetic()
&& rest.as_bytes()[1] == b':' =>
{
rest.to_string()
}
_ => text.to_string(),
}
}
pub const MODEL_CACHE_ENV: &str = "SEMLITH_MODEL_CACHE";
pub fn model_cache_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var(MODEL_CACHE_ENV) {
return Ok(PathBuf::from(dir));
}
Ok(home::user_home()?
.join(".cache")
.join("semlith")
.join("models"))
}
pub fn check_roots(roots: &[PathBuf]) -> (Vec<PathBuf>, Vec<(PathBuf, String)>) {
let mut readable = Vec::new();
let mut refused = Vec::new();
for root in roots {
let listed = match std::fs::metadata(root) {
Ok(meta) if meta.is_dir() => std::fs::read_dir(root).map(|_| ()),
Ok(_) => std::fs::File::open(root).map(|_| ()),
Err(e) => Err(e),
};
match listed {
Ok(()) => readable.push(root.clone()),
Err(e) => refused.push((root.clone(), e.to_string())),
}
}
(readable, refused)
}
fn walk_error_path(e: &ignore::Error) -> Option<PathBuf> {
match e {
ignore::Error::WithPath { path, .. } => Some(path.clone()),
ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
walk_error_path(err)
}
ignore::Error::Loop { child, .. } => Some(child.clone()),
_ => None,
}
}
pub(crate) struct Walked {
pub files: Vec<PathBuf>,
pub named: Vec<PathBuf>,
pub unreadable: Vec<(PathBuf, String)>,
pub generated: Vec<PathBuf>,
}
const ALWAYS: &[&str] = &[
"node_modules",
"bower_components",
".next",
".nuxt",
".svelte-kit",
".turbo",
".parcel-cache",
".yarn",
"__pycache__",
".venv",
"site-packages",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".eggs",
".gradle",
"DerivedData",
".swiftpm",
".dart_tool",
"_build",
".terraform",
".serverless",
".gradle-cache",
];
const GENERATED: &[(&str, &[&str])] = &[
("target", &["Cargo.toml"]),
("vendor", &["go.mod", "composer.json", "Gemfile"]),
("deps", &["mix.exs"]),
(
"build",
&[
"build.gradle",
"build.gradle.kts",
"pom.xml",
"CMakeLists.txt",
"meson.build",
"package.json",
],
),
(
"dist",
&[
"package.json",
"pyproject.toml",
"setup.py",
"rollup.config.js",
],
),
("out", &["package.json", "tsconfig.json"]),
("bin", &["*.csproj", "*.sln", "*.fsproj"]),
("obj", &["*.csproj", "*.sln", "*.fsproj"]),
("coverage", &["package.json", "pyproject.toml"]),
("Pods", &["Podfile"]),
];
pub fn is_generated_dir(path: &Path) -> bool {
if !default_ignores_on(std::env::var("SEMLITH_DEFAULT_IGNORES").ok().as_deref()) {
return false;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if ALWAYS.contains(&name) {
return true;
}
let Some(parent) = path.parent() else {
return false;
};
GENERATED
.iter()
.filter(|(dir, _)| *dir == name)
.any(|(_, manifests)| manifests.iter().any(|m| sibling_exists(parent, m)))
}
pub fn default_ignores_on(value: Option<&str>) -> bool {
!matches!(value, Some("0") | Some("off") | Some("false"))
}
fn sibling_exists(parent: &Path, manifest: &str) -> bool {
let Some(extension) = manifest.strip_prefix("*.") else {
return parent.join(manifest).exists();
};
std::fs::read_dir(parent)
.map(|entries| {
entries.flatten().any(|entry| {
entry
.path()
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case(extension))
})
})
.unwrap_or(false)
}
fn walk(roots: &[PathBuf]) -> Walked {
let mut out = Vec::new();
let mut named = Vec::new();
let mut unreadable = Vec::new();
let mut seen = std::collections::HashSet::new();
let generated: std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>> = Default::default();
for root in roots {
if root.is_file() {
let path = canonical(root);
if seen.insert(path.clone()) {
named.push(path);
}
continue;
}
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.filter_entry({
let generated = generated.clone();
move |e| {
if e.file_name() == ".semlith" {
return false;
}
if e.file_type().is_some_and(|t| t.is_dir()) && is_generated_dir(e.path()) {
if let Ok(mut seen) = generated.lock() {
seen.push(e.path().to_path_buf());
}
return false;
}
true
}
});
for result in builder.build() {
let entry = match result {
Ok(e) => e,
Err(e) => {
let at = walk_error_path(&e).unwrap_or_else(|| root.clone());
unreadable.push((at, format!("the walk could not read it: {e}")));
continue;
}
};
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = canonical(entry.path());
if seen.insert(path.clone()) {
out.push(path);
}
}
}
out.sort();
named.sort();
unreadable.sort();
let mut generated = generated.lock().map(|g| g.clone()).unwrap_or_default();
generated.sort();
generated.dedup();
Walked {
files: out,
named,
unreadable,
generated,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_graph_list_adds_candidates_and_does_not_amplify_them() {
let both = 10u64;
let only_graph = 99u64;
let lists: [CandidateList; 3] = [
("vector", false, vec![(both, 1.0), (11, 1.0)]),
("keyword", false, vec![(both, 1.0)]),
("graph", false, vec![(both, 1.0), (only_graph, 1.0)]),
];
let text_only: [CandidateList; 2] = [lists[0].clone(), lists[1].clone()];
let (with_graph, badges) = fuse(&lists, |_| RRF_K);
let (without_graph, _) = fuse(&text_only, |_| RRF_K);
let score = |fused: &[Scored], id: u64| {
fused
.iter()
.find(|(key, _)| *key == (false, id))
.map(|(_, score)| *score)
.expect("the candidate is in the fused set")
};
assert_eq!(
score(&with_graph, both),
score(&without_graph, both),
"a chunk both text lists found was scored a third time by the graph"
);
let slot = with_graph
.iter()
.position(|(key, _)| *key == (false, both))
.unwrap();
assert_eq!(badges[slot], vec!["vector", "keyword", "graph"]);
assert!(
with_graph
.iter()
.any(|(key, _)| *key == (false, only_graph)),
"the graph list stopped adding candidates of its own"
);
}
#[test]
fn a_query_is_identifier_shaped_only_when_it_is_one_token_of_identifier() {
for identifier in [
"record_retrieval",
"store::edges_out",
"Semlith",
"self.index.search",
"format!",
"k8s",
] {
assert_eq!(
shape_of(identifier),
Shape::Identifier,
"{identifier:?} is an identifier"
);
}
for question in [
"where does a retrieval get written down",
"record retrieval",
"how does indexing work?",
"",
" ",
"42",
] {
assert_eq!(shape_of(question), Shape::Question, "{question:?}");
}
}
#[test]
fn the_shape_decides_what_the_keyword_list_is_worth() {
assert_eq!(Shape::Identifier.keyword_weight(), 2.0);
assert_eq!(Shape::Question.keyword_weight(), 1.0);
assert_eq!(Shape::Identifier.weighting(), "keyword weighted 2×");
assert_eq!(Shape::Question.weighting(), "vector and keyword equal");
}
#[test]
fn prefer_lifts_one_side_and_never_removes_the_other() {
assert_eq!(Prefer::Any.multiplier(true), 1.0);
assert_eq!(Prefer::Any.multiplier(false), 1.0);
assert!(Prefer::Code.multiplier(true) > Prefer::Code.multiplier(false));
assert!(Prefer::Docs.multiplier(false) > Prefer::Docs.multiplier(true));
assert!(Prefer::Code.multiplier(false) > 0.0);
assert!(Prefer::Docs.multiplier(true) > 0.0);
}
#[test]
fn an_unknown_preference_is_an_error() {
assert_eq!(Prefer::parse("code").unwrap(), Prefer::Code);
assert_eq!(Prefer::parse("DOCS").unwrap(), Prefer::Docs);
assert_eq!(Prefer::parse("").unwrap(), Prefer::Any);
assert!(Prefer::parse("source").is_err());
}
#[test]
fn every_rerank_factor_is_a_tiebreak_rather_than_a_ranking() {
let strongest = 1.0 + GRAPH_PROXIMITY;
let weakest = 1.0 - STALE_PENALTY;
assert!(
strongest / weakest < 1.5,
"the whole rerank spans {strongest}/{weakest}, which is a ranking rather than a \
tiebreak"
);
const { assert!(GRAPH_PROXIMITY > 0.0 && STALE_PENALTY > 0.0) };
const { assert!(STALE_PENALTY < 1.0) };
}
#[test]
fn a_read_target_is_a_span_or_a_name_by_its_shape() {
assert_eq!(
Target::parse("src/store.rs:1041-1080"),
Target::Span {
path: "src/store.rs".to_string(),
start: 1041,
end: 1080
}
);
assert_eq!(
Target::parse("src/store.rs:12"),
Target::Span {
path: "src/store.rs".to_string(),
start: 12,
end: 12
}
);
assert_eq!(
Target::parse("a.rs:80-40"),
Target::Span {
path: "a.rs".to_string(),
start: 40,
end: 80
}
);
for name in [
"record_retrieval",
"store::edges_out",
"src/store.rs",
"a.rs:notanumber",
] {
assert!(
matches!(Target::parse(name), Target::Symbol(_)),
"{name:?} is a name"
);
}
}
#[test]
fn a_hit_that_straddles_a_definitions_start_is_labelled_with_it() {
let symbols = [
(100u32, 180u32, "outer".to_string(), "function".to_string()),
(120u32, 140u32, "inner".to_string(), "function".to_string()),
];
let pick = |start: u32, end: u32| {
let contains = symbols
.iter()
.filter(|(s, e, _, _)| *s <= start && *e >= start)
.min_by_key(|(s, e, _, _)| e.saturating_sub(*s));
contains
.or_else(|| {
symbols
.iter()
.filter(|(s, e, _, _)| *s <= end && *e >= start)
.min_by_key(|(s, e, _, _)| e.saturating_sub(*s))
})
.map(|(_, _, name, _)| name.as_str())
};
assert_eq!(pick(115, 130), Some("outer"));
assert_eq!(pick(90, 105), Some("outer"));
assert_eq!(pick(125, 135), Some("inner"));
assert_eq!(pick(200, 210), None);
}
#[test]
fn normalize_gives_unit_length() {
let mut v = vec![3.0, 4.0];
normalize(&mut v);
assert!((v.iter().map(|x| x * x).sum::<f32>() - 1.0).abs() < 1e-6);
let mut z = vec![0.0, 0.0];
normalize(&mut z);
assert_eq!(z, vec![0.0, 0.0]);
}
#[test]
fn one_large_file_does_not_queue_more_than_a_batch() {
let text = "a line of perfectly ordinary text\n".repeat(4000);
let chunks = chunk::chunk_text(&text);
assert!(
chunks.len() > EMBED_BATCH * 4,
"test file is too small to prove anything: {} chunks",
chunks.len()
);
let mut queued = 0usize;
let mut high_water = 0usize;
for _ in &chunks {
queued += 1;
high_water = high_water.max(queued);
if queued >= EMBED_BATCH {
queued = 0;
}
}
assert_eq!(
high_water, EMBED_BATCH,
"queue grew past one batch within a single file"
);
}
#[test]
fn a_new_store_records_the_default_model_and_reopens_on_it() {
let dir = tempdir();
let created = Semlith::open(&dir, None).unwrap();
assert_eq!(*created.model(), default_model());
assert_eq!(created.dim(), 384);
drop(created);
let reopened = Semlith::open(&dir, None).unwrap();
assert_eq!(*reopened.model(), default_model());
}
#[test]
fn an_existing_store_keeps_its_own_model() {
use fastembed::EmbeddingModel;
let dir = tempdir2();
let old = Model::Builtin(EmbeddingModel::BGESmallENV15);
let created = Semlith::open(&dir, Some(old.clone())).unwrap();
assert_eq!(*created.model(), old);
drop(created);
let reopened = Semlith::open(&dir, None).unwrap();
assert_eq!(*reopened.model(), old);
assert!(Semlith::open(&dir, Some(Model::Granite)).is_err());
}
fn tempdir() -> PathBuf {
let d = std::env::temp_dir().join(format!("semlith-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn tempdir2() -> PathBuf {
let d = std::env::temp_dir().join(format!("semlith-test-b-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
}
#[cfg(test)]
mod plain_path_tests {
use super::plain;
#[test]
fn a_verbatim_prefix_is_not_what_anybody_pastes_back() {
assert_eq!(
plain(r"\\?\C:\work\api\src\lock.rs"),
r"C:\work\api\src\lock.rs"
);
assert_eq!(plain(r"\\?\c:\work"), r"c:\work");
assert_eq!(
plain(r"\\?\UNC\server\share\notes.md"),
r"\\server\share\notes.md"
);
let volume = r"\\?\Volume{9f8a7b6c-0000-0000-0000-000000000000}\data";
assert_eq!(plain(volume), volume);
assert_eq!(
plain("/home/someone/api/src/lock.rs"),
"/home/someone/api/src/lock.rs"
);
assert_eq!(plain(r"C:\work\api"), r"C:\work\api");
assert_eq!(
plain(r"\\server\share\notes.md"),
r"\\server\share\notes.md"
);
assert_eq!(plain(""), "");
}
}