@pheem49/mint 1.6.4 → 1.7.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 (34) hide show
  1. package/Cargo.toml +1 -1
  2. package/bin/mint +0 -0
  3. package/crates/mint-cli/src/agent.rs +42 -14
  4. package/crates/mint-cli/src/main.rs +412 -2
  5. package/crates/mint-cli/src/setup.rs +173 -32
  6. package/crates/mint-core/src/api_server.rs +35 -0
  7. package/crates/mint-core/src/config.rs +2 -2
  8. package/crates/mint-core/src/memory.rs +27 -1
  9. package/crates/mint-core/src/orchestration.rs +1 -1
  10. package/crates/mint-core/src/plugins.rs +35 -0
  11. package/crates/mint-core/tests/memory_persistence.rs +28 -0
  12. package/package.json +12 -2
  13. package/src/renderer/src/agentProgress.ts +39 -0
  14. package/src/renderer/src/components/ChatPanel.tsx +308 -34
  15. package/src/renderer/src/components/DashboardSidebar.tsx +3 -3
  16. package/src/renderer/src/components/MintDashboard.tsx +71 -27
  17. package/src/renderer/src/components/ModelPanel.tsx +2 -2
  18. package/src/renderer/src/components/PicturesLibrary.tsx +5 -1
  19. package/src/renderer/src/components/SpotlightWindow.tsx +8 -8
  20. package/src/renderer/src/components/WorkspacePanel.tsx +1 -1
  21. package/src/renderer/src/css/styles.css +195 -17
  22. package/src/renderer/src/index.css +46 -0
  23. package/src/renderer/src/tauri.ts +25 -0
  24. package/src/renderer/src-web/App.tsx +16 -1
  25. package/src/renderer/src-web/agentProgress.ts +39 -0
  26. package/src/renderer/src-web/components/ChatPanel.tsx +309 -35
  27. package/src/renderer/src-web/components/DashboardSidebar.tsx +1 -1
  28. package/src/renderer/src-web/components/MintDashboard.tsx +53 -16
  29. package/src/renderer/src-web/components/PicturesLibrary.tsx +5 -1
  30. package/src/renderer/src-web/css/styles.css +242 -18
  31. package/src/renderer/src-web/index.css +46 -0
  32. package/src/renderer/src-web/tauri.ts +25 -0
  33. package/src-tauri/src/lib.rs +16 -0
  34. package/src-tauri/tauri.conf.json +1 -1
