@pheem49/mint 1.8.0 → 1.8.1

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.
Files changed (41) hide show
  1. package/.github/workflows/release.yml +1 -1
  2. package/Cargo.lock +34 -3
  3. package/Cargo.toml +2 -2
  4. package/README.md +31 -0
  5. package/Release_Note.md +95 -0
  6. package/assets/Mint_CLI.png +0 -0
  7. package/crates/mint-cli/Cargo.toml +1 -0
  8. package/crates/mint-cli/src/agent.rs +142 -48
  9. package/crates/mint-cli/src/main.rs +231 -22
  10. package/crates/mint-core/src/api_server.rs +9 -0
  11. package/crates/mint-core/src/browser.rs +401 -0
  12. package/crates/mint-core/src/config.rs +69 -1
  13. package/crates/mint-core/src/lib.rs +5 -0
  14. package/crates/mint-core/src/orchestration.rs +137 -1
  15. package/crates/mint-core/src/skills.rs +113 -4
  16. package/crates/mint-core/src/workflows.rs +1 -22
  17. package/package.json +1 -1
  18. package/src/renderer/src/components/ChatPanel.tsx +170 -33
  19. package/src/renderer/src/components/ImageStudioPanel.tsx +11 -1
  20. package/src/renderer/src/components/MintDashboard.tsx +40 -2
  21. package/src/renderer/src/components/Settings/PluginsTab.tsx +221 -41
  22. package/src/renderer/src/components/SettingsWindow.tsx +1 -9
  23. package/src/renderer/src/components/WorkspacePanel.tsx +66 -9
  24. package/src/renderer/src/css/image-studio.css +6 -0
  25. package/src/renderer/src/css/styles.css +64 -11
  26. package/src/renderer/src/index.css +12 -1
  27. package/src/renderer/src/tauri.ts +45 -4
  28. package/src/renderer/src-web/components/ChatPanel.tsx +163 -32
  29. package/src/renderer/src-web/components/ImageStudioPanel.tsx +11 -1
  30. package/src/renderer/src-web/components/MintDashboard.tsx +8 -3
  31. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +221 -41
  32. package/src/renderer/src-web/components/SettingsWindow.tsx +1 -9
  33. package/src/renderer/src-web/css/image-studio.css +6 -0
  34. package/src/renderer/src-web/css/styles.css +100 -12
  35. package/src/renderer/src-web/index.css +12 -1
  36. package/src/renderer/src-web/tauri.ts +24 -2
  37. package/src-tauri/src/browser.rs +1 -141
  38. package/src-tauri/src/headless.rs +3 -2
  39. package/src-tauri/src/lib.rs +117 -26
  40. package/src-tauri/src/workflows.rs +56 -4
  41. package/src-tauri/tauri.conf.json +1 -1
