Initial commit

This commit is contained in:
Breadway 2026-07-02 20:59:18 +08:00
commit 2778f14574
29 changed files with 7110 additions and 0 deletions

142
breadmill/src/chunk.rs Normal file
View file

@ -0,0 +1,142 @@
pub struct Chunk {
pub text: String,
pub start: usize,
pub end: usize,
}
/// Split `text` into overlapping word-based windows, then enforce `max_chunk_chars`.
///
/// Any word-window that exceeds `max_chunk_chars` characters is split further at
/// character boundaries so that no chunk passed to the embedder is pathologically
/// large (e.g. minified JSON where a single "word" is hundreds of KB).
///
/// Set `max_chunk_chars = 0` to skip the character cap.
pub fn chunk_text(text: &str, words_per_chunk: usize, overlap_words: usize, max_chunk_chars: usize) -> Vec<Chunk> {
let word_chunks = chunk_by_words(text, words_per_chunk, overlap_words);
if max_chunk_chars == 0 {
return word_chunks;
}
let mut result = Vec::new();
for chunk in word_chunks {
if chunk.text.len() <= max_chunk_chars {
result.push(chunk);
} else {
result.extend(split_by_chars(chunk, max_chunk_chars));
}
}
result
}
fn chunk_by_words(text: &str, words_per_chunk: usize, overlap_words: usize) -> Vec<Chunk> {
let mut positions: Vec<(usize, usize)> = Vec::new();
let mut in_word = false;
let mut word_start = 0;
for (i, c) in text.char_indices() {
if c.is_whitespace() {
if in_word {
positions.push((word_start, i));
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
positions.push((word_start, text.len()));
}
if positions.is_empty() {
return Vec::new();
}
let step = words_per_chunk.saturating_sub(overlap_words).max(1);
let mut chunks = Vec::new();
let mut i = 0;
while i < positions.len() {
let last = (i + words_per_chunk - 1).min(positions.len() - 1);
let start = positions[i].0;
let end = positions[last].1;
chunks.push(Chunk {
text: text[start..end].to_string(),
start,
end,
});
if last == positions.len() - 1 {
break;
}
i += step;
}
chunks
}
fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
let text = &chunk.text;
let mut result = Vec::new();
let mut seg_start = 0usize;
let mut count = 0usize;
for (byte_idx, _) in text.char_indices() {
if count > 0 && count % max_chars == 0 {
result.push(Chunk {
text: text[seg_start..byte_idx].to_string(),
start: chunk.start + seg_start,
end: chunk.start + byte_idx,
});
seg_start = byte_idx;
}
count += 1;
}
if seg_start < text.len() {
result.push(Chunk {
text: text[seg_start..].to_string(),
start: chunk.start + seg_start,
end: chunk.end,
});
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_chunk() {
let text = "one two three four five six seven eight nine ten";
let chunks = chunk_text(text, 4, 1, 0);
assert!(!chunks.is_empty());
for c in &chunks {
assert!(!c.text.is_empty());
assert!(c.start <= c.end);
assert_eq!(&text[c.start..c.end], c.text);
}
}
#[test]
fn char_cap_splits_large_chunks() {
// Simulate a "word" that is 200 chars long — exceeds cap of 50.
let text = "a".repeat(200);
let chunks = chunk_text(&text, 1, 0, 50);
assert_eq!(chunks.len(), 4);
for c in &chunks {
assert!(c.text.len() <= 50);
assert_eq!(&text[c.start..c.end], c.text);
}
}
#[test]
fn char_cap_disabled() {
let text = "a".repeat(200);
let chunks = chunk_text(&text, 1, 0, 0);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].text.len(), 200);
}
}

258
breadmill/src/embed.rs Normal file
View file

