anveesa 0.4.5 → 0.4.6
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +30 -0
- package/src/tools.rs +134 -9
- package/src/tui.rs +64 -6
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/lib.rs
CHANGED
|
@@ -2516,10 +2516,40 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
|
|
|
2516
2516
|
}
|
|
2517
2517
|
}
|
|
2518
2518
|
}
|
|
2519
|
+
// Recent commits give the model useful project history context
|
|
2520
|
+
if let Some(log) = git_output(&cwd, ["log", "--oneline", "--decorate", "-8"]) {
|
|
2521
|
+
if !log.is_empty() {
|
|
2522
|
+
context.push_str("- recent_commits:\n");
|
|
2523
|
+
for line in log.lines() {
|
|
2524
|
+
context.push_str(&format!(" {line}\n"));
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2519
2528
|
} else {
|
|
2520
2529
|
context.push_str("- git: not inside a git repository\n");
|
|
2521
2530
|
}
|
|
2522
2531
|
|
|
2532
|
+
// Project metadata from package.json / Cargo.toml
|
|
2533
|
+
if let Ok(raw) = fs::read_to_string(cwd.join("package.json")) {
|
|
2534
|
+
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
2535
|
+
if let Some(name) = pkg["name"].as_str() {
|
|
2536
|
+
context.push_str(&format!("- project_name: {name}\n"));
|
|
2537
|
+
}
|
|
2538
|
+
if let Some(ver) = pkg["version"].as_str() {
|
|
2539
|
+
context.push_str(&format!("- project_version: {ver}\n"));
|
|
2540
|
+
}
|
|
2541
|
+
if let Some(desc) = pkg["description"].as_str() {
|
|
2542
|
+
context.push_str(&format!("- project_description: {desc}\n"));
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
} else if let Ok(raw) = fs::read_to_string(cwd.join("Cargo.toml")) {
|
|
2546
|
+
for line in raw.lines().take(15) {
|
|
2547
|
+
if line.starts_with("name") || line.starts_with("version") || line.starts_with("description") {
|
|
2548
|
+
context.push_str(&format!("- cargo_{}\n", line.trim()));
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2523
2553
|
let entries = directory_entries(cwd)?;
|
|
2524
2554
|
if entries.is_empty() {
|
|
2525
2555
|
context.push_str("- directory_entries: empty\n");
|
package/src/tools.rs
CHANGED
|
@@ -60,7 +60,7 @@ pub fn is_write_tool(name: &str) -> bool {
|
|
|
60
60
|
matches!(
|
|
61
61
|
name,
|
|
62
62
|
"create_dir" | "write_file" | "edit_file" | "run_command"
|
|
63
|
-
| "delete_file" | "move_file" | "copy_file"
|
|
63
|
+
| "delete_file" | "move_file" | "copy_file" | "patch_file"
|
|
64
64
|
| "git_commit" | "git_stash" | "git_branch"
|
|
65
65
|
)
|
|
66
66
|
}
|
|
@@ -105,6 +105,7 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
|
|
|
105
105
|
else { "git branch".to_string() }
|
|
106
106
|
}
|
|
107
107
|
"git_commit" => format!("git commit {}", field("message")),
|
|
108
|
+
"patch_file" => format!("patch file {}", field("path")),
|
|
108
109
|
"delete_file" => format!("delete {}", field("path")),
|
|
109
110
|
"move_file" => format!("move {} → {}", field("from"), field("to")),
|
|
110
111
|
"copy_file" => format!("copy {} → {}", field("from"), field("to")),
|
|
@@ -382,6 +383,32 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
382
383
|
}
|
|
383
384
|
}
|
|
384
385
|
}),
|
|
386
|
+
json!({
|
|
387
|
+
"type": "function",
|
|
388
|
+
"function": {
|
|
389
|
+
"name": "patch_file",
|
|
390
|
+
"description": "Apply multiple targeted replacements to a file in one call. Each patch must match exactly once. Prefer this over multiple edit_file calls when editing the same file.",
|
|
391
|
+
"parameters": {
|
|
392
|
+
"type": "object",
|
|
393
|
+
"properties": {
|
|
394
|
+
"path": { "type": "string", "description": "File path." },
|
|
395
|
+
"patches": {
|
|
396
|
+
"type": "array",
|
|
397
|
+
"description": "Ordered list of replacements to apply sequentially.",
|
|
398
|
+
"items": {
|
|
399
|
+
"type": "object",
|
|
400
|
+
"properties": {
|
|
401
|
+
"old_string": { "type": "string", "description": "Unique text to replace." },
|
|
402
|
+
"new_string": { "type": "string", "description": "Replacement text." }
|
|
403
|
+
},
|
|
404
|
+
"required": ["old_string", "new_string"]
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
"required": ["path", "patches"]
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}),
|
|
385
412
|
json!({
|
|
386
413
|
"type": "function",
|
|
387
414
|
"function": {
|
|
@@ -492,6 +519,7 @@ pub async fn run(name: &str, arguments: &str) -> String {
|
|
|
492
519
|
"git_stash" => git_stash(arguments).await,
|
|
493
520
|
"git_branch" => git_branch(arguments).await,
|
|
494
521
|
"git_commit" => git_commit(arguments).await,
|
|
522
|
+
"patch_file" => patch_file(arguments).await,
|
|
495
523
|
"delete_file" => delete_file(arguments).await,
|
|
496
524
|
"move_file" => move_file(arguments).await,
|
|
497
525
|
"copy_file" => copy_file(arguments).await,
|
|
@@ -684,7 +712,25 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
684
712
|
let query = args.query.trim();
|
|
685
713
|
if query.is_empty() { bail!("query is empty"); }
|
|
686
714
|
|
|
687
|
-
//
|
|
715
|
+
// 1. Brave Search API (best quality, free tier available)
|
|
716
|
+
if let Ok(key) = std::env::var("BRAVE_SEARCH_API_KEY") {
|
|
717
|
+
if let Ok(results) = search_brave(query, &key).await {
|
|
718
|
+
if !results.is_empty() {
|
|
719
|
+
return Ok(json!({ "ok": true, "query": query, "source": "brave", "results": results }));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 2. Serper.dev (Google results via API)
|
|
725
|
+
if let Ok(key) = std::env::var("SERPER_API_KEY") {
|
|
726
|
+
if let Ok(results) = search_serper(query, &key).await {
|
|
727
|
+
if !results.is_empty() {
|
|
728
|
+
return Ok(json!({ "ok": true, "query": query, "source": "serper", "results": results }));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 3. DuckDuckGo instant-answer API (no key needed, limited results)
|
|
688
734
|
let api_url = format!(
|
|
689
735
|
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
|
|
690
736
|
percent_encode(query)
|
|
@@ -705,16 +751,13 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
705
751
|
}
|
|
706
752
|
}
|
|
707
753
|
|
|
708
|
-
//
|
|
754
|
+
// 4. DuckDuckGo lite HTML fallback
|
|
709
755
|
if results.is_empty() {
|
|
710
|
-
let lite_url = format!(
|
|
711
|
-
"https://lite.duckduckgo.com/lite/?q={}",
|
|
712
|
-
percent_encode(query)
|
|
713
|
-
);
|
|
756
|
+
let lite_url = format!("https://lite.duckduckgo.com/lite/?q={}", percent_encode(query));
|
|
714
757
|
if let Ok(resp) = http_client()
|
|
715
758
|
.get(&lite_url)
|
|
716
759
|
.header("Accept-Language", "en-US,en;q=0.9")
|
|
717
|
-
.header("User-Agent", "Mozilla/5.0 (
|
|
760
|
+
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
|
718
761
|
.send()
|
|
719
762
|
.await
|
|
720
763
|
{
|
|
@@ -725,7 +768,58 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
725
768
|
}
|
|
726
769
|
|
|
727
770
|
results.truncate(10);
|
|
728
|
-
|
|
771
|
+
let source = if results.is_empty() { "none" } else { "duckduckgo" };
|
|
772
|
+
Ok(json!({ "ok": true, "query": query, "source": source, "results": results }))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async fn search_brave(query: &str, api_key: &str) -> Result<Vec<Value>> {
|
|
776
|
+
let url = format!(
|
|
777
|
+
"https://api.search.brave.com/res/v1/web/search?q={}&count=10&search_lang=en",
|
|
778
|
+
percent_encode(query)
|
|
779
|
+
);
|
|
780
|
+
let resp = http_client()
|
|
781
|
+
.get(&url)
|
|
782
|
+
.header("Accept", "application/json")
|
|
783
|
+
.header("Accept-Encoding", "gzip")
|
|
784
|
+
.header("X-Subscription-Token", api_key)
|
|
785
|
+
.send()
|
|
786
|
+
.await
|
|
787
|
+
.context("Brave Search request failed")?;
|
|
788
|
+
|
|
789
|
+
if !resp.status().is_success() {
|
|
790
|
+
bail!("Brave Search HTTP {}", resp.status());
|
|
791
|
+
}
|
|
792
|
+
let body: Value = resp.json().await.context("failed to parse Brave response")?;
|
|
793
|
+
let results = body["web"]["results"].as_array().cloned().unwrap_or_default();
|
|
794
|
+
Ok(results.into_iter().filter_map(|r| {
|
|
795
|
+
let title = r["title"].as_str()?;
|
|
796
|
+
let url = r["url"].as_str()?;
|
|
797
|
+
let snip = r["description"].as_str().unwrap_or("");
|
|
798
|
+
Some(json!({ "title": title, "snippet": snip, "url": url }))
|
|
799
|
+
}).collect())
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async fn search_serper(query: &str, api_key: &str) -> Result<Vec<Value>> {
|
|
803
|
+
let resp = http_client()
|
|
804
|
+
.post("https://google.serper.dev/search")
|
|
805
|
+
.header("X-API-KEY", api_key)
|
|
806
|
+
.header("Content-Type", "application/json")
|
|
807
|
+
.json(&json!({ "q": query, "num": 10 }))
|
|
808
|
+
.send()
|
|
809
|
+
.await
|
|
810
|
+
.context("Serper request failed")?;
|
|
811
|
+
|
|
812
|
+
if !resp.status().is_success() {
|
|
813
|
+
bail!("Serper HTTP {}", resp.status());
|
|
814
|
+
}
|
|
815
|
+
let body: Value = resp.json().await.context("failed to parse Serper response")?;
|
|
816
|
+
let results = body["organic"].as_array().cloned().unwrap_or_default();
|
|
817
|
+
Ok(results.into_iter().filter_map(|r| {
|
|
818
|
+
let title = r["title"].as_str()?;
|
|
819
|
+
let url = r["link"].as_str()?;
|
|
820
|
+
let snip = r["snippet"].as_str().unwrap_or("");
|
|
821
|
+
Some(json!({ "title": title, "snippet": snip, "url": url }))
|
|
822
|
+
}).collect())
|
|
729
823
|
}
|
|
730
824
|
|
|
731
825
|
/// Scrape DuckDuckGo lite (text-only) results page.
|
|
@@ -1223,6 +1317,37 @@ async fn write_file(arguments: &str) -> Result<Value> {
|
|
|
1223
1317
|
}))
|
|
1224
1318
|
}
|
|
1225
1319
|
|
|
1320
|
+
async fn patch_file(arguments: &str) -> Result<Value> {
|
|
1321
|
+
#[derive(Deserialize)]
|
|
1322
|
+
struct Hunk { old_string: String, new_string: String }
|
|
1323
|
+
#[derive(Deserialize)]
|
|
1324
|
+
struct Args { path: String, patches: Vec<Hunk> }
|
|
1325
|
+
|
|
1326
|
+
let args: Args = parse_args(arguments)?;
|
|
1327
|
+
let path = resolve_writable_path(&args.path)?;
|
|
1328
|
+
if !path.is_file() { bail!("{} is not a file", path.display()); }
|
|
1329
|
+
if is_sensitive_path(&path) { bail!("refusing to edit sensitive file"); }
|
|
1330
|
+
if args.patches.is_empty() { bail!("patches array is empty"); }
|
|
1331
|
+
|
|
1332
|
+
let mut content = fs::read_to_string(&path)
|
|
1333
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
1334
|
+
|
|
1335
|
+
for (i, hunk) in args.patches.iter().enumerate() {
|
|
1336
|
+
if hunk.old_string.is_empty() { bail!("patch[{i}]: old_string must not be empty"); }
|
|
1337
|
+
if hunk.old_string == hunk.new_string { bail!("patch[{i}]: old_string and new_string are identical"); }
|
|
1338
|
+
let count = content.matches(&hunk.old_string).count();
|
|
1339
|
+
match count {
|
|
1340
|
+
0 => bail!("patch[{i}]: old_string not found in {}", path.display()),
|
|
1341
|
+
1 => {}
|
|
1342
|
+
n => bail!("patch[{i}]: old_string appears {n} times — make it unique"),
|
|
1343
|
+
}
|
|
1344
|
+
content = content.replacen(&hunk.old_string, &hunk.new_string, 1);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
fs::write(&path, &content).with_context(|| format!("failed to write {}", path.display()))?;
|
|
1348
|
+
Ok(json!({ "ok": true, "path": path.display().to_string(), "patches_applied": args.patches.len() }))
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1226
1351
|
async fn edit_file(arguments: &str) -> Result<Value> {
|
|
1227
1352
|
let args: EditFileArgs = parse_args(arguments)?;
|
|
1228
1353
|
let path = resolve_writable_path(&args.path)?;
|
package/src/tui.rs
CHANGED
|
@@ -87,8 +87,9 @@ pub struct App {
|
|
|
87
87
|
streaming_started_at: Option<Instant>,
|
|
88
88
|
tool_started_at: Option<Instant>,
|
|
89
89
|
unread_count: usize,
|
|
90
|
-
// files/dirs already read this session — injected into workspace context each turn
|
|
91
90
|
seen_paths: std::collections::BTreeSet<String>,
|
|
91
|
+
// undo stack: (path, old_content) — None content means file didn't exist before
|
|
92
|
+
undo_stack: Vec<(String, Option<String>)>,
|
|
92
93
|
|
|
93
94
|
// input
|
|
94
95
|
input: String,
|
|
@@ -176,6 +177,7 @@ impl App {
|
|
|
176
177
|
tool_started_at: None,
|
|
177
178
|
unread_count: 0,
|
|
178
179
|
seen_paths: std::collections::BTreeSet::new(),
|
|
180
|
+
undo_stack: Vec::new(),
|
|
179
181
|
|
|
180
182
|
input: String::new(),
|
|
181
183
|
input_cursor: 0,
|
|
@@ -553,6 +555,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
553
555
|
app.usage = Usage::default();
|
|
554
556
|
app.pending_image = None;
|
|
555
557
|
app.seen_paths.clear();
|
|
558
|
+
app.undo_stack.clear();
|
|
556
559
|
app.input.clear();
|
|
557
560
|
app.input_cursor = 0;
|
|
558
561
|
if let Some(path) = &app.session_path {
|
|
@@ -562,13 +565,20 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
562
565
|
}
|
|
563
566
|
"/help" => {
|
|
564
567
|
app.messages.push(Msg::System(
|
|
565
|
-
"Commands
|
|
566
|
-
/
|
|
568
|
+
"Commands:\n\
|
|
569
|
+
/clear reset conversation\n\
|
|
570
|
+
/undo restore last file changed by AI\n\
|
|
571
|
+
/compact drop old turns to free context\n\
|
|
572
|
+
/copy copy last response to clipboard\n\
|
|
573
|
+
/export [path] save conversation as markdown\n\
|
|
574
|
+
/model [name] · /provider [name] · /status · /exit\n\
|
|
567
575
|
\n\
|
|
568
576
|
Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
|
|
569
577
|
j/k scroll (when input empty) PageUp/Dn scroll\n\
|
|
570
|
-
Ctrl+
|
|
571
|
-
Ctrl+
|
|
578
|
+
Ctrl+V paste (image or text) Ctrl+M scroll/select mode\n\
|
|
579
|
+
Ctrl+W delete-word Ctrl+U clear line\n\
|
|
580
|
+
\n\
|
|
581
|
+
Search: set BRAVE_SEARCH_API_KEY or SERPER_API_KEY for better results".into(),
|
|
572
582
|
));
|
|
573
583
|
app.input.clear();
|
|
574
584
|
app.input_cursor = 0;
|
|
@@ -607,6 +617,27 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
607
617
|
app.input_cursor = 0;
|
|
608
618
|
true
|
|
609
619
|
}
|
|
620
|
+
"/undo" => {
|
|
621
|
+
match app.undo_stack.pop() {
|
|
622
|
+
None => app.messages.push(Msg::System("Nothing to undo.".into())),
|
|
623
|
+
Some((path, Some(old_content))) => {
|
|
624
|
+
match std::fs::write(&path, &old_content) {
|
|
625
|
+
Ok(()) => app.messages.push(Msg::System(format!("Restored {path}"))),
|
|
626
|
+
Err(e) => app.messages.push(Msg::Error(format!("Undo failed: {e}"))),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
Some((path, None)) => {
|
|
630
|
+
// File was newly created — delete it
|
|
631
|
+
match std::fs::remove_file(&path) {
|
|
632
|
+
Ok(()) => app.messages.push(Msg::System(format!("Deleted {path} (undo create)"))),
|
|
633
|
+
Err(e) => app.messages.push(Msg::Error(format!("Undo failed: {e}"))),
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
app.input.clear();
|
|
638
|
+
app.input_cursor = 0;
|
|
639
|
+
true
|
|
640
|
+
}
|
|
610
641
|
"/compact" => {
|
|
611
642
|
// Keep only the last 10 turns, drop older history to free context
|
|
612
643
|
let keep = 10usize;
|
|
@@ -838,6 +869,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
838
869
|
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
839
870
|
flush_streaming_buf(app);
|
|
840
871
|
commit_pending_tool(app, true);
|
|
872
|
+
// Snapshot for /undo (read current content before the write is reflected in messages)
|
|
873
|
+
let old_content = std::fs::read_to_string(&path).ok();
|
|
874
|
+
if app.undo_stack.len() >= 20 { app.undo_stack.remove(0); }
|
|
875
|
+
app.undo_stack.push((path.clone(), old_content));
|
|
841
876
|
app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
|
|
842
877
|
}
|
|
843
878
|
TuiEvent::Confirm { summary, reply } => {
|
|
@@ -950,6 +985,25 @@ fn finish_turn(app: &mut App) {
|
|
|
950
985
|
if !app.history.is_empty() {
|
|
951
986
|
app.messages.push(Msg::Separator);
|
|
952
987
|
}
|
|
988
|
+
// Auto-compact when history exceeds ~40K estimated tokens (1 char ≈ 0.25 tokens)
|
|
989
|
+
auto_compact_if_needed(app);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
fn auto_compact_if_needed(app: &mut App) {
|
|
993
|
+
const TOKEN_THRESHOLD: usize = 40_000;
|
|
994
|
+
let estimated: usize = app.history.iter().map(|m| m.content.len() / 4).sum();
|
|
995
|
+
if estimated < TOKEN_THRESHOLD || app.history.len() < 8 {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
// Drop oldest quarter of turns (keep at least 4 turns)
|
|
999
|
+
let total_turns = app.history.len() / 2;
|
|
1000
|
+
let drop_turns = (total_turns / 4).max(1).min(total_turns.saturating_sub(4));
|
|
1001
|
+
let drop_msgs = drop_turns * 2;
|
|
1002
|
+
app.history.drain(..drop_msgs);
|
|
1003
|
+
app.seen_paths.clear();
|
|
1004
|
+
app.messages.push(Msg::System(format!(
|
|
1005
|
+
"Auto-compacted: dropped {drop_turns} older turn(s) (~{estimated}K est. tokens). Use /compact for manual control."
|
|
1006
|
+
)));
|
|
953
1007
|
}
|
|
954
1008
|
|
|
955
1009
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
@@ -975,7 +1029,11 @@ fn render(frame: &mut Frame, app: &mut App) {
|
|
|
975
1029
|
|
|
976
1030
|
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
977
1031
|
let version = env!("CARGO_PKG_VERSION");
|
|
978
|
-
let token_str = if app.
|
|
1032
|
+
let token_str = if app.mode == Mode::Streaming && !app.streaming_buf.is_empty() {
|
|
1033
|
+
// Live estimate: chars / 4 ≈ tokens
|
|
1034
|
+
let live = app.streaming_buf.len() / 4;
|
|
1035
|
+
format!(" → {live}t")
|
|
1036
|
+
} else if app.usage.total_tokens > 0 {
|
|
979
1037
|
format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
|
|
980
1038
|
} else {
|
|
981
1039
|
String::new()
|