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
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:
1 parent
70746ef428
commit
f5b03c6fb0
27 files changed
+2988
-13
No files matched your search
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user