@ -0,0 +1,258 @@
use std::path::{Path, PathBuf};
use ort::{
session::{Session, builder::{GraphOptimizationLevel, SessionBuilder}},
value::Tensor,
};
use tokenizers::Tokenizer;
const DOCUMENT_PREFIX: &str = "search_document: ";
const QUERY_PREFIX: &str = "search_query: ";
/// Hard token cap for nomic-embed-text-v1.5 (8192-token context window).
/// Sequences longer than this are truncated before ONNX inference to prevent
/// quadratic attention memory blowup.
pub const MAX_SEQ_LEN: usize = 8192;
pub enum Backend {
Cpu,
/// AMD XDNA NPU via the VitisAI ONNX Runtime execution provider.
/// `cache_dir` is used to store the compiled NPU model between runs.
Npu { cache_dir: PathBuf },
/// AMD iGPU via the ROCm ONNX Runtime execution provider.
Rocm,
}
pub struct OrtEmbedder {
session: Session,
tokenizer: Tokenizer,
dim: usize,
}
impl OrtEmbedder {
pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize, backend: Backend) -> Result<Self, String> {
let builder = Session::builder()
.map_err(|e| e.to_string())?
.with_optimization_level(GraphOptimizationLevel::All)
.map_err(|e| e.to_string())?;
let mut builder = configure_eps(builder, &backend)?;
let session = builder.commit_from_file(model_path).map_err(|e| e.to_string())?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| e.to_string())?;
Ok(Self { session, tokenizer, dim })
}
pub fn embed_document(&mut self, text: &str) -> Result<Vec<f32>, String> {
self.embed_with_prefix(text, DOCUMENT_PREFIX)
}
pub fn embed_query(&mut self, text: &str) -> Result<Vec<f32>, String> {
self.embed_with_prefix(text, QUERY_PREFIX)
}
fn embed_with_prefix(&mut self, text: &str, prefix: &str) -> Result<Vec<f32>, String> {
let input = format!("{}{}", prefix, text);
let encoding = self
.tokenizer
.encode(input, true)
.map_err(|e| e.to_string())?;
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mut mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect();
let mut type_ids: Vec<i64> = encoding
.get_type_ids()
.iter()
.map(|&x| x as i64)
.collect();
if ids.len() > MAX_SEQ_LEN {
eprintln!(
"breadmill: truncating {} tokens to {} (chunk too large)",
ids.len(),
MAX_SEQ_LEN
);
ids.truncate(MAX_SEQ_LEN);
mask.truncate(MAX_SEQ_LEN);
type_ids.truncate(MAX_SEQ_LEN);
}
let seq_len = ids.len() as i64;
let id_tensor =
Tensor::<i64>::from_array((vec![1i64, seq_len], ids.clone())).map_err(|e| e.to_string())?;
let mask_tensor =
Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone())).map_err(|e| e.to_string())?;
let type_tensor =
Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids)).map_err(|e| e.to_string())?;
let outputs = self
.session
.run(ort::inputs! {
"input_ids" => id_tensor,
"attention_mask" => mask_tensor,
"token_type_ids" => type_tensor,
})
.map_err(|e| e.to_string())?;
// last_hidden_state: shape [1, seq_len, dim]
let (shape, data) = outputs["last_hidden_state"]
.try_extract_tensor::<f32>()
.map_err(|e| e.to_string())?;
let actual_seq = shape[1] as usize;
let actual_dim = shape[2] as usize;
// Mean-pool over non-padding positions
let mut result = vec![0.0f32; actual_dim];
let mut count = 0usize;
for t in 0..actual_seq {
if mask[t] > 0 {
for d in 0..actual_dim {
result[d] += data[t * actual_dim + d];
}
count += 1;
}
}
if count > 0 {
for x in &mut result {
*x /= count as f32;
}
}
l2_normalize(&mut result);
// Clamp/pad to configured dim
result.truncate(self.dim);
while result.len() < self.dim {
result.push(0.0);
}
Ok(result)
}
}
fn l2_normalize(v: &mut Vec<f32>) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-10 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
// ---- Execution provider selection -------------------------------------------
fn configure_eps(builder: SessionBuilder, backend: &Backend) -> Result<SessionBuilder, String> {
match backend {
Backend::Cpu => Ok(builder),
Backend::Npu { cache_dir } => npu_session(builder, cache_dir),
Backend::Rocm => rocm_session(builder),
}
}
#[cfg(feature = "npu")]
fn npu_session(builder: SessionBuilder, cache_dir: &Path) -> Result<SessionBuilder, String> {
let vitis_ep = build_vitis_ep(cache_dir)?;
eprintln!("breadmill: using NPU (VitisAI) execution provider");
if std::env::var("ORT_DYLIB_PATH").is_err() {
eprintln!(
"breadmill: hint — set ORT_DYLIB_PATH to the Ryzen AI SDK ORT, e.g.:\n \
ORT_DYLIB_PATH=~/.local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"
);
}
builder
.with_execution_providers([vitis_ep, ort::ep::CPU::default().build()])
.map_err(|e| e.to_string())
}
#[cfg(not(feature = "npu"))]
fn npu_session(builder: SessionBuilder, _cache_dir: &Path) -> Result<SessionBuilder, String> {
eprintln!("breadmill: NPU backend requested but not compiled in (rebuild with --features npu); using CPU");
Ok(builder)
}
// ---- VitisAI EP (NPU) -------------------------------------------------------
#[cfg(feature = "npu")]
fn build_vitis_ep(cache_dir: &Path) -> Result<ort::ep::ExecutionProviderDispatch, String> {
let vaip_config = find_vaip_config()?;
let npu_cache = cache_dir.join("npu");
std::fs::create_dir_all(&npu_cache).map_err(|e| e.to_string())?;
eprintln!("breadmill: vaip_config: {}", vaip_config.display());
eprintln!("breadmill: NPU model cache: {}", npu_cache.display());
Ok(ort::ep::Vitis::default()
.with_config_file(vaip_config.to_string_lossy())
.with_cache_dir(npu_cache.to_string_lossy())
.with_cache_key("nomic-embed-text-v1.5")
.build())
}
// ---- ROCm EP (AMD iGPU) -----------------------------------------------------
#[cfg(feature = "rocm")]
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
eprintln!("breadmill: using ROCm execution provider (device 0)");
builder
.with_execution_providers([
ort::execution_providers::ROCmExecutionProvider::default().build(),
ort::ep::CPU::default().build(),
])
.map_err(|e| e.to_string())
}
#[cfg(not(feature = "rocm"))]
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
eprintln!("breadmill: ROCm backend requested but not compiled in (rebuild with --features rocm); using CPU");
Ok(builder)
}
/// Locate the VitisAI EP config file required by the AMD Ryzen AI SDK.
///
/// Search order:
/// 1. `VAIP_CONFIG` environment variable
/// 2. `~/.config/breadsearch/vaip_config.json`
/// 3. `/etc/vaip_config.json`
/// 4. `/opt/xilinx/vaip_config.json`
#[cfg(feature = "npu")]
fn find_vaip_config() -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("VAIP_CONFIG") {
let path = PathBuf::from(&p);
if path.exists() {
return Ok(path);
}
return Err(format!("VAIP_CONFIG={p} does not exist"));
}
let user_path = breadsearch_shared::config_dir().join("vaip_config.json");
if user_path.exists() {
return Ok(user_path);
}
// Standard system / SDK paths (checked in priority order)
let home = std::env::var("HOME").unwrap_or_default();
let sdk_paths = [
format!("{home}/.local/share/ryzen-ai-1.7.1/voe-4.0-linux_x86_64/vaip_config.json"),
"/etc/vaip_config.json".into(),
"/opt/xilinx/vaip_config.json".into(),
];
for p in &sdk_paths {
let path = Path::new(p.as_str());
if path.exists() {
return Ok(path.to_path_buf());
}
}
Err(
"vaip_config.json not found; set VAIP_CONFIG=/path/to/vaip_config.json, \
copy to ~/.config/breadsearch/vaip_config.json, or install the AMD Ryzen AI SDK"
.into(),
)
}