@@ -0,0 +1,401 @@
1
+ use crate::MintConfig;
2
+ use futures_util::{SinkExt, StreamExt};
3
+ use serde::Serialize;
4
+ use serde_json::{Value, json};
5
+ use tokio_tungstenite::{connect_async, tungstenite::Message};
6
+
7
+ fn log_action(action: &str, details: &str) {
8
+ if let Some(config_dir) = dirs::config_dir() {
9
+ let log_dir = config_dir.join("mint");
10
+ let log_file = log_dir.join("browser-automation.log");
11
+ let _ = std::fs::create_dir_all(&log_dir);
12
+ if let Ok(mut file) = std::fs::OpenOptions::new()
13
+ .create(true)
14
+ .append(true)
15
+ .open(&log_file)
16
+ {
17
+ use std::io::Write;
18
+ let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
19
+ let _ = writeln!(file, "[{}] [{}] {}", now, action, details);
20
+ }
21
+ }
22
+ }
23
+
24
+ #[derive(Debug, Clone, Serialize)]
25
+ #[serde(rename_all = "camelCase")]
26
+ pub struct BrowserTab {
27
+ pub id: String,
28
+ pub title: String,
29
+ pub url: String,
30
+ }
31
+
32
+ pub async fn list_tabs(config: &MintConfig) -> Result<Vec<BrowserTab>, String> {
33
+ if !is_browser_running(config).await {
34
+ return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
35
+ }
36
+ Ok(fetch_pages(config)
37
+ .await?
38
+ .into_iter()
39
+ .filter_map(|page| {
40
+ Some(BrowserTab {
41
+ id: page["id"].as_str()?.to_owned(),
42
+ title: page["title"].as_str().unwrap_or_default().to_owned(),
43
+ url: page["url"].as_str().unwrap_or_default().to_owned(),
44
+ })
45
+ })
46
+ .collect())
47
+ }
48
+
49
+ pub async fn navigate(config: &MintConfig, url: &str) -> Result<String, String> {
50
+ if !(url.starts_with("https://") || url.starts_with("http://")) {
51
+ log_action(
52
+ "NAVIGATE_ERROR",
53
+ "Browser navigation only supports http and https URLs",
54
+ );
55
+ return Err("browser navigation only supports http and https URLs".into());
56
+ }
57
+ log_action("NAVIGATE", &format!("Navigating to {url}"));
58
+ ensure_page_open(config).await?;
59
+ match cdp_call(config, "Page.navigate", json!({ "url": url })).await {
60
+ Ok(response) => {
61
+ if response["result"]["frameId"].as_str().is_some() {
62
+ log_action(
63
+ "NAVIGATE_SUCCESS",
64
+ &format!("Successfully navigated to {url}"),
65
+ );
66
+ Ok(format!("navigating to {url}"))
67
+ } else {
68
+ let err = response_error(&response);
69
+ log_action("NAVIGATE_ERROR", &format!("Failed: {err}"));
70
+ Err(err)
71
+ }
72
+ }
73
+ Err(e) => {
74
+ log_action("NAVIGATE_ERROR", &format!("Websocket error: {e}"));
75
+ Err(e)
76
+ }
77
+ }
78
+ }
79
+
80
+ pub async fn read_page_text(config: &MintConfig) -> Result<String, String> {
81
+ log_action("READ", "Reading page text content");
82
+ ensure_page_open(config).await?;
83
+ match cdp_call(
84
+ config,
85
+ "Runtime.evaluate",
86
+ json!({
87
+ "expression": "document.body ? document.body.innerText.substring(0, 12000) : ''",
88
+ "returnByValue": true
89
+ }),
90
+ )
91
+ .await
92
+ {
93
+ Ok(response) => {
94
+ if let Some(val) = response["result"]["result"]["value"].as_str() {
95
+ log_action(
96
+ "READ_SUCCESS",
97
+ &format!("Successfully read {} characters", val.len()),
98
+ );
99
+ Ok(val.to_owned())
100
+ } else {
101
+ let err = response_error(&response);
102
+ log_action("READ_ERROR", &format!("Failed: {err}"));
103
+ Err(err)
104
+ }
105
+ }
106
+ Err(e) => {
107
+ log_action("READ_ERROR", &format!("Websocket error: {e}"));
108
+ Err(e)
109
+ }
110
+ }
111
+ }
112
+
113
+ pub async fn click(config: &MintConfig, selector: &str) -> Result<String, String> {
114
+ let selector = selector.trim();
115
+ if selector.is_empty() || selector.len() > 500 {
116
+ log_action(
117
+ "CLICK_ERROR",
118
+ "Browser selector must contain between 1 and 500 characters",
119
+ );
120
+ return Err("browser selector must contain between 1 and 500 characters".into());
121
+ }
122
+ log_action("CLICK", &format!("Clicking element '{selector}'"));
123
+ ensure_page_open(config).await?;
124
+ let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
125
+ match cdp_call(
126
+ config,
127
+ "Runtime.evaluate",
128
+ json!({
129
+ "expression": format!(
130
+ "(() => {{ const element = document.querySelector({selector_json}); if (!element) return 'not-found'; element.click(); return 'clicked'; }})()"
131
+ ),
132
+ "returnByValue": true
133
+ }),
134
+ )
135
+ .await {
136
+ Ok(response) => {
137
+ match response["result"]["result"]["value"].as_str() {
138
+ Some("clicked") => {
139
+ log_action("CLICK_SUCCESS", &format!("Successfully clicked element '{selector}'"));
140
+ Ok("clicked".into())
141
+ }
142
+ Some("not-found") => {
143
+ let err = format!("browser selector not found: {selector}");
144
+ log_action("CLICK_ERROR", &err);
145
+ Err(err)
146
+ }
147
+ _ => {
148
+ let err = response_error(&response);
149
+ log_action("CLICK_ERROR", &err);
150
+ Err(err)
151
+ }
152
+ }
153
+ }
154
+ Err(e) => {
155
+ log_action("CLICK_ERROR", &format!("Websocket error: {e}"));
156
+ Err(e)
157
+ }
158
+ }
159
+ }
160
+
161
+ pub async fn type_text(config: &MintConfig, selector: &str, text: &str) -> Result<String, String> {
162
+ let selector = selector.trim();
163
+ if selector.is_empty() || selector.len() > 500 {
164
+ log_action(
165
+ "TYPE_ERROR",
166
+ "Browser selector must contain between 1 and 500 characters",
167
+ );
168
+ return Err("browser selector must contain between 1 and 500 characters".into());
169
+ }
170
+ log_action("TYPE", &format!("Typing text into element '{selector}'"));
171
+ ensure_page_open(config).await?;
172
+ let selector_json = serde_json::to_string(selector).map_err(|error| error.to_string())?;
173
+ let text_json = serde_json::to_string(text).map_err(|error| error.to_string())?;
174
+ match cdp_call(
175
+ config,
176
+ "Runtime.evaluate",
177
+ json!({
178
+ "expression": format!(
179
+ "(() => {{ \
180
+ const element = document.querySelector({selector_json}); \
181
+ if (!element) return 'not-found'; \
182
+ element.value = {text_json}; \
183
+ element.dispatchEvent(new Event('input', {{ bubbles: true }})); \
184
+ element.dispatchEvent(new Event('change', {{ bubbles: true }})); \
185
+ return 'typed'; \
186
+ }})()"
187
+ ),
188
+ "returnByValue": true
189
+ }),
190
+ )
191
+ .await
192
+ {
193
+ Ok(response) => match response["result"]["result"]["value"].as_str() {
194
+ Some("typed") => {
195
+ log_action(
196
+ "TYPE_SUCCESS",
197
+ &format!("Successfully typed into element '{selector}'"),
198
+ );
199
+ Ok("typed".into())
200
+ }
201
+ Some("not-found") => {
202
+ let err = format!("browser selector not found: {selector}");
203
+ log_action("TYPE_ERROR", &err);
204
+ Err(err)
205
+ }
206
+ _ => {
207
+ let err = response_error(&response);
208
+ log_action("TYPE_ERROR", &err);
209
+ Err(err)
210
+ }
211
+ },
212
+ Err(e) => {
213
+ log_action("TYPE_ERROR", &format!("Websocket error: {e}"));
214
+ Err(e)
215
+ }
216
+ }
217
+ }
218
+
219
+ pub async fn is_browser_running(config: &MintConfig) -> bool {
220
+ let endpoint = config
221
+ .extra
222
+ .get("browserDebugUrl")
223
+ .and_then(Value::as_str)
224
+ .unwrap_or("http://127.0.0.1:9222/json/list");
225
+ fetch_pages_endpoint(endpoint).await.is_ok()
226
+ }
227
+
228
+ pub async fn spawn_automation_browser(config: &MintConfig) -> Result<(), String> {
229
+ let endpoint = config
230
+ .extra
231
+ .get("browserDebugUrl")
232
+ .and_then(Value::as_str)
233
+ .unwrap_or("http://127.0.0.1:9222/json/list");
234
+
235
+ if fetch_pages_endpoint(endpoint).await.is_ok() {
236
+ return Ok(());
237
+ }
238
+
239
+ let browser_name = config
240
+ .extra
241
+ .get("automationBrowser")
242
+ .and_then(Value::as_str)
243
+ .unwrap_or("chromium");
244
+
245
+ let profile_dir = dirs::config_dir()
246
+ .unwrap_or_else(|| std::env::temp_dir())
247
+ .join("mint")
248
+ .join("automation-profile");
249
+ let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
250
+
251
+ let args = [
252
+ "--remote-debugging-port=9222".to_owned(),
253
+ "--no-first-run".to_owned(),
254
+ "--no-default-browser-check".to_owned(),
255
+ profile_arg,
256
+ ];
257
+
258
+ let mut spawned = false;
259
+ let executables = if cfg!(target_os = "windows") {
260
+ vec!["chrome.exe", "chromium.exe", "msedge.exe"]
261
+ } else if cfg!(target_os = "macos") {
262
+ vec![
263
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
264
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
265
+ "chromium",
266
+ "google-chrome",
267
+ ]
268
+ } else {
269
+ vec![
270
+ "chromium",
271
+ "google-chrome-stable",
272
+ "google-chrome",
273
+ "chrome",
274
+ "chromium-browser",
275
+ ]
276
+ };
277
+
278
+ for exe in executables {
279
+ if std::process::Command::new(exe)
280
+ .args(&args)
281
+ .stdout(std::process::Stdio::null())
282
+ .stderr(std::process::Stdio::null())
283
+ .spawn()
284
+ .is_ok()
285
+ {
286
+ spawned = true;
287
+ break;
288
+ }
289
+ }
290
+
291
+ if !spawned {
292
+ return Err(format!(
293
+ "Could not find or spawn browser '{browser_name}' with remote debugging. Please verify it is installed."
294
+ ));
295
+ }
296
+
297
+ for _ in 0..20 {
298
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
299
+ if fetch_pages_endpoint(endpoint).await.is_ok() {
300
+ return Ok(());
301
+ }
302
+ }
303
+
304
+ Err("Browser spawned but remote debugging port 9222 did not become available.".to_string())
305
+ }
306
+
307
+ pub async fn ensure_page_open(config: &MintConfig) -> Result<(), String> {
308
+ if !is_browser_running(config).await {
309
+ return Err("Browser automation is not running. Please run 'mint auto' first.".to_string());
310
+ }
311
+ let endpoint = config
312
+ .extra
313
+ .get("browserDebugUrl")
314
+ .and_then(Value::as_str)
315
+ .unwrap_or("http://127.0.0.1:9222/json/list");
316
+
317
+ let pages = fetch_pages_endpoint(endpoint).await?;
318
+ let has_page = pages.iter().any(|p| p["type"] == "page");
319
+ if !has_page {
320
+ let base_url = endpoint.replace("/json/list", "/json/new");
321
+ let client = crate::HTTP_CLIENT.clone();
322
+ let _ = client
323
+ .put(&base_url)
324
+ .send()
325
+ .await
326
+ .map_err(|e| format!("failed to open new tab: {e}"))?;
327
+ }
328
+ Ok(())
329
+ }
330
+
331
+ async fn cdp_call(config: &MintConfig, method: &str, params: Value) -> Result<Value, String> {
332
+ let page = fetch_pages(config)
333
+ .await?
334
+ .into_iter()
335
+ .find(|page| page["type"] == "page")
336
+ .ok_or("Chrome DevTools did not report an open browser page")?;
337
+ let socket_url = page["webSocketDebuggerUrl"]
338
+ .as_str()
339
+ .ok_or("Chrome DevTools page does not expose a websocket URL")?;
340
+ let (mut socket, _) = connect_async(socket_url)
341
+ .await
342
+ .map_err(|error| format!("unable to connect to Chrome DevTools websocket: {error}"))?;
343
+ socket
344
+ .send(Message::Text(
345
+ json!({ "id": 1, "method": method, "params": params })
346
+ .to_string()
347
+ .into(),
348
+ ))
349
+ .await
350
+ .map_err(|error| error.to_string())?;
351
+ while let Some(message) = socket.next().await {
352
+ let message = message.map_err(|error| error.to_string())?;
353
+ let Message::Text(raw) = message else {
354
+ continue;
355
+ };
356
+ let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
357
+ if value["id"] == 1 {
358
+ return Ok(value);
359
+ }
360
+ }
361
+ Err("Chrome DevTools websocket closed before returning a response".into())
362
+ }
363
+
364
+ async fn fetch_pages(config: &MintConfig) -> Result<Vec<Value>, String> {
365
+ let endpoint = config
366
+ .extra
367
+ .get("browserDebugUrl")
368
+ .and_then(Value::as_str)
369
+ .unwrap_or("http://127.0.0.1:9222/json/list");
370
+ fetch_pages_endpoint(endpoint).await
371
+ }
372
+
373
+ async fn fetch_pages_endpoint(endpoint: &str) -> Result<Vec<Value>, String> {
374
+ let client = reqwest::Client::builder()
375
+ .timeout(std::time::Duration::from_millis(1000))
376
+ .connect_timeout(std::time::Duration::from_millis(500))
377
+ .build()
378
+ .unwrap_or_else(|_| crate::HTTP_CLIENT.clone());
379
+
380
+ let value: Value = client
381
+ .get(endpoint)
382
+ .send()
383
+ .await
384
+ .map_err(|error| format!("unable to reach Chrome DevTools at {endpoint}: {error}"))?
385
+ .error_for_status()
386
+ .map_err(|error| error.to_string())?
387
+ .json()
388
+ .await
389
+ .map_err(|error| error.to_string())?;
390
+ value
391
+ .as_array()
392
+ .cloned()
393
+ .ok_or_else(|| "Chrome DevTools response was not a page list".into())
394
+ }
395
+
396
+ fn response_error(response: &Value) -> String {
397
+ response["error"]["message"]
398
+ .as_str()
399
+ .unwrap_or("Chrome DevTools returned an unexpected response")
400
+ .to_owned()
401
+ }
@@ -208,7 +208,20 @@ pub fn set_config_value(key: &str, value: Value) -> Result<MintConfig, ConfigErr
208
208
 
