feat: add local agent MCP tools and optional source-grounded AI answers
Core CI / core (ubuntu-latest) (push) Failing after 3m6s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m41s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 20:28:38 +02:00
1 parent 70746ef428
commit f5b03c6fb0
27 files changed
+2988 -13

No files matched your search

+76
View File
@@ -929,6 +929,82 @@ impl SearchIndex {
)?)
}
/// Indexed file version for bounded agent handles. Size and nanosecond
/// modification time must be compared again before serving the source.
pub fn file_signature(&self, path: &str) -> Result<Option<(u64, i64)>> {
self.connection
.query_row(
"SELECT size,mtime FROM files WHERE path=?1 AND size>=0 AND mtime>=0",
[path],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(Into::into)
}
/// Read an indexed document source for agent citations or explicit answers.
/// No file content is read: the canonical source must still match the indexed
/// size and nanosecond modification time. Callers must disclose this bounded
/// source excerpt before sending it to an optional external model.
pub fn source_passage(
&self,
path: &str,
source: &SourceLocation,
max_chars: usize,
) -> Result<Option<IndexedPassage>> {
if !matches!(source.kind.as_str(), "page" | "paragraph" | "line")
|| max_chars == 0
|| max_chars > 8192
{
bail!("Invalid document source or excerpt limit");
}
// Check the opaque indexed identity before touching a caller-controlled
// filesystem path (including UNC paths on Windows).
if !self.contains_path(path)? {
return Ok(None);
}
let canonical = match fs::canonicalize(path) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let metadata = match fs::metadata(&canonical) {
Ok(value) if value.is_file() => value,
_ => return Ok(None),
};
let mtime = metadata.modified()?.duration_since(UNIX_EPOCH)?;
let size = i64::try_from(metadata.len())?;
let modified = i64::try_from(mtime.as_nanos())?;
self.connection
.query_row(
"SELECT p.id,f.path,f.filename,substr(p.text,1,?6),p.kind,p.number
FROM passages p JOIN files f ON f.path=p.file_path
WHERE f.path=?1 AND p.kind=?2 AND p.number=?3
AND f.size=?4 AND f.mtime=?5 ORDER BY p.id LIMIT 1",
params![
canonical.to_string_lossy(),
source.kind,
source.number,
size,
modified,
max_chars as i64
],
|row| {
Ok(IndexedPassage {
id: row.get(0)?,
path: row.get(1)?,
filename: row.get(2)?,
text: row.get(3)?,
source: SourceLocation {
kind: row.get(4)?,
number: row.get(5)?,
},
})
},
)
.optional()
.map_err(Into::into)
}
pub fn passage_batch(&self, after_id: i64, limit: usize) -> Result<Vec<IndexedPassage>> {
let mut statement = self.connection.prepare("SELECT p.rowid,f.path,f.filename,p.text,p.kind,p.number
FROM passages p JOIN files f ON f.path=p.file_path WHERE p.rowid>?1 ORDER BY p.rowid LIMIT ?2")?;
@@ -0,0 +1,76 @@
use searchi_core::{SearchIndex, SourceLocation};
use std::fs;
#[test]
fn document_source_is_bounded_current_and_index_scoped() {
let directory = tempfile::tempdir().unwrap();
let files = directory.path().join("files");
fs::create_dir(&files).unwrap();
let file = files.join("notes.txt");
fs::write(&file, "héllo supplier notice").unwrap();
let mut index = SearchIndex::open(directory.path().join("index.sqlite")).unwrap();
index.index_folder(&files).unwrap();
let path = fs::canonicalize(&file)
.unwrap()
.to_string_lossy()
.into_owned();
let original_version = index.file_signature(&path).unwrap().unwrap();
let source = SourceLocation {
kind: "line".into(),
number: 1,
};
let passage = index.source_passage(&path, &source, 5).unwrap().unwrap();
assert_eq!(passage.text, "héllo");
assert!(index.source_passage(&path, &source, 8193).is_err());
assert!(index
.source_passage(
&path,
&SourceLocation {
kind: "timestamp_ms".into(),
number: 0
},
100
)
.is_err());
assert!(index
.source_passage(
&path,
&SourceLocation {
kind: "line".into(),
number: 99
},
100
)
.unwrap()
.is_none());
let other = files.join("unindexed.txt");
fs::write(&other, "private unrelated content").unwrap();
assert!(index
.file_signature(&other.to_string_lossy())
.unwrap()
.is_none());
assert!(index
.source_passage(&other.to_string_lossy(), &source, 100)
.unwrap()
.is_none());
fs::write(&file, "changed content with different size").unwrap();
assert!(index.source_passage(&path, &source, 100).unwrap().is_none());
index.index_folder(&files).unwrap();
assert_ne!(
index.file_signature(&path).unwrap().unwrap(),
original_version
);
assert!(index.source_passage(&path, &source, 100).unwrap().is_some());
let old_time = fs::metadata(&file).unwrap().modified().unwrap();
let same_size = "x".repeat(fs::metadata(&file).unwrap().len() as usize);
fs::write(&file, same_size).unwrap();
fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_modified(old_time + std::time::Duration::from_secs(2))
.unwrap();
assert!(index.source_passage(&path, &source, 100).unwrap().is_none());
fs::remove_file(&file).unwrap();
assert!(index.source_passage(&path, &source, 100).unwrap().is_none());
}