84
breadmill/src/extract.rs Normal file
View file

@ -0,0 +1,84 @@
use std::{
fs, io::Read, path::Path,
};
pub type ExtractResult = Result<String, String>;
/// Extract plain text from a file based on its extension.
/// Returns Err on hard failures; Err with message if format unsupported.
pub fn extract(path: &Path) -> ExtractResult {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"md" | "txt" | "org" => read_text(path),
"pdf" => extract_pdf(path),
"docx" => extract_docx(path),
"odt" => extract_odt(path),
other => Err(format!("unsupported extension: {}", other)),
}
}
fn read_text(path: &Path) -> ExtractResult {
fs::read_to_string(path).map_err(|e| e.to_string())
}
fn extract_pdf(path: &Path) -> ExtractResult {
// pdf-extract panics on some malformed PDFs; catch_unwind prevents indexer thread death.
let path = path.to_path_buf();
match std::panic::catch_unwind(|| pdf_extract::extract_text(&path)) {
Ok(result) => result.map_err(|e| e.to_string()),
Err(_) => Err("pdf-extract panicked on malformed content stream".into()),
}
}
fn extract_docx(path: &Path) -> ExtractResult {
extract_office_xml(path, "word/document.xml", "w:t")
}
fn extract_odt(path: &Path) -> ExtractResult {
extract_office_xml(path, "content.xml", "text:p")
}
/// Open a zip-based office format and concatenate text from the named XML entry.
/// We grab all Text events as a best-effort extraction.
fn extract_office_xml(path: &Path, xml_entry: &str, _tag_hint: &str) -> ExtractResult {
let file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut xml_bytes = Vec::new();
archive
.by_name(xml_entry)
.map_err(|e| format!("entry '{}' not found: {}", xml_entry, e))?
.read_to_end(&mut xml_bytes)
.map_err(|e| e.to_string())?;
let xml_str = String::from_utf8_lossy(&xml_bytes);
let mut reader = quick_xml::Reader::from_str(&xml_str);
reader.config_mut().trim_text(true);
let mut text = String::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(quick_xml::events::Event::Text(e)) => {
if let Ok(s) = e.decode() {
if !text.is_empty() {
text.push(' ');
}
text.push_str(&s);
}
}
Ok(quick_xml::events::Event::Eof) => break,
Err(e) => return Err(e.to_string()),
_ => {}
}
buf.clear();
}
Ok(text)
}

414
breadmill/src/indexer.rs Normal file
View file