209
209
  fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
210
210
  if !path.exists() {
211
- let config = MintConfig::default();
211
+ let mut config = MintConfig::default();
212
+ if which("gk") {
213
+ let mut servers = BTreeMap::new();
214
+ servers.insert(
215
+ "gitkraken".to_string(),
216
+ serde_json::json!({
217
+ "command": "gk",
218
+ "args": ["mcp"]
219
+ }),
220
+ );
221
+ config
222
+ .extra
223
+ .insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
224
+ }
212
225
  save_config_to(path, &config)?;
213
226
  return Ok(config);
214
227
  }
@@ -225,6 +238,32 @@ fn load_config_from(path: &Path) -> Result<MintConfig, ConfigError> {
225
238
  for (key, value) in runtime_extra_defaults() {
226
239
  config.extra.entry(key).or_insert(value);
227
240
  }
241
+
242
+ let has_gitkraken = config
243
+ .extra
244
+ .get("mcpServers")
245
+ .and_then(|v| v.get("gitkraken"))
246
+ .is_some();
247
+ if !has_gitkraken && which("gk") {
248
+ let mut servers: BTreeMap<String, Value> = config
249
+ .extra
250
+ .get("mcpServers")
251
+ .cloned()
252
+ .and_then(|v| serde_json::from_value(v).ok())
253
+ .unwrap_or_default();
254
+ servers.insert(
255
+ "gitkraken".to_string(),
256
+ serde_json::json!({
257
+ "command": "gk",
258
+ "args": ["mcp"]
259
+ }),
260
+ );
261
+ config
262
+ .extra
263
+ .insert("mcpServers".into(), serde_json::to_value(servers).unwrap());
264
+ let _ = save_config_to(path, &config);
265
+ }
266
+
228
267
  Ok(config)
229
268
  }
