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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.5"
63
+ version = "0.4.6"
64
64
  dependencies = [
65
65
  "anyhow",
66
66
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.4.5"
3
+ version = "0.4.6"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
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
- // Try DuckDuckGo instant-answer API first
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
- // If instant answer had no results, try DuckDuckGo lite (text-only, more reliable)
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 (compatible; anveesa-cli)")
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
- Ok(json!({ "ok": true, "query": query, "results": results }))
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: /clear /compact /copy /export [path]\n\
566
- /model [name] /provider [name] /status /exit\n\
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+W delete-word Ctrl+U clear-line Ctrl+V paste\n\
571
- Ctrl+M toggle scroll/select mode".into(),
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.usage.total_tokens > 0 {
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()