@ -0,0 +1,414 @@
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}},
time::{Duration, Instant, UNIX_EPOCH},
};
use ignore::WalkBuilder;
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
use sha2::{Digest, Sha256};
use crate::{embed::OrtEmbedder, extract, chunk, power, store::Store};
pub struct SharedState {
pub store: Mutex<Store>,
pub embedder: Mutex<Option<OrtEmbedder>>,
pub model_ready: AtomicBool,
pub indexed: AtomicUsize,
pub pending: AtomicUsize,
pub reindex_signal: AtomicBool,
}
impl SharedState {
pub fn new(store: Store) -> Self {
SharedState {
store: Mutex::new(store),
embedder: Mutex::new(None),
model_ready: AtomicBool::new(false),
indexed: AtomicUsize::new(0),
pending: AtomicUsize::new(0),
reindex_signal: AtomicBool::new(false),
}
}
}
pub struct Indexer {
state: Arc<SharedState>,
config: breadsearch_shared::Config,
state_dir: PathBuf,
}
impl Indexer {
pub fn new(state: Arc<SharedState>, config: breadsearch_shared::Config, state_dir: PathBuf) -> Self {
Indexer { state, config, state_dir }
}
pub fn run(self) {
self.initial_scan();
self.watch_loop();
}
/// True when embedding should be skipped right now: the user turned
/// indexing off entirely, or the machine is on battery and
/// `power.run_on_battery` is not set. Cheap sysfs reads — safe to call
/// per-file and on every watch_loop tick.
fn indexing_paused(&self) -> bool {
if !self.config.power.enabled {
return true;
}
!self.config.power.run_on_battery && !power::on_ac_power()
}
pub fn full_reindex(&self) {
eprintln!("breadmill: full reindex triggered");
{
let mut store = self.state.store.lock().unwrap();
// Clear all state
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
let _ = store.index.reserve(4096);
}
self.initial_scan();
}
fn initial_scan(&self) {
eprintln!("breadmill: scanning roots...");
let roots: Vec<PathBuf> = self.config.index.roots
.iter()
.map(|r| expand_home(r))
.collect();
let excludes: Vec<PathBuf> = self.config.index.excludes
.iter()
.map(|r| expand_home(r))
.collect();
// Snapshot existing indexed files
let known: HashMap<String, (i64, String)> = {
let store = self.state.store.lock().unwrap();
store.all_files()
.unwrap_or_default()
.into_iter()
.map(|f| (f.path, (f.mtime, f.hash)))
.collect()
};
let mut seen: HashSet<String> = HashSet::new();
let max_bytes = (self.config.index.max_file_mb * 1024.0 * 1024.0) as u64;
for root in &roots {
if !root.exists() {
continue;
}
for entry in WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
{
let path = entry.path();
if excludes.iter().any(|excl| path.starts_with(excl)) {
continue;
}
if !self.is_indexed_extension(path) {
continue;
}
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(_) => continue,
};
if meta.len() > max_bytes {
continue;
}
let path_str = path.to_string_lossy().into_owned();
seen.insert(path_str.clone());
let mtime = mtime_secs(&meta);
if let Some((known_mtime, _)) = known.get(&path_str) {
if *known_mtime == mtime {
continue; // unchanged
}
}
self.state.pending.fetch_add(1, Ordering::Relaxed);
self.index_file(path, &path_str, mtime);
self.state.pending.fetch_sub(1, Ordering::Relaxed);
}
}
// Drop files that were deleted
let to_delete: Vec<String> = known
.keys()
.filter(|p| !seen.contains(*p))
.cloned()
.collect();
if !to_delete.is_empty() {
let mut store = self.state.store.lock().unwrap();
for path in to_delete {
eprintln!("breadmill: removing deleted file: {}", path);
let _ = store.delete_file(&path);
}
}
let count = {
let store = self.state.store.lock().unwrap();
let n = store.chunk_count();
let _ = store.save_index(&self.state_dir);
n
};
self.state.indexed.store(count, Ordering::Relaxed);
eprintln!("breadmill: initial scan done — {} chunks indexed", count);
}
fn watch_loop(self) {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher: RecommendedWatcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
eprintln!("breadmill: watcher init failed: {}", e);
return;
}
};
for root in self.config.index.roots.iter().map(|r| expand_home(r)) {
if root.exists() {
let _ = watcher.watch(&root, RecursiveMode::Recursive);
}
}
let mut pending_paths: HashSet<PathBuf> = HashSet::new();
let mut last_event = Instant::now();
let quiet = Duration::from_secs(2);
let mut was_paused = self.indexing_paused();
let mut last_power_check = Instant::now();
eprintln!("breadmill: watching for changes");
loop {
// Drain the reindex signal
if self.state.reindex_signal.swap(false, Ordering::Relaxed) {
self.full_reindex();
}
// Re-check the power gate periodically (sysfs reads are cheap but
// no need to do it every 500ms tick). On a paused->active
// transition, re-run the incremental scan to catch up anything
// skipped while gated.
if last_power_check.elapsed() >= Duration::from_secs(30) {
last_power_check = Instant::now();
let now_paused = self.indexing_paused();
if was_paused && !now_paused {
eprintln!("breadmill: power gate opened — resuming indexing");
self.initial_scan();
}
was_paused = now_paused;
}
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(Ok(event)) => {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
for p in event.paths {
pending_paths.insert(p);
}
last_event = Instant::now();
}
_ => {}
}
}
Ok(Err(e)) => eprintln!("breadmill: watch error: {}", e),
Err(_) => {} // timeout — check quiet period
}
if !pending_paths.is_empty() && last_event.elapsed() >= quiet {
for path in pending_paths.drain() {
self.handle_fs_event(&path);
}
let count = {
let store = self.state.store.lock().unwrap();
let n = store.chunk_count();
let _ = store.save_index(&self.state_dir);
n
};
self.state.indexed.store(count, Ordering::Relaxed);
}
}
}
fn handle_fs_event(&self, path: &Path) {
let excludes: Vec<PathBuf> = self.config.index.excludes
.iter()
.map(|r| expand_home(r))
.collect();
if excludes.iter().any(|excl| path.starts_with(excl)) {
return;
}
if !path.is_file() {
let path_str = path.to_string_lossy().into_owned();
// File deleted — remove from index
let mut store = self.state.store.lock().unwrap();
let _ = store.delete_file(&path_str);
return;
}
if !self.is_indexed_extension(path) {
return;
}
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(_) => return,
};
let max_bytes = (self.config.index.max_file_mb * 1024.0 * 1024.0) as u64;
if meta.len() > max_bytes {
return;
}
let path_str = path.to_string_lossy().into_owned();
let mtime = mtime_secs(&meta);
self.state.pending.fetch_add(1, Ordering::Relaxed);
self.index_file(path, &path_str, mtime);
self.state.pending.fetch_sub(1, Ordering::Relaxed);
}
fn index_file(&self, path: &Path, path_str: &str, mtime: i64) {
if self.indexing_paused() {
// Leave the file unrecorded so it's picked up again once indexing
// resumes (initial_scan/watch_loop treat it as not-yet-indexed).
return;
}
eprintln!("breadmill: extracting {}", path_str);
let text = match extract::extract(path) {
Ok(t) if !t.trim().is_empty() => t,
Ok(_) => return,
Err(e) => {
eprintln!("breadmill: extract {}: {}", path_str, e);
return;
}
};
let hash = sha256_str(text.as_bytes());
// Check if hash changed (catches content changes without mtime change)
{
let store = self.state.store.lock().unwrap();
if let Ok(files) = store.all_files() {
if files.iter().any(|f| f.path == path_str && f.hash == hash) {
return;
}
}
}
// 2000 char cap keeps even minified single-line files to ~5002000 tokens,
// avoiding quadratic attention blowup while still splitting at word boundaries
// for natural-language files.
let chunks = chunk::chunk_text(&text, 400, 80, 2_000);
eprintln!("breadmill: embedding {} ({} chars, {} chunks)", path_str, text.len(), chunks.len());
let mut embedder_guard = self.state.embedder.lock().unwrap();
if !self.state.model_ready.load(Ordering::Relaxed) {
eprintln!("breadmill: model not ready, skipping embed for {}", path_str);
return;
}
let embedder = match embedder_guard.as_mut() {
Some(e) => e,
None => return,
};
{
let mut store = self.state.store.lock().unwrap();
let _ = store.delete_file(path_str); // remove old chunks/vectors first
}
let mut any_ok = false;
let mut chunks_added = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
eprintln!("breadmill: embed chunk {}/{} ({} chars) for {}", i + 1, chunks.len(), chunk.text.len(), path_str);
match embedder.embed_document(&chunk.text) {
Ok(embedding) => {
let mut store = self.state.store.lock().unwrap();
// Ensure file row exists before inserting chunks (FK constraint)
let _ = store.upsert_file(path_str, mtime, &hash);
let _ = store.insert_chunk(
path_str,
&chunk.text,
chunk.start,
chunk.end,
&embedding,
);
any_ok = true;
chunks_added += 1;
}
Err(e) => eprintln!("breadmill: embed error for {}: {}", path_str, e),
}
}
if !any_ok {
eprintln!("breadmill: no chunks embedded for {}", path_str);
// Record the file so the mtime+hash check skips it on the next startup
// rather than re-entering the same embed-fail loop.
let store = self.state.store.lock().unwrap();
let _ = store.upsert_file(path_str, mtime, &hash);
} else {
// Increment live so `status` reflects progress before the full scan ends.
self.state.indexed.fetch_add(chunks_added, Ordering::Relaxed);
eprintln!("breadmill: done {} ({} chunks indexed)", path_str, chunks_added);
}
}
fn is_indexed_extension(&self, path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|ext| {
self.config
.index
.extensions
.iter()
.any(|e| e.eq_ignore_ascii_case(ext))
})
.unwrap_or(false)
}
}
fn mtime_secs(meta: &fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn sha256_str(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub fn expand_home(path: &str) -> PathBuf {
if path.starts_with("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(&path[2..])
} else {
PathBuf::from(path)
}
}

242
breadmill/src/main.rs Normal file
View file

@ -0,0 +1,242 @@
use std::{
io::Read,
path::{Path, PathBuf},
sync::{Arc, atomic::Ordering},
};
use breadsearch_shared::{Request, Response};
mod chunk;
mod embed;
mod extract;
mod indexer;
mod power;
mod serve;
mod store;
use embed::{Backend, OrtEmbedder};
use indexer::{Indexer, SharedState};
use store::Store;
const MODEL_URL: &str =
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/onnx/model.onnx";
const TOKENIZER_URL: &str =
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/tokenizer.json";
fn main() {
let raw_args: Vec<String> = std::env::args().collect();
// Extract global flags before command dispatch.
let use_npu = raw_args.iter().any(|a| a == "--npu");
let use_rocm = raw_args.iter().any(|a| a == "--rocm");
// Build a view of argv without backend flags for command matching.
let args: Vec<&str> = raw_args
.iter()
.skip(1)
.filter(|a| a.as_str() != "--npu" && a.as_str() != "--rocm")
.map(|s| s.as_str())
.collect();
match args.first().copied() {
Some("--version") | Some("-V") => {
println!("breadmill {}", env!("CARGO_PKG_VERSION"));
}
Some("--fetch-model") | Some("fetch-model") => {
if let Err(e) = fetch_model() {
eprintln!("breadmill: {}", e);
std::process::exit(1);
}
}
Some("--reindex") | Some("reindex") => {
if let Err(e) = run_daemon(true, use_npu, use_rocm) {
eprintln!("breadmill: {}", e);
std::process::exit(1);
}
}
Some("query") => {
let q = args.get(1).copied().unwrap_or("");
if q.is_empty() {
eprintln!("usage: breadmill query <text>");
std::process::exit(1);
}
cli_query(q);
}
Some("status") => {
cli_status();
}
None | Some("serve") | Some("--serve") => {
if let Err(e) = run_daemon(false, use_npu, use_rocm) {
eprintln!("breadmill: {}", e);
std::process::exit(1);
}
}
Some(cmd) => {
eprintln!("breadmill: unknown command: {}", cmd);
eprintln!(
"usage: breadmill [serve|reindex|fetch-model|query <text>|status] [--npu|--rocm] [--version]"
);
std::process::exit(1);
}
}
}
// ---- Daemon -----------------------------------------------------------------
fn run_daemon(force_reindex: bool, use_npu: bool, use_rocm: bool) -> Result<(), String> {
let config = breadsearch_shared::Config::load();
let state_dir = breadsearch_shared::state_dir();
let cache_dir = breadsearch_shared::cache_dir();
let socket_path = breadsearch_shared::socket_path();
let dim = config.model.dim;
let snippet_len = config.search.snippet_len;
let search_limit = config.search.limit;
std::fs::create_dir_all(&state_dir).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
let backend = if use_npu || config.model.backend == "npu" {
eprintln!("breadmill: NPU backend selected");
Backend::Npu { cache_dir: cache_dir.clone() }
} else if use_rocm || config.model.backend == "rocm" {
eprintln!("breadmill: ROCm backend selected");
Backend::Rocm
} else {
Backend::Cpu
};
let store = Store::open(&state_dir, dim)?;
let state = Arc::new(SharedState::new(store));
// Load embedder if model files present
let model_dir = model_dir(&cache_dir);
let model_path = model_dir.join("model.onnx");
let tokenizer_path = model_dir.join("tokenizer.json");
if model_path.exists() && tokenizer_path.exists() {
eprintln!("breadmill: loading model...");
match OrtEmbedder::load(&model_path, &tokenizer_path, dim, backend) {
Ok(embedder) => {
*state.embedder.lock().unwrap() = Some(embedder);
state.model_ready.store(true, Ordering::Relaxed);
eprintln!("breadmill: model loaded");
}
Err(e) => eprintln!("breadmill: model load failed: {} — run --fetch-model", e),
}
} else {
eprintln!(
"breadmill: model files not found in {} — run: breadmill --fetch-model",
model_dir.display()
);
}
// Indexer runs in a background thread
{
let state_clone = Arc::clone(&state);
let config_clone = config.clone();
let state_dir_clone = state_dir.clone();
std::thread::spawn(move || {
let indexer = Indexer::new(state_clone, config_clone, state_dir_clone);
if force_reindex {
indexer.full_reindex();
}
indexer.run();
});
}
// Server runs on the main thread (blocking)
serve::run(&socket_path, Arc::clone(&state), snippet_len, search_limit);
Ok(())
}
// ---- Model fetch ------------------------------------------------------------
fn fetch_model() -> Result<(), String> {
let cache_dir = breadsearch_shared::cache_dir();
let model_dir = model_dir(&cache_dir);
std::fs::create_dir_all(&model_dir).map_err(|e| e.to_string())?;
download_if_missing(MODEL_URL, &model_dir.join("model.onnx"))?;
download_if_missing(TOKENIZER_URL, &model_dir.join("tokenizer.json"))?;
eprintln!("breadmill: model files ready in {}", model_dir.display());
Ok(())
}
fn download_if_missing(url: &str, dest: &Path) -> Result<(), String> {
if dest.exists() {
eprintln!(" already present: {}", dest.display());
return Ok(());
}
eprintln!(" downloading {} ...", url);
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(300))
.build();
let response = agent.get(url).call().map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
response
.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err(format!("empty download from {}", url));
}
// Write atomically via temp file
let tmp = dest.with_extension("tmp");
std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
eprintln!(" saved {} ({:.1} MB)", dest.display(), bytes.len() as f64 / 1_048_576.0);
Ok(())
}
fn model_dir(cache_dir: &Path) -> PathBuf {
cache_dir.join("models")
}
// ---- CLI helpers ------------------------------------------------------------
fn cli_query(query: &str) {
let req = Request::Query {
query: query.to_string(),
limit: 10,
};
match breadsearch_shared::send_request(&req) {
Ok(Response::Hits { hits }) => {
if hits.is_empty() {
println!("no results");
}
for (i, h) in hits.iter().enumerate() {
println!(
"{:2}. {} ({:.3})\n {}\n {}\n",
i + 1,
h.title,
h.score,
h.path,
h.snippet.lines().next().unwrap_or(""),
);
}
}
Ok(Response::Error { message }) => eprintln!("error: {}", message),
Ok(_) => eprintln!("unexpected response"),
Err(e) => eprintln!("could not reach breadmill: {}", e),
}
}
fn cli_status() {
match breadsearch_shared::send_request(&Request::Status) {
Ok(Response::StatusInfo(s)) => {
println!("indexed: {}", s.indexed);
println!("pending: {}", s.pending);
println!("model ready: {}", s.model_ready);
}
Ok(_) => eprintln!("unexpected response"),
Err(e) => eprintln!("could not reach breadmill: {}", e),
}
}