package/Cargo.toml CHANGED
@@ -7,7 +7,7 @@ members = [
7
7
  resolver = "2"
8
8
 
9
9
  [workspace.package]
10
- version = "1.6.4"
10
+ version = "1.7.1"
11
11
  edition = "2024"
12
12
  license = "AGPL-3.0-only"
13
13
 
package/bin/mint CHANGED
Binary file
@@ -555,7 +555,7 @@ fn render_live_status(status: &mut LiveStatus) {
555
555
  let frames = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
556
556
  let frame = frames[status.spinner_tick % frames.len()];
557
557
  status.spinner_tick += 1;
558
- lines.push(format!("{MINT}{frame}{RESET}{BRIGHT}{thinking}{RESET}"));
558
+ lines.push(format!("{MINT}{frame}{RESET} {BRIGHT}{thinking}{RESET}"));
559
559
  }
560
560
  if lines.is_empty() {
561
561
  return;
@@ -801,22 +801,50 @@ fn format_markdown_bold(text: &str) -> String {
801
801
  }
802
802
 
803
803
  if !in_code_block {
804
- let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
805
- if hash_count > 0 && hash_count <= 6 {
806
- let next_char = trimmed.chars().nth(hash_count);
807
- let is_heading = match next_char {
808
- Some(' ') => true,
809
- Some(c) => !c.is_alphanumeric(),
810
- None => true,
811
- };
812
- if is_heading {
813
- // Make the entire heading line bold and bright white
814
- formatted_line = format!("{}{}{}", BRIGHT, line, RESET);
804
+ let mut leading_spaces = 0;
805
+ let mut is_list_item = false;
806
+ let mut marker_char = '-';
807
+
808
+ for (idx, c) in line.char_indices() {
809
+ if c.is_whitespace() {
810
+ leading_spaces += c.len_utf8();
815
811
  } else {
816
- formatted_line = process_inline_bold(&formatted_line);
812
+ if c == '-' || c == '*' || c == '+' {
813
+ let after = &line[idx + c.len_utf8()..];
814
+ if after.chars().next().map_or(false, |c| c.is_whitespace()) {
815
+ is_list_item = true;
816
+ marker_char = c;
817
+ }
818
+ }
819
+ break;
817
820
  }
821
+ }
822
+
823
+ if is_list_item {
824
+ let marker_len = marker_char.len_utf8();
825
+ let mut new_line = String::new();
826
+ new_line.push_str(&line[..leading_spaces]);
827
+ new_line.push('•');
828
+ new_line.push_str(&line[leading_spaces + marker_len..]);
829
+ formatted_line = process_inline_bold(&new_line);
818
830
  } else {
819
- formatted_line = process_inline_bold(&formatted_line);
831
+ let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
832
+ if hash_count > 0 && hash_count <= 6 {
833
+ let next_char = trimmed.chars().nth(hash_count);
834
+ let is_heading = match next_char {
835
+ Some(' ') => true,
836
+ Some(c) => !c.is_alphanumeric(),
837
+ None => true,
838
+ };
839
+ if is_heading {
840
+ // Make the entire heading line bold and bright white
841
+ formatted_line = format!("{}{}{}", BRIGHT, line, RESET);
842
+ } else {
843
+ formatted_line = process_inline_bold(&formatted_line);
844
+ }
845
+ } else {
846
+ formatted_line = process_inline_bold(&formatted_line);
847
+ }
820
848
  }
821
849
  } else {
822
850
  // Do not format markdown inside code blocks
@@ -1084,6 +1084,295 @@ async fn launch_mint_target(target: String) -> Result<()> {
1084
1084
  Ok(())
1085
1085
  }
1086
1086
 
1087
+ fn is_table_line(line: &str) -> bool {
1088
+ let trimmed = line.trim();
1089
+ trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.len() > 1
1090
+ }
1091
+
1092
+ fn process_inline_bold(s: &str) -> String {
1093
+ let mut result = String::new();
1094
+ let parts = s.split("**");
1095
+ let mut is_bold = false;
1096
+ for part in parts {
1097
+ if is_bold {
1098
+ result.push_str("\x1b[1m");
1099
+ result.push_str(part);
1100
+ result.push_str("\x1b[0m");
1101
+ } else {
1102
+ result.push_str(part);
1103
+ }
1104
+ is_bold = !is_bold;
1105
+ }
1106
+ result
1107
+ }
1108
+
1109
+ fn format_line(line: &str) -> String {
1110
+ let mut formatted = line.to_string();
1111
+ let trimmed = line.trim_start();
1112
+ if (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
1113
+ && trimmed.len() >= 2
1114
+ {
1115
+ let leading_len = line.len() - trimmed.len();
1116
+ let leading_spaces = &line[..leading_len];
1117
+ formatted = format!("{}•{}", leading_spaces, &trimmed[1..]);
1118
+ }
1119
+ process_inline_bold(&formatted)
1120
+ }
1121
+
1122
+ fn unicode_width(s: &str) -> usize {
1123
+ let mut count = 0;
1124
+ for c in s.chars() {
1125
+ let val = c as u32;
1126
+ if (0x0E31..=0x0E31).contains(&val)
1127
+ || (0x0E34..=0x0E3A).contains(&val)
1128
+ || (0x0E47..=0x0E4E).contains(&val)
1129
+ {
1130
+ continue;
1131
+ }
1132
+ if is_cjk(c) {
1133
+ count += 2;
1134
+ } else {
1135
+ count += 1;
1136
+ }
1137
+ }
1138
+ count
1139
+ }
1140
+
1141
+ fn is_cjk(c: char) -> bool {
1142
+ let val = c as u32;
1143
+ (0x4E00..=0x9FFF).contains(&val)
1144
+ || (0x3400..=0x4DBF).contains(&val)
1145
+ || (0x20000..=0x2A6DF).contains(&val)
1146
+ || (0xAC00..=0xD7AF).contains(&val)
1147
+ || (0x3040..=0x309F).contains(&val)
1148
+ || (0x30A0..=0x30FF).contains(&val)
1149
+ }
1150
+
1151
+ fn render_markdown_table(table_lines: &[String]) -> String {
1152
+ let mut rows: Vec<Vec<String>> = Vec::new();
1153
+ for line in table_lines {
1154
+ let trimmed = line.trim();
1155
+ if trimmed.is_empty() {
1156
+ continue;
1157
+ }
1158
+ let content = &trimmed[1..trimmed.len() - 1];
1159
+ let content_escaped = content.replace("\\|", "\u{0000}");
1160
+ let cols: Vec<String> = content_escaped
1161
+ .split('|')
1162
+ .map(|s| s.replace("\u{0000}", "|").trim().to_string())
1163
+ .collect();
1164
+ rows.push(cols);
1165
+ }
1166
+
1167
+ if rows.is_empty() {
1168
+ return String::new();
1169
+ }
1170
+
1171
+ let mut has_separator = false;
1172
+ let mut separator_idx = None;
1173
+ for (i, row) in rows.iter().enumerate() {
1174
+ if row.iter().all(|col| {
1175
+ col.chars()
1176
+ .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
1177
+ }) && !row.is_empty()
1178
+ {
1179
+ has_separator = true;
1180
+ separator_idx = Some(i);
1181
+ break;
1182
+ }
1183
+ }
1184
+
1185
+ let mut data_rows: Vec<Vec<String>> = Vec::new();
1186
+ let mut header_row: Option<Vec<String>> = None;
1187
+
1188
+ if has_separator {
1189
+ let sep_idx = separator_idx.unwrap();
1190
+ if sep_idx > 0 {
1191
+ header_row = Some(rows[sep_idx - 1].clone());
1192
+ for (i, row) in rows.iter().enumerate() {
1193
+ if i != sep_idx && i != sep_idx - 1 {
1194
+ data_rows.push(row.clone());
1195
+ }
1196
+ }
1197
+ } else {
1198
+ for (i, row) in rows.iter().enumerate() {
1199
+ if i != sep_idx {
1200
+ data_rows.push(row.clone());
1201
+ }
1202
+ }
1203
+ }
1204
+ } else {
1205
+ header_row = Some(rows[0].clone());
1206
+ data_rows = rows[1..].to_vec();
1207
+ }
1208
+
1209
+ let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
1210
+ let mut col_widths = vec![0; num_cols];
1211
+
1212
+ if let Some(ref header) = header_row {
1213
+ for (i, col) in header.iter().enumerate() {
1214
+ if i < num_cols {
1215
+ col_widths[i] = col_widths[i].max(unicode_width(col));
1216
+ }
1217
+ }
1218
+ }
1219
+ for row in &data_rows {
1220
+ for (i, col) in row.iter().enumerate() {
1221
+ if i < num_cols {
1222
+ col_widths[i] = col_widths[i].max(unicode_width(col));
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ let mut rendered = String::new();
1228
+
1229
+ let draw_border =
1230
+ |left: &str, mid: &str, right: &str, fill: &str, widths: &[usize]| -> String {
1231
+ let mut s = left.to_string();
1232
+ for (i, &w) in widths.iter().enumerate() {
1233
+ s.push_str(&fill.repeat(w + 2));
1234
+ if i < widths.len() - 1 {
1235
+ s.push_str(mid);
1236
+ }
1237
+ }
1238
+ s.push_str(right);
1239
+ s.push('\n');
1240
+ s
1241
+ };
1242
+
1243
+ rendered.push_str(&draw_border("┌", "┬", "┐", "─", &col_widths));
1244
+
1245
+ if let Some(ref header) = header_row {
1246
+ rendered.push_str("│");
1247
+ for (i, col) in header.iter().enumerate() {
1248
+ let width = unicode_width(col);
1249
+ let padding = col_widths[i] - width;
1250
+ rendered.push_str(&format!(
1251
+ " \x1b[1;36m{}\x1b[0m{}",
1252
+ col,
1253
+ " ".repeat(padding + 1)
1254
+ ));
1255
+ rendered.push_str("│");
1256
+ }
1257
+ rendered.push('\n');
1258
+ rendered.push_str(&draw_border("├", "┼", "┤", "─", &col_widths));
1259
+ }
1260
+
1261
+ for (r_idx, row) in data_rows.iter().enumerate() {
1262
+ rendered.push_str("│");
1263
+ for i in 0..num_cols {
1264
+ let col_val = row.get(i).cloned().unwrap_or_default();
1265
+ let width = unicode_width(&col_val);
1266
+ let padding = col_widths[i] - width;
1267
+ rendered.push_str(&format!(" {}{}", col_val, " ".repeat(padding + 1)));
1268
+ rendered.push_str("│");
1269
+ }
1270
+ rendered.push('\n');
1271
+
1272
+ if r_idx < data_rows.len() - 1 {
1273
+ rendered.push_str(&draw_border("├", "┼", "┤", "─", &col_widths));
1274
+ }
1275
+ }
1276
+
1277
+ rendered.push_str(&draw_border("└", "┴", "┘", "─", &col_widths).trim_end());
1278
+ rendered
1279
+ }
1280
+
1281
+ struct CliStreamFormatter {
1282
+ line_buffer: String,
1283
+ table_buffer: Vec<String>,
1284
+ in_code_block: bool,
1285
+ }
1286
+
1287
+ impl CliStreamFormatter {
1288
+ fn new() -> Self {
1289
+ Self {
1290
+ line_buffer: String::new(),
1291
+ table_buffer: Vec::new(),
1292
+ in_code_block: false,
1293
+ }
1294
+ }
1295
+
1296
+ fn process_char(&mut self, c: char, mut print_fn: impl FnMut(char)) {
1297
+ self.line_buffer.push(c);
1298
+ if c == '\n' {
1299
+ let mut line = self.line_buffer.clone();
1300
+ self.line_buffer.clear();
1301
+ if line.ends_with('\n') {
1302
+ line.pop();
1303
+ }
1304
+
1305
+ let trimmed = line.trim();
1306
+ if trimmed.starts_with("```") {
1307
+ self.in_code_block = !self.in_code_block;
1308
+ }
1309
+
1310
+ if self.in_code_block {
1311
+ if !self.table_buffer.is_empty() {
1312
+ self.flush_table(&mut print_fn);
1313
+ }
1314
+ for ch in line.chars() {
1315
+ print_fn(ch);
1316
+ }
1317
+ print_fn('\n');
1318
+ } else {
1319
+ if is_table_line(&line) {
1320
+ self.table_buffer.push(line);
1321
+ } else {
1322
+ if !self.table_buffer.is_empty() {
1323
+ self.flush_table(&mut print_fn);
1324
+ }
1325
+ let formatted = format_line(&line);
1326
+ for ch in formatted.chars() {
1327
+ print_fn(ch);
1328
+ }
1329
+ print_fn('\n');
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ fn finalize(&mut self, mut print_fn: impl FnMut(char)) {
1336
+ if !self.line_buffer.is_empty() {
1337
+ let mut line = self.line_buffer.clone();
1338
+ self.line_buffer.clear();
1339
+ if line.ends_with('\n') {
1340
+ line.pop();
1341
+ }
1342
+ if self.in_code_block {
1343
+ if !self.table_buffer.is_empty() {
1344
+ self.flush_table(&mut print_fn);
1345
+ }
1346
+ for ch in line.chars() {
1347
+ print_fn(ch);
1348
+ }
1349
+ } else if is_table_line(&line) {
1350
+ self.table_buffer.push(line);
1351
+ } else {
1352
+ if !self.table_buffer.is_empty() {
1353
+ self.flush_table(&mut print_fn);
1354
+ }
1355
+ let formatted = format_line(&line);
1356
+ for ch in formatted.chars() {
1357
+ print_fn(ch);
1358
+ }
1359
+ }
1360
+ }
1361
+ if !self.table_buffer.is_empty() {
1362
+ self.flush_table(&mut print_fn);
1363
+ }
1364
+ }
1365
+
1366
+ fn flush_table<F: FnMut(char)>(&mut self, mut print_fn: F) {
1367
+ let rendered = render_markdown_table(&self.table_buffer);
1368
+ for ch in rendered.chars() {
1369
+ print_fn(ch);
1370
+ }
1371
+ print_fn('\n');
1372
+ self.table_buffer.clear();
1373
+ }
1374
+ }
1375
+
1087
1376
  struct ActionStreamFilter {
1088
1377
  buffer: String,
1089
1378
  in_action: bool,
@@ -1293,6 +1582,10 @@ async fn handle_slash_command(
1293
1582
  ("/paste [prompt]", "Attach image from clipboard"),
1294
1583
  ("Ctrl+V", "Paste clipboard image as [Image #1]"),
1295
1584
  ("/learn <path>", "Import a persistent .md or .txt skill"),
1585
+ (
1586
+ "/plugins <name> [prompt]",
1587
+ "Generate a skill.md file for a plugin/skill",
1588
+ ),
1296
1589
  ("/memory list", "Show recent interactions"),
1297
1590
  ("/memory clear", "Clear all interactions"),
1298
1591
  ("/memory get <key>", "Read a profile value"),
@@ -1676,6 +1969,110 @@ async fn handle_slash_command(
1676
1969
 
1677
1970
  "/exit" | "/quit" => Some(SlashResult::Exit),
1678
1971
 
1972
+ "/plugins" | "/plugin" => {
1973
+ let (plugin_name, prompt) = rest
1974
+ .split_once(char::is_whitespace)
1975
+ .map(|(p, r)| (p, r.trim()))
1976
+ .unwrap_or((rest, ""));
1977
+
1978
+ if plugin_name.is_empty() {
1979
+ println!("\n{BLUE}────────────────────────────────────────────{RESET}");
1980
+ println!("{MINT} Available Plugins & Skills{RESET}");
1981
+ println!("{BLUE}────────────────────────────────────────────{RESET}");
1982
+
1983
+ println!("{MINT}Native Plugins:{RESET}");
1984
+ for p in native_plugins() {
1985
+ println!(" - {BLUE}{:<20}{RESET} {}", p.name, p.description);
1986
+ }
1987
+
1988
+ println!("\n{MINT}Custom Skills/Plugins (Active):{RESET}");
1989
+ let mut active_skills = std::collections::HashSet::new();
1990
+ if let Ok(memory) = MemoryStore::open_default() {
1991
+ match memory.learned_skills(100) {
1992
+ Ok(skills) => {
1993
+ if skills.is_empty() {
1994
+ println!(
1995
+ " {DIM}(No custom skills/plugins learned yet. Use '/learn <path>' to add one.){RESET}"
1996
+ );
1997
+ } else {
1998
+ for s in &skills {
1999
+ println!(" - {BLUE}{:<20}{RESET} {}", s.name, s.source_path);
2000
+
2001
+ // Try to store canonicalized active path
2002
+ if let Ok(canon) = Path::new(&s.source_path).canonicalize() {
2003
+ active_skills.insert(canon);
2004
+ } else {
2005
+ active_skills.insert(PathBuf::from(&s.source_path));
2006
+ }
2007
+ }
2008
+ }
2009
+ }
2010
+ Err(e) => println!(" {ERROR}Error loading custom skills: {e}{RESET}"),
2011
+ }
2012
+ }
2013
+
2014
+ // Scan local workspace skills directory
2015
+ let local_skills_dir = session.current_dir.join("skills");
2016
+ if local_skills_dir.is_dir() {
2017
+ if let Ok(entries) = fs::read_dir(&local_skills_dir) {
2018
+ let mut local_files = Vec::new();
2019
+ for entry in entries.flatten() {
2020
+ let path = entry.path();
2021
+ if path.is_file() {
2022
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
2023
+ if matches!(ext.to_ascii_lowercase().as_str(), "md" | "txt") {
2024
+ if let Some(name) =
2025
+ path.file_name().and_then(|n| n.to_str())
2026
+ {
2027
+ if let Ok(canon_path) = path.canonicalize() {
2028
+ local_files.push((name.to_owned(), canon_path));
2029
+ } else {
2030
+ local_files.push((name.to_owned(), path));
2031
+ }
2032
+ }
2033
+ }
2034
+ }
2035
+ }
2036
+ }
2037
+
2038
+ let inactive_files: Vec<_> = local_files
2039
+ .into_iter()
2040
+ .filter(|(_, full_path)| !active_skills.contains(full_path))
2041
+ .map(|(name, _)| name)
2042
+ .collect();
2043
+
2044
+ if !inactive_files.is_empty() {
2045
+ println!(
2046
+ "\n{MINT}Local Skill Files (Found but not learned/active yet):{RESET}"
2047
+ );
2048
+ for file in inactive_files {
2049
+ println!(
2050
+ " - {WARN}{:<20}{RESET} skills/{} {DIM}(Type '/learn skills/{}' to activate){RESET}",
2051
+ file, file, file
2052
+ );
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ println!();
2059
+ Some(SlashResult::Handled)
2060
+ } else {
2061
+ let agent_prompt = if prompt.is_empty() {
2062
+ format!(
2063
+ "Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace. Write the appropriate instructions inside.",
2064
+ plugin_name, plugin_name
2065
+ )
2066
+ } else {
2067
+ format!(
2068
+ "Create a skill markdown file for the plugin '{}' at the path 'skills/{}.md' in the workspace based on these instructions: {}",
2069
+ plugin_name, plugin_name, prompt
2070
+ )
2071
+ };
2072
+ Some(SlashResult::ForwardToAgent(agent_prompt))
2073
+ }
2074
+ }
2075
+
1679
2076
  "/code" => {
1680
2077
  if rest.is_empty() {
1681
2078
  println!("{WARN}/code requires a task description{RESET}\n");
@@ -1924,6 +2321,7 @@ async fn run_interactive_chat() -> Result<()> {
1924
2321
  let sent_image = image_uri.clone();
1925
2322
  let mut first_chunk = true;
1926
2323
  let mut filter = ActionStreamFilter::new();
2324
+ let mut formatter = CliStreamFormatter::new();
1927
2325
 
1928
2326
  let stream_result = orchestrate_chat_stream_with_fallback(
1929
2327
  &session.config,
@@ -1942,7 +2340,11 @@ async fn run_interactive_chat() -> Result<()> {
1942
2340
  print!("\r\x1b[2K{MINT}Mint:{RESET} ");
1943
2341
  }
1944
2342
  filter.process_chunk(&chunk, |text| {
1945
- print!("{}", text);
2343
+ for c in text.chars() {
2344
+ formatter.process_char(c, |formatted_c| {
2345
+ print!("{}", formatted_c);
2346
+ });
2347
+ }
1946
2348
  });
1947
2349
  let _ = io::stdout().flush();
1948
2350
  },
@@ -1950,7 +2352,14 @@ async fn run_interactive_chat() -> Result<()> {
1950
2352
  .await;
1951
2353
 
1952
2354
  let actions = filter.finalize(|text| {
1953
- print!("{}", text);
2355
+ for c in text.chars() {
2356
+ formatter.process_char(c, |formatted_c| {
2357
+ print!("{}", formatted_c);
2358
+ });
2359
+ }
2360
+ });
2361
+ formatter.finalize(|formatted_c| {
2362
+ print!("{}", formatted_c);
1954
2363
  });
1955
2364
  let _ = io::stdout().flush();
1956
2365
 
@@ -2029,6 +2438,7 @@ const AUTOCOMPLETE_COMMANDS: &[(&str, &str)] = &[
2029
2438
  ("/image", "Attach image from disk"),
2030
2439
  ("/paste", "Attach image from clipboard"),
2031
2440
  ("/learn", "Import persistent skill/instruction"),
2441
+ ("/plugins", "List or generate plugins/skills"),
2032
2442
  ("/memory", "Manage long-term memory store"),
2033
2443
  ("/mcp", "List configured MCP servers"),
2034
2444
  ("/stats", "Show session statistics"),