buildwithnexus 0.10.3 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +10 -4
- package/harness/src/checkpoint.rs +104 -0
- package/harness/src/config.rs +1 -1
- package/harness/src/lib.rs +296 -39
- package/harness/src/onboarding.rs +50 -8
- package/harness/src/report.rs +2 -2
- package/harness/src/tools.rs +218 -3
- package/harness/src/tui.rs +184 -56
- package/package.json +3 -3
- package/scripts/postinstall.js +6 -1
package/harness/src/tools.rs
CHANGED
|
@@ -8,6 +8,8 @@ use std::process::Command;
|
|
|
8
8
|
|
|
9
9
|
use serde_json::{json, Value};
|
|
10
10
|
|
|
11
|
+
use crate::checkpoint;
|
|
12
|
+
|
|
11
13
|
pub struct ToolDef {
|
|
12
14
|
pub name: &'static str,
|
|
13
15
|
pub description: &'static str,
|
|
@@ -25,6 +27,8 @@ fn err(s: impl Into<String>) -> Outcome { Outcome { content: s.into(), is_error:
|
|
|
25
27
|
|
|
26
28
|
const MAX_READ: usize = 100 * 1024;
|
|
27
29
|
const MAX_OUT: usize = 16 * 1024;
|
|
30
|
+
const MAX_SEARCH_FILES: usize = 10_000;
|
|
31
|
+
const DEFAULT_SEARCH_LIMIT: usize = 100;
|
|
28
32
|
|
|
29
33
|
// Exposed only for the criterion perf suite (see provider::bench).
|
|
30
34
|
#[doc(hidden)]
|
|
@@ -36,10 +40,14 @@ pub mod bench {
|
|
|
36
40
|
|
|
37
41
|
pub fn defs(include_subagent: bool) -> Vec<ToolDef> {
|
|
38
42
|
let mut v = vec![
|
|
39
|
-
ToolDef { name: "read_file", description: "Read a UTF-8 text file and return its contents. Works anywhere on the filesystem.",
|
|
40
|
-
schema: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}) },
|
|
43
|
+
ToolDef { name: "read_file", description: "Read a UTF-8 text file and return its contents. Optional start_line/end_line return a bounded line range. Works anywhere on the filesystem.",
|
|
44
|
+
schema: json!({"type":"object","properties":{"path":{"type":"string"},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1}},"required":["path"]}) },
|
|
41
45
|
ToolDef { name: "list_dir", description: "List entries in a directory. Works anywhere on the filesystem.",
|
|
42
46
|
schema: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}) },
|
|
47
|
+
ToolDef { name: "find_files", description: "Recursively find files by a simple glob pattern such as `*.rs` or `src/*test*`. Skips heavy build/dependency directories.",
|
|
48
|
+
schema: json!({"type":"object","properties":{"root":{"type":"string","default":"."},"pattern":{"type":"string"},"max":{"type":"integer","minimum":1,"maximum":500}},"required":["pattern"]}) },
|
|
49
|
+
ToolDef { name: "grep_files", description: "Search text files for a literal pattern and return path:line matches. Optional file_pattern limits searched files, e.g. `*.rs`.",
|
|
50
|
+
schema: json!({"type":"object","properties":{"root":{"type":"string","default":"."},"pattern":{"type":"string"},"file_pattern":{"type":"string"},"case_sensitive":{"type":"boolean","default":false},"max":{"type":"integer","minimum":1,"maximum":500}},"required":["pattern"]}) },
|
|
43
51
|
ToolDef { name: "write_file", description: "Create or overwrite a file with the given contents.",
|
|
44
52
|
schema: json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}) },
|
|
45
53
|
ToolDef { name: "edit_file", description: "Replace the unique occurrence of `old` with `new` in a file.",
|
|
@@ -98,6 +106,8 @@ pub fn preview(name: &str, input: &Value) -> String {
|
|
|
98
106
|
"write_file" => format!("write {}", input["path"].as_str().unwrap_or("?")),
|
|
99
107
|
"edit_file" => format!("edit {}", input["path"].as_str().unwrap_or("?")),
|
|
100
108
|
"run_command" => format!("run: {}", input["command"].as_str().unwrap_or("?")),
|
|
109
|
+
"find_files" => format!("find files: {}", input["pattern"].as_str().unwrap_or("?")),
|
|
110
|
+
"grep_files" => format!("grep: {}", input["pattern"].as_str().unwrap_or("?")),
|
|
101
111
|
"spawn_subagent" => format!("subagent: {}", input["task"].as_str().unwrap_or("?")),
|
|
102
112
|
"fetch_url" => format!("GET {}", input["url"].as_str().unwrap_or("?")),
|
|
103
113
|
_ => name.to_string(),
|
|
@@ -120,6 +130,7 @@ pub fn touched_path(name: &str, input: &Value, cwd: &Path) -> Option<PathBuf> {
|
|
|
120
130
|
"read_file" | "list_dir" | "write_file" | "edit_file" => {
|
|
121
131
|
Some(resolve(cwd, input["path"].as_str().unwrap_or("")))
|
|
122
132
|
}
|
|
133
|
+
"find_files" | "grep_files" => Some(resolve(cwd, input["root"].as_str().unwrap_or("."))),
|
|
123
134
|
_ => None,
|
|
124
135
|
}
|
|
125
136
|
}
|
|
@@ -329,12 +340,116 @@ fn truncate(s: String, max: usize) -> String {
|
|
|
329
340
|
out
|
|
330
341
|
}
|
|
331
342
|
|
|
343
|
+
fn line_range(input: &Value) -> Option<(usize, usize)> {
|
|
344
|
+
let start = input["start_line"].as_u64().map(|n| n.max(1) as usize);
|
|
345
|
+
let end = input["end_line"].as_u64().map(|n| n.max(1) as usize);
|
|
346
|
+
match (start, end) {
|
|
347
|
+
(Some(s), Some(e)) => Some((s, e.max(s))),
|
|
348
|
+
(Some(s), None) => Some((s, usize::MAX)),
|
|
349
|
+
(None, Some(e)) => Some((1, e)),
|
|
350
|
+
(None, None) => None,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
fn apply_line_range(text: &str, range: Option<(usize, usize)>) -> String {
|
|
355
|
+
let Some((start, end)) = range else {
|
|
356
|
+
return text.to_string();
|
|
357
|
+
};
|
|
358
|
+
text.lines()
|
|
359
|
+
.enumerate()
|
|
360
|
+
.filter_map(|(i, line)| {
|
|
361
|
+
let line_no = i + 1;
|
|
362
|
+
if line_no >= start && line_no <= end { Some(line) } else { None }
|
|
363
|
+
})
|
|
364
|
+
.collect::<Vec<_>>()
|
|
365
|
+
.join("\n")
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
fn skip_dir(path: &Path) -> bool {
|
|
369
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
370
|
+
return false;
|
|
371
|
+
};
|
|
372
|
+
matches!(
|
|
373
|
+
name,
|
|
374
|
+
".git" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo"
|
|
375
|
+
| ".cache" | "vendor" | "__pycache__"
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn collect_files(root: &Path, out: &mut Vec<PathBuf>, seen: &mut usize) {
|
|
380
|
+
if *seen >= MAX_SEARCH_FILES {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
let Ok(rd) = fs::read_dir(root) else {
|
|
384
|
+
return;
|
|
385
|
+
};
|
|
386
|
+
for entry in rd.filter_map(|e| e.ok()) {
|
|
387
|
+
if *seen >= MAX_SEARCH_FILES {
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
let path = entry.path();
|
|
391
|
+
if path.is_dir() {
|
|
392
|
+
if !skip_dir(&path) {
|
|
393
|
+
collect_files(&path, out, seen);
|
|
394
|
+
}
|
|
395
|
+
} else if path.is_file() {
|
|
396
|
+
*seen += 1;
|
|
397
|
+
out.push(path);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn simple_glob_match(pattern: &str, text: &str) -> bool {
|
|
403
|
+
if pattern.is_empty() || pattern == "*" {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
if !pattern.contains('*') {
|
|
407
|
+
return text == pattern || text.ends_with(pattern);
|
|
408
|
+
}
|
|
409
|
+
let anchored_start = !pattern.starts_with('*');
|
|
410
|
+
let anchored_end = !pattern.ends_with('*');
|
|
411
|
+
let parts: Vec<&str> = pattern.split('*').filter(|p| !p.is_empty()).collect();
|
|
412
|
+
if parts.is_empty() {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
let mut rest = text;
|
|
416
|
+
for (idx, part) in parts.iter().enumerate() {
|
|
417
|
+
let Some(pos) = rest.find(part) else {
|
|
418
|
+
return false;
|
|
419
|
+
};
|
|
420
|
+
if idx == 0 && anchored_start && pos != 0 {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
rest = &rest[pos + part.len()..];
|
|
424
|
+
}
|
|
425
|
+
if anchored_end {
|
|
426
|
+
if let Some(last) = parts.last() {
|
|
427
|
+
return text.ends_with(last);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
true
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
fn display_path(path: &Path, cwd: &Path) -> String {
|
|
434
|
+
path.strip_prefix(cwd)
|
|
435
|
+
.unwrap_or(path)
|
|
436
|
+
.to_string_lossy()
|
|
437
|
+
.replace('\\', "/")
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fn search_limit(input: &Value) -> usize {
|
|
441
|
+
input["max"]
|
|
442
|
+
.as_u64()
|
|
443
|
+
.map(|n| n.clamp(1, 500) as usize)
|
|
444
|
+
.unwrap_or(DEFAULT_SEARCH_LIMIT)
|
|
445
|
+
}
|
|
446
|
+
|
|
332
447
|
pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
|
|
333
448
|
match name {
|
|
334
449
|
"read_file" => {
|
|
335
450
|
let p = resolve(cwd, input["path"].as_str().unwrap_or(""));
|
|
336
451
|
match fs::read_to_string(&p) {
|
|
337
|
-
Ok(c) => ok(truncate(c, MAX_READ)),
|
|
452
|
+
Ok(c) => ok(truncate(apply_line_range(&c, line_range(input)), MAX_READ)),
|
|
338
453
|
Err(e) => err(format!("cannot read {}: {e}", p.display())),
|
|
339
454
|
}
|
|
340
455
|
}
|
|
@@ -352,12 +467,80 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
|
|
|
352
467
|
Err(e) => err(format!("cannot list {}: {e}", p.display())),
|
|
353
468
|
}
|
|
354
469
|
}
|
|
470
|
+
"find_files" => {
|
|
471
|
+
let root = resolve(cwd, input["root"].as_str().unwrap_or("."));
|
|
472
|
+
let pattern = input["pattern"].as_str().unwrap_or("").trim();
|
|
473
|
+
if pattern.is_empty() {
|
|
474
|
+
return err("pattern is required");
|
|
475
|
+
}
|
|
476
|
+
let limit = search_limit(input);
|
|
477
|
+
let mut files = Vec::new();
|
|
478
|
+
let mut seen = 0;
|
|
479
|
+
collect_files(&root, &mut files, &mut seen);
|
|
480
|
+
files.sort();
|
|
481
|
+
let matches: Vec<String> = files
|
|
482
|
+
.into_iter()
|
|
483
|
+
.filter_map(|path| {
|
|
484
|
+
let rel = display_path(&path, cwd);
|
|
485
|
+
let name_match = path
|
|
486
|
+
.file_name()
|
|
487
|
+
.and_then(|n| n.to_str())
|
|
488
|
+
.is_some_and(|name| simple_glob_match(pattern, name));
|
|
489
|
+
if simple_glob_match(pattern, &rel) || name_match { Some(rel) } else { None }
|
|
490
|
+
})
|
|
491
|
+
.take(limit)
|
|
492
|
+
.collect();
|
|
493
|
+
if matches.is_empty() { ok(format!("no files matched {pattern}")) } else { ok(matches.join("\n")) }
|
|
494
|
+
}
|
|
495
|
+
"grep_files" => {
|
|
496
|
+
let root = resolve(cwd, input["root"].as_str().unwrap_or("."));
|
|
497
|
+
let pattern = input["pattern"].as_str().unwrap_or("");
|
|
498
|
+
if pattern.is_empty() {
|
|
499
|
+
return err("pattern is required");
|
|
500
|
+
}
|
|
501
|
+
let file_pattern = input["file_pattern"].as_str().unwrap_or("*");
|
|
502
|
+
let case_sensitive = input["case_sensitive"].as_bool().unwrap_or(false);
|
|
503
|
+
let needle = if case_sensitive { pattern.to_string() } else { pattern.to_lowercase() };
|
|
504
|
+
let limit = search_limit(input);
|
|
505
|
+
let mut files = Vec::new();
|
|
506
|
+
let mut seen = 0;
|
|
507
|
+
collect_files(&root, &mut files, &mut seen);
|
|
508
|
+
files.sort();
|
|
509
|
+
let mut matches = Vec::new();
|
|
510
|
+
for path in files {
|
|
511
|
+
if matches.len() >= limit {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
let rel = display_path(&path, cwd);
|
|
515
|
+
let basename_matches = path
|
|
516
|
+
.file_name()
|
|
517
|
+
.and_then(|n| n.to_str())
|
|
518
|
+
.is_some_and(|name| simple_glob_match(file_pattern, name));
|
|
519
|
+
if !simple_glob_match(file_pattern, &rel) && !basename_matches {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
let Ok(text) = fs::read_to_string(&path) else {
|
|
523
|
+
continue;
|
|
524
|
+
};
|
|
525
|
+
for (idx, line) in text.lines().enumerate() {
|
|
526
|
+
let haystack = if case_sensitive { line.to_string() } else { line.to_lowercase() };
|
|
527
|
+
if haystack.contains(&needle) {
|
|
528
|
+
matches.push(format!("{}:{}:{}", rel, idx + 1, line.trim_end()));
|
|
529
|
+
if matches.len() >= limit {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if matches.is_empty() { ok(format!("no matches for {pattern}")) } else { ok(matches.join("\n")) }
|
|
536
|
+
}
|
|
355
537
|
"write_file" => {
|
|
356
538
|
let p = resolve(cwd, input["path"].as_str().unwrap_or(""));
|
|
357
539
|
let content = input["content"].as_str().unwrap_or("");
|
|
358
540
|
if let Some(dir) = p.parent() {
|
|
359
541
|
let _ = fs::create_dir_all(dir);
|
|
360
542
|
}
|
|
543
|
+
checkpoint::record(cwd, &p, "write_file");
|
|
361
544
|
match fs::write(&p, content) {
|
|
362
545
|
Ok(_) => ok(format!("wrote {} ({} bytes)", p.display(), content.len())),
|
|
363
546
|
Err(e) => err(format!("cannot write {}: {e}", p.display())),
|
|
@@ -382,6 +565,7 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
|
|
|
382
565
|
if body[first + old.len()..].contains(old) {
|
|
383
566
|
return err("`old` text is not unique — add surrounding context");
|
|
384
567
|
}
|
|
568
|
+
checkpoint::record(cwd, &p, "edit_file");
|
|
385
569
|
match fs::write(&p, body.replacen(old, new, 1)) {
|
|
386
570
|
Ok(_) => ok(format!("edited {}", p.display())),
|
|
387
571
|
Err(e) => err(format!("cannot write {}: {e}", p.display())),
|
|
@@ -650,6 +834,37 @@ mod tests {
|
|
|
650
834
|
let _ = fs::remove_dir_all(&d);
|
|
651
835
|
}
|
|
652
836
|
|
|
837
|
+
#[test]
|
|
838
|
+
fn run_read_file_line_range() {
|
|
839
|
+
let d = tempdir();
|
|
840
|
+
run("write_file", &json!({"path": "f.txt", "content": "one\ntwo\nthree\nfour"}), &d);
|
|
841
|
+
let r = run("read_file", &json!({"path": "f.txt", "start_line": 2, "end_line": 3}), &d);
|
|
842
|
+
assert_eq!(r.content, "two\nthree");
|
|
843
|
+
let _ = fs::remove_dir_all(&d);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[test]
|
|
847
|
+
fn run_find_files_matches_glob_and_skips_heavy_dirs() {
|
|
848
|
+
let d = tempdir();
|
|
849
|
+
run("write_file", &json!({"path": "src/main.rs", "content": ""}), &d);
|
|
850
|
+
run("write_file", &json!({"path": "target/debug/skip.rs", "content": ""}), &d);
|
|
851
|
+
let r = run("find_files", &json!({"root": ".", "pattern": "*.rs"}), &d);
|
|
852
|
+
assert!(r.content.contains("src/main.rs"), "{}", r.content);
|
|
853
|
+
assert!(!r.content.contains("target/debug/skip.rs"), "{}", r.content);
|
|
854
|
+
let _ = fs::remove_dir_all(&d);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#[test]
|
|
858
|
+
fn run_grep_files_returns_literal_path_line_matches() {
|
|
859
|
+
let d = tempdir();
|
|
860
|
+
run("write_file", &json!({"path": "src/lib.rs", "content": "alpha\nNeedle\nbeta"}), &d);
|
|
861
|
+
run("write_file", &json!({"path": "README.md", "content": "Needle"}), &d);
|
|
862
|
+
let r = run("grep_files", &json!({"root": ".", "pattern": "needle", "file_pattern": "*.rs"}), &d);
|
|
863
|
+
assert!(r.content.contains("src/lib.rs:2:Needle"), "{}", r.content);
|
|
864
|
+
assert!(!r.content.contains("README.md"), "{}", r.content);
|
|
865
|
+
let _ = fs::remove_dir_all(&d);
|
|
866
|
+
}
|
|
867
|
+
|
|
653
868
|
#[test]
|
|
654
869
|
fn run_edit_unique_match() {
|
|
655
870
|
let d = tempdir();
|