31
breadmill/src/power.rs Normal file
View file

@ -0,0 +1,31 @@
use std::fs;
use std::path::Path;
/// Best-effort check for whether the system is currently on AC/mains power.
/// Scans `/sys/class/power_supply` for a Mains or USB supply with `online=1`.
/// Systems with no such supply at all (desktops, no battery) are treated as
/// always on power, so this never blocks indexing on hardware without a
/// battery to protect.
pub fn on_ac_power() -> bool {
let dir = Path::new("/sys/class/power_supply");
let Ok(entries) = fs::read_dir(dir) else {
return true;
};
let mut found_mains = false;
for entry in entries.flatten() {
let path = entry.path();
let supply_type = fs::read_to_string(path.join("type")).unwrap_or_default();
let supply_type = supply_type.trim();
if supply_type != "Mains" && supply_type != "USB" {
continue;
}
found_mains = true;
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
if online.trim() == "1" {
return true;
}
}
!found_mains
}

127
breadmill/src/serve.rs Normal file
View file

@ -0,0 +1,127 @@
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::{UnixListener, UnixStream},
path::Path,
sync::Arc,
};
use breadsearch_shared::{Request, Response, StatusInfo};
use crate::indexer::SharedState;
pub fn run(socket_path: &Path, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
let _ = std::fs::remove_file(socket_path);
let listener = match UnixListener::bind(socket_path) {
Ok(l) => l,
Err(e) => {
eprintln!("breadmill: bind {}: {}", socket_path.display(), e);
return;
}
};
eprintln!("breadmill: listening on {}", socket_path.display());
for stream in listener.incoming() {
match stream {
Ok(s) => {
let state = Arc::clone(&state);
std::thread::spawn(move || {
handle(s, state, snippet_len, search_limit);
});
}
Err(e) => eprintln!("breadmill: accept error: {}", e),
}
}
}
fn handle(stream: UnixStream, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
let stream_write = match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
};
let mut reader = BufReader::new(&stream);
let mut writer = std::io::BufWriter::new(stream_write);
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
return;
}
let response = match serde_json::from_str::<Request>(line.trim()) {
Ok(req) => {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dispatch(req, &state, snippet_len, search_limit)
}));
match r {
Ok(resp) => resp,
Err(_) => Response::Error { message: "internal error".into() },
}
}
Err(e) => Response::Error { message: e.to_string() },
};
if let Ok(mut json) = serde_json::to_string(&response) {
json.push('\n');
let _ = writer.write_all(json.as_bytes());
let _ = writer.flush();
}
}
fn dispatch(
req: Request,
state: &SharedState,
snippet_len: usize,
search_limit: usize,
) -> Response {
use std::sync::atomic::Ordering;
match req {
Request::Query { query, limit } => {
if !state.model_ready.load(Ordering::Relaxed) {
return Response::Error {
message: "model not ready — run breadmill --fetch-model".into(),
};
}
let embedding = {
let mut embedder = state.embedder.lock().unwrap();
match embedder.as_mut() {
Some(e) => match e.embed_query(&query) {
Ok(v) => v,
Err(e) => return Response::Error { message: e },
},
None => {
return Response::Error {
message: "embedder unavailable".into(),
}
}
}
};
let limit = limit.min(search_limit).max(1);
let store = state.store.lock().unwrap();
match store.search(&embedding, limit, snippet_len) {
Ok(hits) => Response::Hits { hits },
Err(e) => Response::Error { message: e },
}
}
Request::Status => {
use std::sync::atomic::Ordering;
Response::StatusInfo(StatusInfo {
indexed: state.indexed.load(Ordering::Relaxed),
pending: state.pending.load(Ordering::Relaxed),
model_ready: state.model_ready.load(Ordering::Relaxed),
})
}
Request::Reindex => {
use std::sync::atomic::Ordering;
state.reindex_signal.store(true, Ordering::Relaxed);
Response::Ok
}
}
}