230
269
 
@@ -317,6 +356,21 @@ fn runtime_extra_defaults() -> BTreeMap<String, Value> {
317
356
  .expect("runtime config defaults must be a JSON object")
318
357
  }
319
358
 
359
+ pub fn which(cmd: &str) -> bool {
360
+ #[cfg(windows)]
361
+ let check_cmd = "where";
362
+ #[cfg(not(windows))]
363
+ let check_cmd = "which";
364
+
365
+ std::process::Command::new(check_cmd)
366
+ .arg(cmd)
367
+ .stdout(std::process::Stdio::null())
368
+ .stderr(std::process::Stdio::null())
369
+ .status()
370
+ .map(|status| status.success())
371
+ .unwrap_or(false)
372
+ }
373
+
320
374
  #[cfg(test)]
321
375
  mod tests {
322
376
  use super::*;
@@ -384,4 +438,18 @@ mod tests {
384
438
  );
385
439
  assert_eq!(config.extra["allowedMcpTools"], serde_json::json!({}));
386
440
  }
441
+
442
+ #[test]
443
+ fn test_gitkraken_auto_detection() {
444
+ let path = std::env::temp_dir().join("mint-test-config.json");
445
+ let _ = std::fs::remove_file(&path);
446
+
447
+ let initial_config = MintConfig::default();
448
+ save_config_to(&path, &initial_config).unwrap();
449
+
450
+ let loaded = load_config_from(&path).unwrap();
451
+ assert!(loaded.theme.len() > 0);
452
+
453
+ let _ = std::fs::remove_file(&path);
454
+ }
387
455
  }
@@ -1,6 +1,7 @@
1
1
  #![recursion_limit = "256"]
2
2
 
3
3
  pub mod agent_loop;
4
+ pub mod browser;
4
5
  pub mod chat;
5
6
  pub mod code_tools;
6
7
  pub mod config;
@@ -29,6 +30,10 @@ pub use chat::{
29
30
  stream_chat_with_fallback,
30
31
  };
31
32
 
33
+ pub use browser::{
34
+ BrowserTab, click, is_browser_running, list_tabs, navigate, read_page_text,
35
+ spawn_automation_browser, type_text,
36
+ };
32
37
  pub use code_tools::{
33
38
  AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
34
39
  CodePatchHunk, CodePlan, CodeSearchHit, RepositorySummary, apply_code_edits, build_code_patch,
@@ -253,6 +253,16 @@ fn request_chat_id(request: &ChatRequest) -> &str {
253
253
 
254
254
  const MAX_STEPS: usize = 32;
255
255
  const MAX_OBSERVATION_BYTES: usize = 16_000;
256
+ fn is_port_9222_open() -> bool {
257
+ use std::net::TcpStream;
258
+ use std::time::Duration;
259
+ if let Ok(addr) = "127.0.0.1:9222".parse() {
260
+ TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok()
261
+ } else {
262
+ false
263
+ }
264
+ }
265
+
256
266
  pub fn build_system_prompt(config: &MintConfig) -> String {
257
267
  let mut allowed_actions = vec![
258
268
  "list_files",
@@ -284,6 +294,14 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
284
294
  "apply_patch",
285
295
  "write_file",
286
296
  ];
297
+
298
+ if is_port_9222_open() {
299
+ allowed_actions.push("browser_open");
300
+ allowed_actions.push("browser_click");
301
+ allowed_actions.push("browser_type");
302
+ allowed_actions.push("browser_read");
303
+ }
304
+
287
305
  allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
288
306
  allowed_actions.push("finish");
289
307
 
@@ -319,6 +337,19 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
319
337
  if allowed_actions.contains(&"web_search") {
320
338
  input_formats.push("- web_search: {\"query\":\"search terms\",\"limit\":5}");
321
339
  }
340
+ if allowed_actions.contains(&"browser_open") {
341
+ input_formats.push("- browser_open: {\"url\":\"https://example.com\"}");
342
+ }
343
+ if allowed_actions.contains(&"browser_click") {
344
+ input_formats.push("- browser_click: {\"selector\":\"button.submit-btn\"}");
345
+ }
346
+ if allowed_actions.contains(&"browser_type") {
347
+ input_formats
348
+ .push("- browser_type: {\"selector\":\"input.search-bar\", \"text\":\"search query\"}");
349
+ }
350
+ if allowed_actions.contains(&"browser_read") {
351
+ input_formats.push("- browser_read: {}");
352
+ }
322
353
  if allowed_actions.contains(&"memory_recall") {
323
354
  input_formats.push("- memory_recall: {\"query\":\"what did user say about X\"}");
324
355
  }
@@ -420,6 +451,20 @@ pub fn build_system_prompt(config: &MintConfig) -> String {
420
451
  if allowed_actions.contains(&"web_search") {
421
452
  rules.push("7. Use web_search when the user asks to look something up online or needs current information.");
422
453
  }
454
+ if allowed_actions.contains(&"browser_open") {
455
+ rules.push("7a. Use browser_open to navigate the virtual browser to a URL.");
456
+ }
457
+ if allowed_actions.contains(&"browser_click") {
458
+ rules.push("7b. Use browser_click to click elements on the page.");
459
+ }
460
+ if allowed_actions.contains(&"browser_type") {
461
+ rules.push("7c. Use browser_type to enter text/input into form fields or search boxes.");
462
+ }
463
+ if allowed_actions.contains(&"browser_read") {
464
+ rules.push(
465
+ "7d. Use browser_read to read the text content of the active page to analyze it.",
466
+ );
467
+ }
423
468
  if allowed_actions.contains(&"memory_recall") {
424
469
  rules.push("8. Use memory_recall to search past interactions before asking the user to repeat context.");
425
470
  }
@@ -590,6 +635,12 @@ struct AgentInput {
590
635
  title: String,
591
636
  #[serde(default)]
592
637
  status: String,
638
+ #[serde(default)]
639
+ url: String,
640
+ #[serde(default)]
641
+ selector: String,
642
+ #[serde(default)]
643
+ text: String,
593
644
  }
594
645
 
595
646
  #[derive(Debug, Deserialize, Serialize)]
@@ -632,7 +683,7 @@ where
632
683
  ))
633
684
  })?;