235
breadmill/src/store.rs Normal file
View file

@ -0,0 +1,235 @@
use std::path::Path;
use rusqlite::{Connection, params};
use usearch::{Index, IndexOptions, MetricKind, ScalarKind, new_index};
pub struct Store {
pub conn: Connection,
pub index: Index,
pub dim: usize,
}
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
unsafe impl Send for Store {}
#[derive(Debug)]
pub struct FileMeta {
pub path: String,
pub mtime: i64,
pub hash: String,
}
impl Store {
pub fn open(state_dir: &Path, dim: usize) -> Result<Self, String> {
std::fs::create_dir_all(state_dir).map_err(|e| e.to_string())?;
let db_path = state_dir.join("meta.db");
let conn = Connection::open(&db_path).map_err(|e| e.to_string())?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
mtime INTEGER NOT NULL,
hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
chunk_text TEXT NOT NULL,
chunk_start INTEGER NOT NULL,
chunk_end INTEGER NOT NULL
);",
)
.map_err(|e| e.to_string())?;
let idx_path = state_dir.join("vectors.usearch");
let options = IndexOptions {
dimensions: dim,
metric: MetricKind::Cos,
quantization: ScalarKind::F32,
connectivity: 16,
expansion_add: 128,
expansion_search: 64,
multi: false,
};
let index = new_index(&options).map_err(|e| e.to_string())?;
if idx_path.exists() {
index
.load(idx_path.to_str().unwrap())
.map_err(|e| e.to_string())?;
} else {
index.reserve(4096).map_err(|e| e.to_string())?;
}
Ok(Self { conn, index, dim })
}
// ---- file state ---------------------------------------------------------
pub fn all_files(&self) -> Result<Vec<FileMeta>, String> {
let mut stmt = self
.conn
.prepare("SELECT path, mtime, hash FROM files")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(FileMeta {
path: row.get(0)?,
mtime: row.get(1)?,
hash: row.get(2)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
pub fn upsert_file(&self, path: &str, mtime: i64, hash: &str) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO files (path, mtime, hash) VALUES (?1, ?2, ?3)",
params![path, mtime, hash],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Delete a file and all its chunks from SQLite; also remove chunk vectors.
pub fn delete_file(&mut self, path: &str) -> Result<(), String> {
// Collect chunk IDs before deletion for usearch removal
let ids = self.chunk_ids_for(path)?;
self.conn
.execute("DELETE FROM files WHERE path = ?1", params![path])
.map_err(|e| e.to_string())?;
for id in ids {
let _ = self.index.remove(id); // best-effort; stale entries are harmless
}
Ok(())
}
fn chunk_ids_for(&self, path: &str) -> Result<Vec<u64>, String> {
let mut stmt = self
.conn
.prepare("SELECT id FROM chunks WHERE path = ?1")
.map_err(|e| e.to_string())?;
let ids = stmt
.query_map(params![path], |row| row.get::<_, i64>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|id| id as u64)
.collect();
Ok(ids)
}
// ---- chunk operations ---------------------------------------------------
pub fn insert_chunk(
&mut self,
path: &str,
text: &str,
start: usize,
end: usize,
embedding: &[f32],
) -> Result<u64, String> {
self.conn
.execute(
"INSERT INTO chunks (path, chunk_text, chunk_start, chunk_end)
VALUES (?1, ?2, ?3, ?4)",
params![path, text, start as i64, end as i64],
)
.map_err(|e| e.to_string())?;
let id = self.conn.last_insert_rowid() as u64;
// Grow index if needed
if self.index.size() + 1 > self.index.capacity() {
self.index
.reserve(self.index.capacity() + 4096)
.map_err(|e| e.to_string())?;
}
self.index.add(id, embedding).map_err(|e| e.to_string())?;
Ok(id)
}
pub fn chunk_count(&self) -> usize {
self.conn
.query_row("SELECT COUNT(*) FROM chunks", [], |row| {
row.get::<_, i64>(0)
})
.unwrap_or(0) as usize
}
// ---- query --------------------------------------------------------------
pub fn search(
&self,
embedding: &[f32],
limit: usize,
snippet_len: usize,
) -> Result<Vec<breadsearch_shared::Hit>, String> {
let results = self
.index
.search(embedding, limit)
.map_err(|e| e.to_string())?;
let mut hits = Vec::new();
for (key, distance) in results.keys.iter().zip(results.distances.iter()) {
// Convert cosine distance → similarity score (higher = better)
let score = 1.0 - distance;
let maybe_chunk = self
.conn
.query_row(
"SELECT chunk_text, path FROM chunks WHERE id = ?1",
params![*key as i64],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.ok();
if let Some((chunk_text, path)) = maybe_chunk {
let snippet = truncate_to_chars(&chunk_text, snippet_len);
let title = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path)
.to_string();
hits.push(breadsearch_shared::Hit {
title,
path,
snippet,
score,
});
}
}
Ok(hits)
}
// ---- persistence --------------------------------------------------------
pub fn save_index(&self, state_dir: &Path) -> Result<(), String> {
let idx_path = state_dir.join("vectors.usearch");
self.index
.save(idx_path.to_str().unwrap())
.map_err(|e| e.to_string())
}
}
fn truncate_to_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let truncated: String = s.chars().take(max_chars).collect();
format!("{}", truncated.trim_end())
}