634
685
  let resolved_task = resolve_github_links(task, config).await;
635
- let skills = crate::skills::learned_skills_context().unwrap_or_default();
686
+ let skills = crate::skills::learned_skills_context(Some(&root)).unwrap_or_default();
636
687
  let mut observation = initial_observation(&resolved_task, &root, &skills);
637
688
  let mut pending_image = image_data_uri;
638
689
 
@@ -1076,6 +1127,91 @@ where
1076
1127
  )),
1077
1128
  }
1078
1129
  }
1130
+ "browser_open" => {
1131
+ let url = if !input.url.is_empty() {
1132
+ &input.url
1133
+ } else if !input.path.is_empty() {
1134
+ &input.path
1135
+ } else {
1136
+ return Err(OrchestrationError::Agent(
1137
+ "browser_open requires 'url'".into(),
1138
+ ));
1139
+ };
1140
+ if crate::is_browser_running(config).await {
1141
+ let result = crate::browser::navigate(config, url)
1142
+ .await
1143
+ .map_err(|e| OrchestrationError::Agent(e))?;
1144
+ Ok(result)
1145
+ } else {
1146
+ let opened = if cfg!(target_os = "macos") {
1147
+ std::process::Command::new("open").arg(url).spawn().is_ok()
1148
+ } else if cfg!(target_os = "windows") {
1149
+ std::process::Command::new("cmd")
1150
+ .args(&["/C", "start", url])
1151
+ .spawn()
1152
+ .is_ok()
1153
+ } else {
1154
+ std::process::Command::new("xdg-open")
1155
+ .arg(url)
1156
+ .spawn()
1157
+ .is_ok()
1158
+ };
1159
+ if opened {
1160
+ Ok(format!(
1161
+ "Mint Auto is not active. Opened {url} in your default browser instead."
1162
+ ))
1163
+ } else {
1164
+ Err(OrchestrationError::Agent(
1165
+ "Failed to open URL in default browser.".into(),
1166
+ ))
1167
+ }
1168
+ }
1169
+ }
1170
+ "browser_click" => {
1171
+ let selector = if !input.selector.is_empty() {
1172
+ &input.selector
1173
+ } else if !input.path.is_empty() {
1174
+ &input.path
1175
+ } else {
1176
+ return Err(OrchestrationError::Agent(
1177
+ "browser_click requires 'selector'".into(),
1178
+ ));
1179
+ };
1180
+ let result = crate::browser::click(config, selector)
1181
+ .await
1182
+ .map_err(|e| OrchestrationError::Agent(e))?;
1183
+ Ok(result)
1184
+ }
1185
+ "browser_type" => {
1186
+ let selector = if !input.selector.is_empty() {
1187
+ &input.selector
1188
+ } else if !input.path.is_empty() {
1189
+ &input.path
1190
+ } else {
1191
+ return Err(OrchestrationError::Agent(
1192
+ "browser_type requires 'selector'".into(),
1193
+ ));
1194
+ };
1195
+ let text = if !input.text.is_empty() {
1196
+ &input.text
1197
+ } else if !input.query.is_empty() {
1198
+ &input.query
1199
+ } else {
1200
+ return Err(OrchestrationError::Agent(
1201
+ "browser_type requires 'text'".into(),
1202
+ ));
1203
+ };
1204
+ let result = crate::browser::type_text(config, selector, text)
1205
+ .await
1206
+ .map_err(|e| OrchestrationError::Agent(e))?;
1207
+ Ok(result)
1208
+ }
1209
+ "browser_read" => {
1210
+ let result = crate::browser::read_page_text(config)
1211
+ .await
1212
+ .map_err(|e| OrchestrationError::Agent(e))?;
1213
+ Ok(result)
1214
+ }
1079
1215
  "memory_recall" => {
1080
1216
  let query = required(&input.query, "query")?;
1081
1217
  let query_lower = query.to_ascii_lowercase();