diff --git a/Cargo.lock b/Cargo.lock index d8d5131be2..4adb160200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,9 +169,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar_archive_writer" @@ -253,14 +253,14 @@ checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -293,7 +293,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -304,7 +304,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -381,9 +381,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -876,7 +876,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -971,7 +971,7 @@ dependencies = [ "serde_derive", "serde_with", "serde_yaml", - "syn 2.0.116", + "syn 2.0.117", "syntect", "terminal-colorsaurus", "thiserror 2.0.18", @@ -1016,7 +1016,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1416,7 +1416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d4f95e880cfd28c4ca5a006cf7f6af52b4bcb7b5866f573b2faa126fb7affb" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1442,7 +1442,7 @@ checksum = "549ca9c364fdc06f9f36d1356980193d930abc80db848193c2dd4d0e9a83de1b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1479,9 +1479,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" dependencies = [ "allocator-api2", ] @@ -1509,7 +1509,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1730,7 +1730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1849,9 +1849,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -1859,9 +1859,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -1887,7 +1887,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1908,11 +1908,11 @@ dependencies = [ [[package]] name = "cliclack" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa510b739c618c679375ea9c5af44ce9f591289546e874ad5910e7ce7df79844" +checksum = "4797110534d49f4e38465be8d84c911f3a9e0f6582f70d3aa4cb30c8fa737851" dependencies = [ - "console 0.15.11", + "console 0.16.2", "indicatif 0.18.4", "once_cell", "strsim", @@ -2021,9 +2021,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "brotli", "compression-core", @@ -2320,7 +2320,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2449,7 +2449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2514,7 +2514,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2528,7 +2528,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2541,7 +2541,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2552,7 +2552,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2563,7 +2563,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2574,7 +2574,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2846,7 +2846,7 @@ checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2913,7 +2913,7 @@ dependencies = [ "stringcase", "strum", "strum_macros", - "syn 2.0.116", + "syn 2.0.117", "syn-match", "thiserror 2.0.18", ] @@ -3059,7 +3059,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3080,7 +3080,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3090,7 +3090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3112,7 +3112,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.116", + "syn 2.0.117", "unicode-xid", ] @@ -3143,7 +3143,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3163,7 +3163,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3248,7 +3248,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3424,7 +3424,27 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3654,7 +3674,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3705,6 +3725,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "find_cuda_helper" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f9e65c593dd01ac77daad909ea4ad17f0d6d1776193fc8ea766356177abdad" +dependencies = [ + "glob", +] + [[package]] name = "finl_unicode" version = "1.4.0" @@ -3823,7 +3852,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3870,7 +3899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4007,7 +4036,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4430,6 +4459,7 @@ dependencies = [ "dashmap 6.1.0", "dirs 5.0.1", "dotenvy", + "encoding_rs", "env-lock", "etcetera 0.11.0", "fs2", @@ -4446,6 +4476,7 @@ dependencies = [ "jsonwebtoken", "keyring", "lazy_static", + "llama-cpp-2", "lru", "minijinja", "mockall", @@ -4544,7 +4575,7 @@ version = "1.24.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4643,6 +4674,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "shell-words", "shellexpand", "tempfile", "tokio", @@ -5568,7 +5600,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5605,7 +5637,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5677,7 +5709,7 @@ checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5843,7 +5875,7 @@ dependencies = [ "log", "openssl", "security-framework 2.11.1", - "security-framework 3.6.0", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] @@ -5874,7 +5906,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6029,6 +6061,34 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "llama-cpp-2" +version = "0.1.135" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b660b832461ef32da66c7acedf35251cdc9e13c0e816bc5904b717cd1e5b25" +dependencies = [ + "encoding_rs", + "enumflags2", + "llama-cpp-sys-2", + "thiserror 2.0.18", + "tracing", + "tracing-core", +] + +[[package]] +name = "llama-cpp-sys-2" +version = "0.1.135" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cf8be1ff000b531ac0523c66a9ffce3cef6d09a5d95b21cad745bedb115468" +dependencies = [ + "bindgen", + "cc", + "cmake", + "find_cuda_helper", + "glob", + "walkdir", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -6303,7 +6363,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6331,7 +6391,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6556,7 +6616,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7011,7 +7071,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7454,7 +7514,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7507,7 +7567,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7536,7 +7596,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7716,7 +7776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7795,7 +7855,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8301,7 +8361,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8617,7 +8677,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8630,7 +8690,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8643,7 +8703,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8812,7 +8872,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", ] [[package]] @@ -8849,7 +8909,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.9", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -8956,7 +9016,7 @@ checksum = "92150f9246c01d501855e34469810a82adc27c416c8d8e21665567f8cd966f29" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9043,7 +9103,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9055,7 +9115,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9101,9 +9161,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", @@ -9114,9 +9174,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -9189,7 +9249,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9200,7 +9260,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9291,7 +9351,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9330,7 +9390,7 @@ checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9364,6 +9424,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.1" @@ -9633,7 +9699,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9656,7 +9722,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.116", + "syn 2.0.117", "tokio", "url", ] @@ -9846,7 +9912,7 @@ checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9890,7 +9956,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9972,7 +10038,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10027,7 +10093,7 @@ checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10132,7 +10198,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10237,7 +10303,7 @@ checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10248,7 +10314,7 @@ checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10488,9 +10554,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -10499,13 +10565,13 @@ dependencies = [ [[package]] name = "syn-match" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783c4140d7ed89f37116e865b49e5a9fdd28608b9071a9dd1e158b50fc0a31fc" +checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10531,7 +10597,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10580,7 +10646,7 @@ checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10840,7 +10906,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10851,7 +10917,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "test-case-core", ] @@ -10907,7 +10973,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10918,7 +10984,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11131,7 +11197,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11239,9 +11305,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum 0.8.8", @@ -11268,9 +11334,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -11370,7 +11436,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11902,7 +11968,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11913,7 +11979,7 @@ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12092,7 +12158,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -12490,7 +12556,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12501,7 +12567,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12512,7 +12578,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12523,7 +12589,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12534,7 +12600,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13009,7 +13075,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -13025,7 +13091,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -13213,7 +13279,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -13225,7 +13291,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -13246,7 +13312,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13266,7 +13332,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -13287,7 +13353,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13321,7 +13387,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] diff --git a/crates/goose-acp/src/bin/server.rs b/crates/goose-acp/src/bin/server.rs index c3c6a9aed5..f59f634a2a 100644 --- a/crates/goose-acp/src/bin/server.rs +++ b/crates/goose-acp/src/bin/server.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Parser; +use goose::builtin_extension::register_builtin_extensions; use goose::config::paths::Paths; use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig}; use std::net::SocketAddr; @@ -29,6 +30,8 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer().with_target(true)) .init(); + register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone()); + let cli = Cli::parse(); let builtins = if cli.builtins.is_empty() { diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4ac9eb2cab..080e8975e6 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -854,6 +854,13 @@ enum Command { #[command(subcommand)] command: TermCommand, }, + /// Manage local inference models + #[command(about = "Manage local inference models", visible_alias = "lm")] + LocalModels { + #[command(subcommand)] + command: LocalModelsCommand, + }, + /// Generate completions for various shells #[command(about = "Generate the autocompletion script for the specified shell")] Completion { @@ -875,6 +882,38 @@ enum Command { }, } +#[derive(Subcommand)] +enum LocalModelsCommand { + /// Search HuggingFace for GGUF models + #[command(about = "Search HuggingFace for GGUF models")] + Search { + /// Search query + query: String, + + /// Maximum number of results + #[arg(short, long, default_value = "10")] + limit: usize, + }, + + /// Download a model from HuggingFace + #[command(about = "Download a GGUF model (e.g. bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M)")] + Download { + /// Model spec in user/repo:quantization format + spec: String, + }, + + /// List downloaded local models + #[command(about = "List downloaded local models")] + List, + + /// Delete a downloaded model + #[command(about = "Delete a downloaded local model")] + Delete { + /// Model ID to delete + id: String, + }, +} + #[derive(Subcommand)] enum TermCommand { /// Print shell initialization script @@ -964,6 +1003,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Recipe { .. }) => "recipe", Some(Command::Web { .. }) => "web", Some(Command::Term { .. }) => "term", + Some(Command::LocalModels { .. }) => "local-models", Some(Command::Completion { .. }) => "completion", Some(Command::ValidateExtensions { .. }) => "validate-extensions", None => "default_session", @@ -1400,6 +1440,167 @@ async fn handle_term_subcommand(command: TermCommand) -> Result<()> { } } +async fn handle_local_models_command(command: LocalModelsCommand) -> Result<()> { + use goose::providers::local_inference::hf_models; + use goose::providers::local_inference::local_model_registry::{ + get_registry, model_id_from_repo, LocalModelEntry, + }; + + match command { + LocalModelsCommand::Search { query, limit } => { + println!("Searching HuggingFace for '{}'...", query); + let results = hf_models::search_gguf_models(&query, limit).await?; + + if results.is_empty() { + println!("No GGUF models found."); + return Ok(()); + } + + for model in &results { + println!( + "\n{} (by {}) — {} downloads", + model.model_name, model.author, model.downloads + ); + for file in &model.gguf_files { + let size = if file.size_bytes > 0 { + format!( + "{:.1}GB", + file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) + } else { + "unknown".to_string() + }; + println!(" {} — {}", file.quantization, size); + } + println!( + " Download: goose local-models download {}:", + model.repo_id + ); + } + } + LocalModelsCommand::Download { spec } => { + println!("Resolving {}...", spec); + let (repo_id, file) = hf_models::resolve_model_spec(&spec).await?; + let model_id = model_id_from_repo(&repo_id, &file.quantization); + let local_path = + goose::config::paths::Paths::in_data_dir("models").join(&file.filename); + + println!( + "Downloading {} ({})...", + model_id, + if file.size_bytes > 0 { + format!( + "{:.1}GB", + file.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) + } else { + "unknown size".to_string() + } + ); + + // Register + let entry = LocalModelEntry { + id: model_id.clone(), + repo_id: repo_id.clone(), + filename: file.filename.clone(), + quantization: file.quantization.clone(), + local_path: local_path.clone(), + source_url: file.download_url.clone(), + settings: Default::default(), + size_bytes: file.size_bytes, + }; + + { + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + registry.add_model(entry)?; + } + + // Download + let manager = goose::download_manager::get_download_manager(); + manager + .download_model( + format!("{}-model", model_id), + file.download_url, + local_path, + None, + ) + .await?; + + // Poll progress + loop { + if let Some(progress) = manager.get_progress(&format!("{}-model", model_id)) { + match progress.status { + goose::download_manager::DownloadStatus::Downloading => { + print!( + "\r {:.1}% ({:.0}MB / {:.0}MB)", + progress.progress_percent, + progress.bytes_downloaded as f64 / (1024.0 * 1024.0), + progress.total_bytes as f64 / (1024.0 * 1024.0), + ); + use std::io::Write; + std::io::stdout().flush().ok(); + } + goose::download_manager::DownloadStatus::Completed => { + println!("\nDownloaded: {}", model_id); + break; + } + goose::download_manager::DownloadStatus::Failed => { + let err = progress.error.unwrap_or_default(); + anyhow::bail!("Download failed: {}", err); + } + goose::download_manager::DownloadStatus::Cancelled => { + println!("\nDownload cancelled."); + break; + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + LocalModelsCommand::List => { + let registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + let models = registry.list_models(); + + if models.is_empty() { + println!("No local models downloaded."); + return Ok(()); + } + + println!("{:<50} {:<10} Downloaded", "ID", "Quant"); + println!("{}", "-".repeat(70)); + for m in models { + println!( + "{:<50} {:<10} {}", + m.id, + m.quantization, + if m.is_downloaded() { "✓" } else { "✗" } + ); + } + } + LocalModelsCommand::Delete { id } => { + let mut registry = get_registry() + .lock() + .map_err(|_| anyhow::anyhow!("Failed to acquire registry lock"))?; + + if let Some(entry) = registry.get_model(&id) { + if entry.local_path.exists() { + std::fs::remove_file(&entry.local_path)?; + } + registry.remove_model(&id)?; + println!("Deleted model: {}", id); + } else { + println!("Model not found: {}", id); + } + } + } + + Ok(()) +} + async fn handle_default_session() -> Result<()> { if !Config::global().exists() { return handle_configure().await; @@ -1530,6 +1731,7 @@ pub async fn cli() -> anyhow::Result<()> { no_auth, }) => crate::commands::web::handle_web(port, host, open, auth_token, no_auth).await, Some(Command::Term { command }) => handle_term_subcommand(command).await, + Some(Command::LocalModels { command }) => handle_local_models_command(command).await, Some(Command::ValidateExtensions { file }) => { use goose::agents::validate_extensions::validate_bundled_extensions; match validate_bundled_extensions(&file) { diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 2ab1183b06..fcc22dc4b0 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -479,7 +479,6 @@ impl CliSession { let history_manager = HistoryManager::new(); history_manager.load(&mut editor); - output::display_greeting(); loop { self.display_context_usage().await?; diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 28ccd1677e..c458744b09 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -261,7 +261,7 @@ pub fn render_message(message: &Message, debug: bool) { } } _ => { - println!("WARNING: Message content type could not be rendered"); + eprintln!("WARNING: Message content type could not be rendered"); } } } @@ -349,7 +349,7 @@ pub fn render_message_streaming( } _ => { flush_markdown_buffer(buffer, theme); - println!("WARNING: Message content type could not be rendered"); + eprintln!("WARNING: Message content type could not be rendered"); } } } @@ -1252,6 +1252,8 @@ pub fn display_session_info( session_id: &Option, provider_instance: Option<&Arc>, ) { + set_terminal_title(); + let status = if resume { "resuming" } else if session_id.is_none() { @@ -1271,8 +1273,16 @@ pub fn display_session_info( model.to_string() }; + let cwd_display = std::env::current_dir() + .ok() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // ASCII art goose with session info on the right + println!(); println!( - "\n {} {} {} {} {}", + " {} {} {} {} {} {}", + style(" __( O)>").white(), style("●").green(), style(status).dim(), style("·").dim(), @@ -1280,28 +1290,30 @@ pub fn display_session_info( style(&model_display).cyan(), ); - let cwd_display = std::env::current_dir() - .ok() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - if let Some(id) = session_id { println!( - " {} {} {}", + " {} {} {} {}", + style(r" \____)").white(), style(" ").dim(), style(id).dim(), style(format!("· {}", cwd_display)).dim(), ); } else { println!( - " {} {}", + " {} {} {}", + style(r" \____)").white(), style(" ").dim(), style(format!(" {}", cwd_display)).dim(), ); } + println!( + " {} {}", + style(" L L").white(), + style(" goose is ready").white() + ); } -pub fn set_terminal_title() { +fn set_terminal_title() { if !std::io::stdout().is_terminal() { return; } @@ -1316,15 +1328,6 @@ pub fn set_terminal_title() { let _ = std::io::stdout().flush(); } -pub fn display_greeting() { - set_terminal_title(); - println!( - "\n{} {}\n", - style("🪿 goose").bold(), - style("ready — type a message to get started").dim() - ); -} - pub fn display_context_usage(total_tokens: usize, context_limit: usize) { use console::style; diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index a3472453ff..c928ce00cd 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -7,6 +7,16 @@ license.workspace = true repository.workspace = true description.workspace = true +# Include 3rd-party licenses for JavaScript/CSS minified files +include = [ + "licenses/chart-js.license", + "licenses/d3-js.license", + "licenses/d3-sankey.license", + "licenses/leaflet.license", + "licenses/leaflet-markercluster.license", + "licenses/mermaid.license" +] + [lints] workspace = true @@ -56,6 +66,7 @@ libc = "0.2" # ~1000 downloads). Pinned to exact version to prevent supply chain attacks. mpatch = "=0.2.0" tokio-util = { workspace = true } +shell-words = "1.1.1" [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/goose-mcp/licenses/chart-js.license b/crates/goose-mcp/licenses/chart-js.license new file mode 100644 index 0000000000..f216610fd7 --- /dev/null +++ b/crates/goose-mcp/licenses/chart-js.license @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2014-2024 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/goose-mcp/licenses/d3-js.license b/crates/goose-mcp/licenses/d3-js.license new file mode 100644 index 0000000000..3594fffaf6 --- /dev/null +++ b/crates/goose-mcp/licenses/d3-js.license @@ -0,0 +1,13 @@ +Copyright 2010-2023 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/crates/goose-mcp/licenses/d3-sankey.license b/crates/goose-mcp/licenses/d3-sankey.license new file mode 100644 index 0000000000..77d73f7ca8 --- /dev/null +++ b/crates/goose-mcp/licenses/d3-sankey.license @@ -0,0 +1,27 @@ +Copyright 2015, Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/goose-mcp/licenses/leaflet-markercluster.license b/crates/goose-mcp/licenses/leaflet-markercluster.license new file mode 100644 index 0000000000..19af06829c --- /dev/null +++ b/crates/goose-mcp/licenses/leaflet-markercluster.license @@ -0,0 +1,20 @@ +Copyright 2012 David Leaver + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/goose-mcp/licenses/leaflet.license b/crates/goose-mcp/licenses/leaflet.license new file mode 100644 index 0000000000..7b41f200ab --- /dev/null +++ b/crates/goose-mcp/licenses/leaflet.license @@ -0,0 +1,26 @@ +BSD 2-Clause License + +Copyright (c) 2010-2023, Volodymyr Agafonkin +Copyright (c) 2010-2011, CloudMade +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/goose-mcp/licenses/mermaid.license b/crates/goose-mcp/licenses/mermaid.license new file mode 100644 index 0000000000..2e5daebd2a --- /dev/null +++ b/crates/goose-mcp/licenses/mermaid.license @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 - 2022 Knut Sveidqvist + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/goose-mcp/src/autovisualiser/templates/assets/leaflet.min.css b/crates/goose-mcp/src/autovisualiser/templates/assets/leaflet.min.css index 2961b7618a..9ade8dc49b 100644 --- a/crates/goose-mcp/src/autovisualiser/templates/assets/leaflet.min.css +++ b/crates/goose-mcp/src/autovisualiser/templates/assets/leaflet.min.css @@ -1,661 +1,661 @@ -/* required styles */ - -.leaflet-pane, -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-tile-container, -.leaflet-pane > svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; - } -.leaflet-container { - overflow: hidden; - } -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; - } -/* Prevents IE11 from highlighting tiles in blue */ -.leaflet-tile::selection { - background: transparent; -} -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; - } -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; - } -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; - } -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg { - max-width: none !important; - max-height: none !important; - } -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer, -.leaflet-container .leaflet-tile { - max-width: none !important; - max-height: none !important; - width: auto; - padding: 0; - } - -.leaflet-container img.leaflet-tile { - /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ - mix-blend-mode: plus-lighter; -} - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; - } -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - /* Fallback for FF which doesn't support pinch-zoom */ - touch-action: none; - touch-action: pinch-zoom; -} -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; - } -.leaflet-tile-loaded { - visibility: inherit; - } -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; - } -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; - } - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; - } -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; - } - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; - } -.leaflet-top { - top: 0; - } -.leaflet-right { - right: 0; - } -.leaflet-bottom { - bottom: 0; - } -.leaflet-left { - left: 0; - } -.leaflet-control { - float: left; - clear: both; - } -.leaflet-right .leaflet-control { - float: right; - } -.leaflet-top .leaflet-control { - margin-top: 10px; - } -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; - } -.leaflet-left .leaflet-control { - margin-left: 10px; - } -.leaflet-right .leaflet-control { - margin-right: 10px; - } - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; - } -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; - } -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - } -svg.leaflet-zoom-animated { - will-change: transform; -} - -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); - } -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - transition: none; - } - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; - } - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; - } -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - cursor: grab; - } -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; - } -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; - } -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - cursor: grabbing; - } - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; - } - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive, -svg.leaflet-image-layer.leaflet-interactive path { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline-offset: 1px; - } -.leaflet-container a { - color: #0078A8; - } -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); - } - - -/* general typography */ -.leaflet-container { - font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; - font-size: 12px; - font-size: 0.75rem; - line-height: 1.5; - } - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; - } -.leaflet-bar a { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; - } -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; - } -.leaflet-bar a:hover, -.leaflet-bar a:focus { - background-color: #f4f4f4; - } -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; - } -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; - } - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; - } -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; - } - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; - } - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; - } - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; - } -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; - } -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; - } -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; - } -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; - } -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; - } -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; - } -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - overflow-x: hidden; - padding-right: 5px; - } -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; - } -.leaflet-control-layers label { - display: block; - font-size: 13px; - font-size: 1.08333em; - } -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; - } - -/* Default icon URLs */ -.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ - background-image: url(images/marker-icon.png); - } - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.8); - margin: 0; - } -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - line-height: 1.4; - } -.leaflet-control-attribution a { - text-decoration: none; - } -.leaflet-control-attribution a:hover, -.leaflet-control-attribution a:focus { - text-decoration: underline; - } -.leaflet-attribution-flag { - display: inline !important; - vertical-align: baseline !important; - width: 1em; - height: 0.6669em; - } -.leaflet-left .leaflet-control-scale { - margin-left: 5px; - } -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; - } -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - white-space: nowrap; - -moz-box-sizing: border-box; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.8); - text-shadow: 1px 1px #fff; - } -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; - } -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; - } - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; - } -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; - } - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; - } -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; - } -.leaflet-popup-content { - margin: 13px 24px 13px 20px; - line-height: 1.3; - font-size: 13px; - font-size: 1.08333em; - min-height: 1px; - } -.leaflet-popup-content p { - margin: 17px 0; - margin: 1.3em 0; - } -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-top: -1px; - margin-left: -20px; - overflow: hidden; - pointer-events: none; - } -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - pointer-events: auto; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); - } -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - border: none; - text-align: center; - width: 24px; - height: 24px; - font: 16px/24px Tahoma, Verdana, sans-serif; - color: #757575; - text-decoration: none; - background: transparent; - } -.leaflet-container a.leaflet-popup-close-button:hover, -.leaflet-container a.leaflet-popup-close-button:focus { - color: #585858; - } -.leaflet-popup-scrolled { - overflow: auto; - } - -.leaflet-oldie .leaflet-popup-content-wrapper { - -ms-zoom: 1; - } -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); - } - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; - } - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; - } - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); - } -.leaflet-tooltip.leaflet-interactive { - cursor: pointer; - pointer-events: auto; - } -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; - } - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; - } -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; - } -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; - } -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; - } -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; - } -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; - } - -/* Printing */ - -@media print { - /* Prevent printers from removing background-images of controls. */ - .leaflet-control { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } - } +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/crates/goose-mcp/src/computercontroller/mod.rs b/crates/goose-mcp/src/computercontroller/mod.rs index 785d612578..d957e73138 100644 --- a/crates/goose-mcp/src/computercontroller/mod.rs +++ b/crates/goose-mcp/src/computercontroller/mod.rs @@ -1,4 +1,6 @@ use crate::subprocess::SubprocessExt; +#[cfg(target_os = "macos")] +use base64::Engine; use etcetera::{choose_app_strategy, AppStrategy}; use indoc::{formatdoc, indoc}; use reqwest::{Client, Url}; @@ -17,6 +19,11 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fs, path::PathBuf, sync::Arc, sync::Mutex}; use tokio::process::Command; +#[cfg(target_os = "macos")] +use rmcp::model::Role; +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicBool, Ordering}; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -91,16 +98,38 @@ pub struct AutomationScriptParams { pub save_output: bool, } -/// Parameters for the computer_control tool +/// Parameters for the computer_control tool (Windows, Linux) +#[cfg(not(target_os = "macos"))] #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ComputerControlParams { - /// The automation script content (PowerShell for Windows, AppleScript for macOS) + /// The automation script content (PowerShell for Windows, shell for Linux) pub script: String, /// Whether to save the script output to a file #[serde(default)] pub save_output: bool, } +/// Parameters for the computer_control tool (macOS — Peekaboo CLI passthrough) +#[cfg(target_os = "macos")] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ComputerControlParams { + /// The peekaboo subcommand and arguments as a single string. + /// Examples: + /// "see --app Safari --annotate" + /// "click --on B1" + /// "type --text \"hello\" --return" + /// "hotkey --keys cmd,c" + /// "app launch Safari --open https://example.com" + /// "window list --app Safari --json" + /// "press tab --count 3" + /// "clipboard --action get" + pub command: String, + /// Whether to capture and return a screenshot as part of the result. + /// Useful after click/type actions to see the updated UI state. + #[serde(default)] + pub capture_screenshot: bool, +} + /// Parameters for the cache tool #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CacheParams { @@ -285,6 +314,8 @@ pub struct ComputerControllerServer { http_client: Client, instructions: String, system_automation: Arc>, + #[cfg(target_os = "macos")] + peekaboo_installed: Arc, } impl Default for ComputerControllerServer { @@ -334,25 +365,96 @@ impl ComputerControllerServer { "macos" => indoc! {r#" Here are some extra tools: automation_script - - Create and run Shell and Ruby scripts - - Shell (bash) is recommended for most tasks + - Create and run Shell, Ruby, or AppleScript scripts - Scripts can save their output to files - - macOS-specific features: - - AppleScript for system and UI control - - Integration with macOS apps and services - - Use the screenshot tool if needed to help with tasks - computer_control - - System automation using AppleScript - - Consider the screenshot tool to work out what is on screen and what to do to help with the control task. + computer_control — Peekaboo CLI for macOS UI automation (auto-installed via Homebrew). + Peekaboo captures/inspects screens, targets UI elements, drives input, and manages + apps/windows/menus. Pass a peekaboo subcommand string as the `command` parameter. + Set `capture_screenshot: true` to capture the screen after actions (click, type, etc.). + Commands support `--json`/`-j` for structured output. Run `peekaboo --help` for + full flags if needed. - When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to: - - Open specific URLs - - Fill in forms - - Click buttons - - Extract content - - Handle web-based workflows - This is often more reliable than web scraping for modern web applications. + Quickstart (most reliable flow): + 1. command: "see --app Safari --annotate" — get annotated screenshot with element IDs + 2. command: "click --on B3 --app Safari" — click element B3 + 3. command: "type \"user@example.com\" --app Safari" — type text + 4. command: "press tab --count 1 --app Safari" — press tab + 5. command: "type \"password\" --app Safari --return" — type and press enter + + Vision: + - see — annotated UI map with element IDs and optional AI analysis + `see --app Safari --annotate`, `see --mode screen --screen-index 0` + `see --app Notes --analyze "describe what's on screen"` + - image — capture screenshots without annotation + `image --mode frontmost`, `image --mode screen --screen-index 1 --retina` + `image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"` + - capture — live motion-aware capture + `capture live --mode region --region 100,100,800,600 --duration 30` + + Interaction: + - click — by element ID, query, or coordinates with smart waits + `click --on B1`, `click --coords 100,200`, `click --on B1 --double`, `click --on B1 --right` + - type — text input with optional control keys + `type "hello" --return`, `type "text" --clear --app Notes`, `type "slow" --wpm 80` + - press — special key sequences with repeats + `press tab --count 3`, `press escape`, `press return`, `press space` + - hotkey — modifier key combos (comma-separated) + `hotkey --keys cmd,c`, `hotkey --keys cmd,shift,t`, `hotkey --keys cmd,a` + - paste — set clipboard then paste (more reliable than type for long text) + `paste --text "long multi-line content"` + - scroll — directional scrolling with optional targeting + `scroll --direction down --amount 5 --smooth`, `scroll --direction up --amount 3` + - drag — drag between elements or coordinates + `drag --from B1 --to T2`, `drag --from-coords 100,100 --to-coords 500,300` + - swipe — gesture-style drags + `swipe --from-coords 100,500 --to-coords 100,200 --duration 800` + - move — cursor positioning + `move 500,300 --smooth` + + Apps & Windows: + - app — launch, quit, switch, list applications + `app launch Safari --open https://example.com`, `app quit Safari` + `app switch Safari`, `app list`, `app hide Safari`, `app unhide Safari` + - window — manage window position, size, focus, list + `window list --app Safari --json`, `window focus --app Safari` + `window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800` + `window close --app Safari`, `window minimize --app Safari` + - list — enumerate apps, windows, screens + `list apps --json`, `list windows --json`, `list screens --json` + - space — macOS Spaces (virtual desktops) + `space list`, `space switch --index 2` + + Menus & System: + - menu — click application menu items + `menu click --app Safari --item "New Window"` + `menu click --app TextEdit --path "Format > Font > Show Fonts"` + - menubar — status bar / menu extras + `menubar list --json`, `menubar click --title "WiFi"` + - dock — Dock items + `dock launch Safari`, `dock list --json` + - dialog — system dialogs and alerts + `dialog click --button "OK"`, `dialog list` + - clipboard — read/write clipboard + `clipboard --action get`, `clipboard --action set --text "content"` + - open — open URLs or files with app targeting + `open https://example.com --app Safari` + - permissions — check Screen Recording / Accessibility status + `permissions status` + + Common targeting parameters (work across most commands): + - App/window: `--app Name`, `--pid 1234`, `--window-title "title"`, `--window-id 5678`, `--window-index 0` + - Elements: `--on B1` (element ID from see), `--coords 100,200` + - Snapshot reuse: `--snapshot ` (reuse a previous see result without re-capturing) + - Focus: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space` + + Tips: + - Always `see --annotate` first to identify element IDs before clicking + - Use `--json` for structured output on list/query commands + - Use `paste` over `type` for long or multi-line text + - Use `--screen-index` for multi-monitor setups + - If something fails, check `permissions status` for missing permissions + - Use `capture_screenshot: true` on click/type/press actions to verify the result "#}, _ => indoc! {r#" Here are some extra tools: @@ -421,6 +523,8 @@ impl ComputerControllerServer { http_client: Client::builder().user_agent("goose/1.0").build().unwrap(), instructions, system_automation, + #[cfg(target_os = "macos")] + peekaboo_installed: Arc::new(AtomicBool::new(crate::peekaboo::is_peekaboo_installed())), } } @@ -592,20 +696,36 @@ impl ComputerControllerServer { } /// Create and run small scripts for automation tasks - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] #[tool( name = "automation_script", description = " - Create and run small scripts for automation tasks. - Supports Shell and Ruby (on macOS). + Create and run Shell, Ruby, or AppleScript (via osascript) scripts. + Use shell (bash) for most tasks. AppleScript for app scripting and system settings. + Examples: + - sort file.txt | uniq + - awk -F ',' '{ print $2}' file.csv + - osascript -e 'tell app \"Finder\" to get name of every window' + " + )] + pub async fn automation_script( + &self, + params: Parameters, + ) -> Result { + self.automation_script_impl(params).await + } - The script is saved to a temporary file and executed. + /// Create and run small scripts for automation tasks + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[tool( + name = "automation_script", + description = " + Create and run Shell scripts for automation tasks. Consider using shell script (bash) for most simple tasks first. - Ruby is useful for text processing or when you need more sophisticated scripting capabilities. - Some examples of shell: - - create a sorted list of unique lines: sort file.txt | uniq - - extract 2nd column in csv: awk -F ',' '{ print $2}' - - pattern matching: grep pattern file.txt + Examples: + - sort file.txt | uniq + - awk -F ',' '{ print $2}' file.csv + - grep pattern file.txt " )] pub async fn automation_script( @@ -788,33 +908,33 @@ impl ComputerControllerServer { self.computer_control_impl(params).await } - /// Control the computer using system automation + /// Control the computer using Peekaboo CLI for macOS GUI automation. + /// Auto-installs via Homebrew on first use. #[cfg(target_os = "macos")] #[tool( name = "computer_control", description = " - Control the computer using AppleScript (macOS only). Automate applications and system features. + macOS UI automation via Peekaboo CLI. Pass a subcommand string as `command`. - Key capabilities: - - Control Applications: Launch, quit, manage apps (Mail, Safari, iTunes, etc) - - Interact with app-specific feature: (e.g, edit documents, process photos) - - Perform tasks in third-party apps that support AppleScript - - UI Automation: Simulate user interactions like, clicking buttons, select menus, type text, filling out forms - - System Control: Manage settings (volume, brightness, wifi), shutdown/restart, monitor events - - Web & Email: Open URLs, web automation, send/organize emails, handle attachments - - Media: Manage music libraries, photo collections, playlists - - File Operations: Organize files/folders - - Integration: Calendar, reminders, messages - - Data: Interact with spreadsheets and documents + Core workflow: see → click → type + 1. see --app Safari --annotate (get annotated screenshot with element IDs) + 2. click --on B3 (click element by ID) + 3. type \"hello\" --return (type text, press enter) - Can be combined with screenshot tool for visual task assistance. + Key commands: see, image, click, type, press, hotkey, paste, scroll, drag, + swipe, move, app, window, list, menu, menubar, dock, dialog, clipboard, + space, open, permissions. + + Targeting: --app Name, --window-title, --window-id, --on ID, --coords x,y + Set capture_screenshot=true to verify UI state after actions. + See extension instructions for full command reference and examples. " )] pub async fn computer_control( &self, params: Parameters, ) -> Result { - self.computer_control_impl(params).await + self.peekaboo_impl(params).await } /// Control the computer using system automation @@ -856,6 +976,7 @@ impl ComputerControllerServer { self.computer_control_impl(params).await } + #[cfg(not(target_os = "macos"))] async fn computer_control_impl( &self, params: Parameters, @@ -892,6 +1013,170 @@ impl ComputerControllerServer { Ok(CallToolResult::success(vec![Content::text(result)])) } + #[cfg(target_os = "macos")] + fn ensure_peekaboo(&self) -> Result<(), ErrorData> { + if self.peekaboo_installed.load(Ordering::Relaxed) { + return Ok(()); + } + if crate::peekaboo::is_peekaboo_installed() { + self.peekaboo_installed.store(true, Ordering::Relaxed); + return Ok(()); + } + tracing::info!("Peekaboo not found, attempting auto-install via brew"); + match crate::peekaboo::auto_install_peekaboo() { + Ok(()) => { + self.peekaboo_installed.store(true, Ordering::Relaxed); + tracing::info!("Peekaboo installed successfully"); + Ok(()) + } + Err(msg) => Err(ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!( + "Peekaboo is not installed and auto-install failed: {}\n\ + Install manually with: brew install steipete/tap/peekaboo\n\ + Peekaboo requires macOS 15+ (Sequoia) with Screen Recording and Accessibility permissions.", + msg + ), + None, + )), + } + } + + #[cfg(target_os = "macos")] + fn run_peekaboo_cmd(&self, args: &[&str]) -> Result { + let output = std::process::Command::new("peekaboo") + .args(args) + .output() + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Failed to run peekaboo: {}", e), + None, + ) + })?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !output.status.success() { + return Err(ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!( + "peekaboo {} failed (exit {}):\n{}\n{}", + args.first().unwrap_or(&""), + output.status, + stderr.trim(), + stdout.trim() + ), + None, + )); + } + Ok(stdout) + } + + #[cfg(target_os = "macos")] + async fn peekaboo_impl( + &self, + params: Parameters, + ) -> Result { + self.ensure_peekaboo()?; + let params = params.0; + + let args = shell_words::split(¶ms.command).map_err(|e| { + ErrorData::new( + ErrorCode::INVALID_PARAMS, + format!("Failed to parse command: {}", e), + None, + ) + })?; + if args.is_empty() { + return Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Command cannot be empty".to_string(), + None, + )); + } + + let is_see = args[0] == "see"; + let is_image = args[0] == "image"; + let screenshot_path = if is_see || is_image { + Some(self.get_cache_path(&args[0], "png")) + } else { + None + }; + + let mut full_args: Vec = args.clone(); + + if let Some(ref path) = screenshot_path { + if !full_args.iter().any(|a| a == "--path") { + full_args.push("--path".to_string()); + full_args.push(path.to_string_lossy().to_string()); + } + } + if is_see && !full_args.iter().any(|a| a == "--json-output") { + full_args.push("--json-output".to_string()); + } + + let wants_json = matches!( + args[0].as_str(), + "list" | "window" | "menubar" | "permissions" | "clipboard" + ); + if wants_json + && !full_args.iter().any(|a| a == "--json" || a == "-j") + && !full_args.iter().any(|a| a == "--json-output") + { + full_args.push("--json".to_string()); + } + + let arg_refs: Vec<&str> = full_args.iter().map(|s| s.as_str()).collect(); + let stdout = self.run_peekaboo_cmd(&arg_refs)?; + + let mut contents = Vec::new(); + + if let Some(ref path) = screenshot_path { + let annotated = path.to_string_lossy().replace(".png", "_annotated.png"); + let image_path = if is_see && std::path::Path::new(&annotated).exists() { + PathBuf::from(&annotated) + } else { + path.clone() + }; + if image_path.exists() { + if let Ok(bytes) = fs::read(&image_path) { + let data = base64::prelude::BASE64_STANDARD.encode(&bytes); + contents.push(Content::image(data, "image/png").with_priority(0.0)); + } + } + } + + if params.capture_screenshot && screenshot_path.is_none() { + let cap_path = self.get_cache_path("peekaboo_capture", "png"); + let cap_path_str = cap_path.to_string_lossy().to_string(); + if self + .run_peekaboo_cmd(&["image", "--mode", "frontmost", "--path", &cap_path_str]) + .is_ok() + && cap_path.exists() + { + if let Ok(bytes) = fs::read(&cap_path) { + let data = base64::prelude::BASE64_STANDARD.encode(&bytes); + contents.push(Content::image(data, "image/png").with_priority(0.0)); + } + } + } + + let text = if stdout.len() > 12000 { + let truncated: String = stdout.chars().take(12000).collect(); + format!( + "{}\n\n[Output truncated. {} total chars.]", + truncated, + stdout.len() + ) + } else { + stdout + }; + + contents.insert(0, Content::text(&text).with_audience(vec![Role::Assistant])); + + Ok(CallToolResult::success(contents)) + } + /// Process Excel (XLSX) files to read and manipulate spreadsheet data #[tool( name = "xlsx_tool", diff --git a/crates/goose-mcp/src/computercontroller/platform/mod.rs b/crates/goose-mcp/src/computercontroller/platform/mod.rs index bd50e27f53..272ee5043c 100644 --- a/crates/goose-mcp/src/computercontroller/platform/mod.rs +++ b/crates/goose-mcp/src/computercontroller/platform/mod.rs @@ -14,6 +14,7 @@ pub use self::macos::MacOSAutomation; #[cfg(any(target_os = "linux", target_os = "android"))] pub use self::linux::LinuxAutomation; +#[allow(dead_code)] pub trait SystemAutomation: Send + Sync { fn execute_system_script(&self, script: &str) -> std::io::Result; fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg) diff --git a/crates/goose-mcp/src/lib.rs b/crates/goose-mcp/src/lib.rs index e1077683c9..530c30dfb9 100644 --- a/crates/goose-mcp/src/lib.rs +++ b/crates/goose-mcp/src/lib.rs @@ -14,6 +14,8 @@ pub mod computercontroller; pub mod developer; pub mod mcp_server_runner; mod memory; +#[cfg(target_os = "macos")] +pub mod peekaboo; pub mod subprocess; pub mod tutorial; diff --git a/crates/goose-mcp/src/peekaboo/mod.rs b/crates/goose-mcp/src/peekaboo/mod.rs new file mode 100644 index 0000000000..2754f63222 --- /dev/null +++ b/crates/goose-mcp/src/peekaboo/mod.rs @@ -0,0 +1,85 @@ +//! Peekaboo helper functions for macOS GUI automation via the Peekaboo CLI. +//! +//! These are used by `ComputerControllerServer` on macOS to auto-install +//! and invoke peekaboo. This module does not expose its own MCP server — +//! peekaboo is accessed through the `computer_control` tool on macOS. + +const BREW_FORMULA: &str = "steipete/tap/peekaboo"; + +pub fn is_peekaboo_installed() -> bool { + std::process::Command::new("which") + .arg("peekaboo") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub fn resolve_brew() -> Option { + if let Ok(output) = std::process::Command::new("which").arg("brew").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Some(path); + } + } + } + + for candidate in &["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] { + if std::path::Path::new(candidate).exists() { + return Some(candidate.to_string()); + } + } + + None +} + +pub fn auto_install_peekaboo() -> Result<(), String> { + let brew = resolve_brew().ok_or_else(|| { + "Homebrew is not installed. Install Homebrew first (https://brew.sh), then run: brew install steipete/tap/peekaboo".to_string() + })?; + + tracing::info!("Running: {} install {}", brew, BREW_FORMULA); + + let output = std::process::Command::new(&brew) + .args(["install", BREW_FORMULA]) + .output() + .map_err(|e| format!("Failed to run brew: {}", e))?; + + if output.status.success() { + if is_peekaboo_installed() { + return Ok(()); + } + // brew succeeded but binary not on PATH — try adding brew bin + if let Ok(prefix_output) = std::process::Command::new(&brew) + .args(["--prefix"]) + .output() + { + let prefix = String::from_utf8_lossy(&prefix_output.stdout) + .trim() + .to_string(); + let bin_path = format!("{}/bin/peekaboo", prefix); + if std::path::Path::new(&bin_path).exists() { + if let Ok(current_path) = std::env::var("PATH") { + std::env::set_var("PATH", format!("{}/bin:{}", prefix, current_path)); + if is_peekaboo_installed() { + return Ok(()); + } + } + } + } + Err("brew install succeeded but peekaboo binary not found on PATH".to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "brew install failed (exit {}):\n{}{}", + output.status, + stderr.trim(), + if stdout.trim().is_empty() { + String::new() + } else { + format!("\n{}", stdout.trim()) + } + )) + } +} diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index f33db83520..e128289bad 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -4,7 +4,7 @@ use goose::agents::ExtensionConfig; use goose::config::permission::PermissionLevel; use goose::config::ExtensionEntry; use goose::conversation::Conversation; -use goose::dictation::download_manager::{DownloadProgress, DownloadStatus}; +use goose::download_manager::{DownloadProgress, DownloadStatus}; use goose::model::ModelConfig; use goose::permission::permission_confirmation::{Permission, PrincipalType}; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; @@ -424,6 +424,15 @@ derive_utoipa!(Icon as IconSchema); super::routes::dictation::get_download_progress, super::routes::dictation::cancel_download, super::routes::dictation::delete_model, + super::routes::local_inference::list_local_models, + super::routes::local_inference::search_hf_models, + super::routes::local_inference::get_repo_files, + super::routes::local_inference::download_hf_model, + super::routes::local_inference::get_local_model_download_progress, + super::routes::local_inference::cancel_local_model_download, + super::routes::local_inference::delete_local_model, + super::routes::local_inference::get_model_settings, + super::routes::local_inference::update_model_settings, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -592,6 +601,15 @@ derive_utoipa!(Icon as IconSchema); goose::dictation::providers::DictationProvider, super::routes::dictation::DictationProviderStatus, super::routes::dictation::WhisperModelResponse, + super::routes::local_inference::LocalModelResponse, + super::routes::local_inference::ModelDownloadStatus, + super::routes::local_inference::DownloadModelRequest, + goose::providers::local_inference::hf_models::HfModelInfo, + goose::providers::local_inference::hf_models::HfGgufFile, + goose::providers::local_inference::hf_models::HfQuantVariant, + super::routes::local_inference::RepoVariantsResponse, + goose::providers::local_inference::local_model_registry::ModelSettings, + goose::providers::local_inference::local_model_registry::SamplingConfig, DownloadProgress, DownloadStatus, )) diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs index fb1c97aa71..8530d2b1c7 100644 --- a/crates/goose-server/src/routes/dictation.rs +++ b/crates/goose-server/src/routes/dictation.rs @@ -7,11 +7,11 @@ use axum::{ Json, Router, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use goose::dictation::download_manager::{get_download_manager, DownloadProgress}; use goose::dictation::providers::{ is_configured, transcribe_local, transcribe_with_provider, DictationProvider, PROVIDERS, }; use goose::dictation::whisper; +use goose::download_manager::{get_download_manager, DownloadProgress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -257,11 +257,16 @@ pub async fn download_model(Path(model_id): Path) -> Result, + }, + Downloaded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct LocalModelResponse { + pub id: String, + pub repo_id: String, + pub filename: String, + pub quantization: String, + pub size_bytes: u64, + pub status: ModelDownloadStatus, + pub recommended: bool, + pub settings: ModelSettings, +} + +async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> { + let mut entries_to_add = Vec::new(); + + for spec in FEATURED_MODELS { + let (repo_id, quantization) = match hf_models::parse_model_spec(spec) { + Ok(parts) => parts, + Err(_) => continue, + }; + + let model_id = model_id_from_repo(&repo_id, &quantization); + + { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + if registry.has_model(&model_id) { + continue; + } + } + + let hf_file = match resolve_model_spec(spec).await { + Ok((_repo, file)) => file, + Err(_) => { + let filename = format!( + "{}-{}.gguf", + repo_id.split('/').next_back().unwrap_or("model"), + quantization + ); + HfGgufFile { + filename: filename.clone(), + size_bytes: 0, + quantization: quantization.to_string(), + download_url: format!( + "https://huggingface.co/{}/resolve/main/{}", + repo_id, filename + ), + } + } + }; + + let local_path = Paths::in_data_dir("models").join(&hf_file.filename); + + entries_to_add.push(LocalModelEntry { + id: model_id, + repo_id, + filename: hf_file.filename, + quantization, + local_path, + source_url: hf_file.download_url, + settings: ModelSettings::default(), + size_bytes: hf_file.size_bytes, + }); + } + + if !entries_to_add.is_empty() { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry.sync_with_featured(entries_to_add); + } + + Ok(()) +} + +#[utoipa::path( + get, + path = "/local-inference/models", + responses( + (status = 200, description = "List of available local LLM models", body = Vec) + ) +)] +pub async fn list_local_models( + axum::extract::State(state): axum::extract::State>, +) -> Result>, ErrorResponse> { + ensure_featured_models_in_registry().await?; + + let recommended_id = recommend_local_model(&state.inference_runtime); + + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + let mut models: Vec = Vec::new(); + + for entry in registry.list_models() { + let goose_status = entry.download_status(); + + let status = match goose_status { + RegistryDownloadStatus::NotDownloaded => ModelDownloadStatus::NotDownloaded, + RegistryDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps, + } => ModelDownloadStatus::Downloading { + progress_percent, + bytes_downloaded, + total_bytes, + speed_bps: Some(speed_bps), + }, + RegistryDownloadStatus::Downloaded => ModelDownloadStatus::Downloaded, + }; + + let size_bytes = entry.file_size(); + + models.push(LocalModelResponse { + id: entry.id.clone(), + repo_id: entry.repo_id.clone(), + filename: entry.filename.clone(), + quantization: entry.quantization.clone(), + size_bytes, + status, + recommended: recommended_id == entry.id, + settings: entry.settings.clone(), + }); + } + + models.sort_by(|a, b| { + let a_downloaded = matches!(a.status, ModelDownloadStatus::Downloaded); + let b_downloaded = matches!(b.status, ModelDownloadStatus::Downloaded); + match (b_downloaded, a_downloaded) { + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + _ => a.id.cmp(&b.id), + } + }); + + Ok(Json(models)) +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub q: String, + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct RepoVariantsResponse { + pub variants: Vec, + pub recommended_index: Option, +} + +#[utoipa::path( + get, + path = "/local-inference/search", + params( + ("q" = String, Query, description = "Search query"), + ("limit" = Option, Query, description = "Max results") + ), + responses( + (status = 200, description = "Search results", body = Vec), + (status = 500, description = "Search failed") + ) +)] +pub async fn search_hf_models( + Query(params): Query, +) -> Result>, ErrorResponse> { + let limit = params.limit.unwrap_or(20).min(50); + let results = hf_models::search_gguf_models(¶ms.q, limit) + .await + .map_err(|e| ErrorResponse::internal(format!("Search failed: {}", e)))?; + Ok(Json(results)) +} + +#[utoipa::path( + get, + path = "/local-inference/repo/{author}/{repo}/files", + responses( + (status = 200, description = "GGUF files in the repo", body = RepoVariantsResponse) + ) +)] +pub async fn get_repo_files( + axum::extract::State(state): axum::extract::State>, + Path((author, repo)): Path<(String, String)>, +) -> Result, ErrorResponse> { + let repo_id = format!("{}/{}", author, repo); + let variants = hf_models::get_repo_gguf_variants(&repo_id) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; + + let available_memory = available_inference_memory_bytes(&state.inference_runtime); + let recommended_index = hf_models::recommend_variant(&variants, available_memory); + + Ok(Json(RepoVariantsResponse { + variants, + recommended_index, + })) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DownloadModelRequest { + /// Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" + pub spec: String, +} + +#[utoipa::path( + post, + path = "/local-inference/download", + request_body = DownloadModelRequest, + responses( + (status = 202, description = "Download started", body = String), + (status = 400, description = "Invalid request") + ) +)] +pub async fn download_hf_model( + Json(req): Json, +) -> Result<(StatusCode, Json), ErrorResponse> { + let (repo_id, quantization) = hf_models::parse_model_spec(&req.spec) + .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec format: {e}")))?; + + let (_repo, hf_file) = resolve_model_spec(&req.spec) + .await + .map_err(|e| ErrorResponse::bad_request(format!("Invalid spec: {}", e)))?; + + let model_id = model_id_from_repo(&repo_id, &quantization); + let local_path = Paths::in_data_dir("models").join(&hf_file.filename); + let download_url = hf_file.download_url.clone(); + + let entry = LocalModelEntry { + id: model_id.clone(), + repo_id, + filename: hf_file.filename, + quantization, + local_path: local_path.clone(), + source_url: download_url.clone(), + settings: ModelSettings::default(), + size_bytes: hf_file.size_bytes, + }; + + { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry + .add_model(entry) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + } + + let dm = get_download_manager(); + dm.download_model( + format!("{}-model", model_id), + download_url, + local_path, + None, + ) + .await + .map_err(|e| ErrorResponse::internal(format!("Download failed: {}", e)))?; + + Ok((StatusCode::ACCEPTED, Json(model_id))) +} + +#[utoipa::path( + get, + path = "/local-inference/models/{model_id}/download", + responses( + (status = 200, description = "Download progress", body = DownloadProgress), + (status = 404, description = "No active download") + ) +)] +pub async fn get_local_model_download_progress( + Path(model_id): Path, +) -> Result, ErrorResponse> { + let download_id = format!("{}-model", model_id); + debug!(model_id = %model_id, download_id = %download_id, "Getting download progress"); + + let manager = get_download_manager(); + + let model_progress = manager + .get_progress(&download_id) + .ok_or_else(|| ErrorResponse::not_found("No active download"))?; + + Ok(Json(model_progress)) +} + +#[utoipa::path( + delete, + path = "/local-inference/models/{model_id}/download", + responses( + (status = 200, description = "Download cancelled"), + (status = 404, description = "No active download") + ) +)] +pub async fn cancel_local_model_download( + Path(model_id): Path, +) -> Result { + let manager = get_download_manager(); + manager + .cancel_download(&format!("{}-model", model_id)) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + + Ok(StatusCode::OK) +} + +#[utoipa::path( + delete, + path = "/local-inference/models/{model_id}", + responses( + (status = 200, description = "Model deleted"), + (status = 404, description = "Model not found") + ) +)] +pub async fn delete_local_model(Path(model_id): Path) -> Result { + let local_path = { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + let entry = registry + .get_model(&model_id) + .ok_or_else(|| ErrorResponse::not_found("Model not found"))?; + entry.local_path.clone() + }; + + if local_path.exists() { + tokio::fs::remove_file(&local_path) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to delete: {}", e)))?; + } + + // Only remove non-featured models from registry (featured ones stay as placeholders) + if !is_featured_model(&model_id) { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + registry + .remove_model(&model_id) + .map_err(|e| ErrorResponse::internal(format!("{}", e)))?; + } + + Ok(StatusCode::OK) +} + +#[utoipa::path( + get, + path = "/local-inference/models/{model_id}/settings", + responses( + (status = 200, description = "Model settings", body = ModelSettings), + (status = 404, description = "Model not found") + ) +)] +pub async fn get_model_settings( + Path(model_id): Path, +) -> Result, ErrorResponse> { + let registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + if let Some(settings) = registry.get_model_settings(&model_id) { + return Ok(Json(settings.clone())); + } + + Err(ErrorResponse::not_found("Model not found")) +} + +#[utoipa::path( + put, + path = "/local-inference/models/{model_id}/settings", + request_body = ModelSettings, + responses( + (status = 200, description = "Settings updated", body = ModelSettings), + (status = 404, description = "Model not found"), + (status = 500, description = "Failed to save settings") + ) +)] +pub async fn update_model_settings( + Path(model_id): Path, + Json(settings): Json, +) -> Result, ErrorResponse> { + let mut registry = get_registry() + .lock() + .map_err(|_| ErrorResponse::internal("Failed to acquire registry lock"))?; + + registry + .update_model_settings(&model_id, settings.clone()) + .map_err(|e| ErrorResponse::not_found(format!("{}", e)))?; + + Ok(Json(settings)) +} + +pub fn routes(state: Arc) -> Router { + goose::download_manager::cleanup_partial_downloads(&Paths::in_data_dir("models")); + + Router::new() + .route("/local-inference/models", get(list_local_models)) + .route("/local-inference/search", get(search_hf_models)) + .route( + "/local-inference/repo/{author}/{repo}/files", + get(get_repo_files), + ) + .route("/local-inference/download", post(download_hf_model)) + .route( + "/local-inference/models/{model_id}/download", + get(get_local_model_download_progress), + ) + .route( + "/local-inference/models/{model_id}/download", + delete(cancel_local_model_download), + ) + .route( + "/local-inference/models/{model_id}", + delete(delete_local_model), + ) + .route( + "/local-inference/models/{model_id}/settings", + get(get_model_settings), + ) + .route( + "/local-inference/models/{model_id}/settings", + axum::routing::put(update_model_settings), + ) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 9eb00449f1..cbb956849b 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod agent; pub mod config_management; pub mod dictation; pub mod errors; +pub mod local_inference; pub mod mcp_app_proxy; pub mod mcp_ui_proxy; pub mod prompts; @@ -30,6 +31,7 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(action_required::routes(state.clone())) .merge(agent::routes(state.clone())) .merge(dictation::routes(state.clone())) + .merge(local_inference::routes(state.clone())) .merge(config_management::routes(state.clone())) .merge(prompts::routes()) .merge(recipe::routes(state.clone())) diff --git a/crates/goose-server/src/routes/utils.rs b/crates/goose-server/src/routes/utils.rs index 713280b14a..1e97a0271d 100644 --- a/crates/goose-server/src/routes/utils.rs +++ b/crates/goose-server/src/routes/utils.rs @@ -94,6 +94,11 @@ pub fn inspect_keys( pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: ProviderType) -> bool { let config = Config::global(); + // Special override + if metadata.name == "local" { + return true; + } + if provider_type == ProviderType::Custom || provider_type == ProviderType::Declarative { if let Ok(loaded_provider) = load_provider(metadata.name.as_str()) { if !loaded_provider.config.requires_auth { diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 57f104d47e..4cf836d42d 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -14,6 +14,7 @@ use tokio::task::JoinHandle; use crate::tunnel::TunnelManager; use goose::agents::ExtensionLoadResult; +use goose::providers::local_inference::InferenceRuntime; type ExtensionLoadingTasks = Arc>>>>>>>; @@ -26,6 +27,7 @@ pub struct AppState { recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, pub extension_loading_tasks: ExtensionLoadingTasks, + pub inference_runtime: Arc, } fn spawn_developer(r: tokio::io::DuplexStream, w: tokio::io::DuplexStream) { @@ -57,6 +59,7 @@ impl AppState { recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), + inference_runtime: InferenceRuntime::get_or_init(), })) } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index ded774e989..a0de55ef8f 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -9,7 +9,7 @@ description.workspace = true [features] default = [] -cuda = ["candle-core/cuda", "candle-nn/cuda"] +cuda = ["candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"] [lints] workspace = true @@ -126,14 +126,17 @@ ignore = { workspace = true } which = { workspace = true } pctx_code_mode = "^0.2.3" unbinder = "0.1.7" +llama-cpp-2 = { version = "0.1.133", features = ["sampler"] } +encoding_rs = "0.8.35" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } -# Platform-specific GPU acceleration for Whisper +# Platform-specific GPU acceleration for Whisper and local inference [target.'cfg(target_os = "macos")'.dependencies] candle-core = { version = "0.9", default-features = false, features = ["metal"] } candle-nn = { version = "0.9", default-features = false, features = ["metal"] } +llama-cpp-2 = { version = "0.1.133", features = ["sampler", "metal"] } [dev-dependencies] serial_test = { workspace = true } @@ -156,6 +159,7 @@ path = "examples/agent.rs" name = "databricks_oauth" path = "examples/databricks_oauth.rs" + [[bin]] name = "build_canonical_models" path = "src/providers/canonical/build_canonical_models.rs" diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index d77668cf4d..51981780f8 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -236,7 +236,12 @@ async fn child_process_client( command.env("PATH", path); } - if let Some(dir) = working_dir { + // Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var + let effective_working_dir = working_dir + .map(|p| p.to_path_buf()) + .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)); + + if let Some(ref dir) = effective_working_dir { if dir.exists() && dir.is_dir() { tracing::info!("Setting MCP process working directory: {:?}", dir); command.current_dir(dir); @@ -710,6 +715,7 @@ impl ExtensionManager { })?; let mut context = self.context.clone(); context.extension_manager = Some(Arc::downgrade(self)); + (def.client_factory)(context) } ExtensionConfig::InlinePython { @@ -1584,6 +1590,16 @@ impl ExtensionManager { session_id: &str, working_dir: &std::path::Path, ) -> Option { + // Skip MOIM for models with small context windows to avoid consuming limited context + const MIN_CONTEXT_FOR_MOIM: usize = 32_000; + if let Ok(provider_guard) = self.provider.try_lock() { + if let Some(provider) = provider_guard.as_ref() { + if provider.get_model_config().context_limit() < MIN_CONTEXT_FOR_MOIM { + return None; + } + } + } + // Use minute-level granularity to prevent conversation changes every second let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:00").to_string(); let mut content = format!( diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 91a0b4a3ad..9ec4f73b6d 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -330,10 +330,12 @@ impl SummonClient { "Load knowledge into your current context or discover available sources.\n\n\ Call with no arguments to list all available sources (subrecipes, recipes, skills, agents).\n\ Call with a source name to load its content into your context.\n\ - For background tasks: load(source: \"task_id\", cancel: true) stops and returns output.\n\n\ + For background tasks: load(source: \"task_id\") waits for the task and returns the result.\n\ + To cancel a running task: load(source: \"task_id\", cancel: true) stops and returns output.\n\n\ Examples:\n\ - load() → Lists available sources\n\ - - load(source: \"rust-patterns\") → Loads the rust-patterns skill" + - load(source: \"rust-patterns\") → Loads the rust-patterns skill\n\ + - load(source: \"20260219_1\") → Waits for background task, then returns result" .to_string(), schema.as_object().unwrap().clone(), ) @@ -880,7 +882,7 @@ impl SummonClient { // Wait for the running task to complete, keeping the tool call // alive so notifications (subagent tool calls) stream in real time. - let task = running.remove(task_id).unwrap(); + let mut task = running.remove(task_id).unwrap(); drop(running); let buffered = { @@ -896,45 +898,37 @@ impl SummonClient { } } - let description = task.description.clone(); - let mut handle = task.handle; - - let (output, timed_out) = tokio::select! { - result = &mut handle => { - let s = match result { + tokio::select! { + result = &mut task.handle => { + let output = match result { Ok(Ok(s)) => s, Ok(Err(e)) => format!("Error: {}", e), Err(e) => format!("Task panicked: {}", e), }; - (s, false) + + return Ok(vec![Content::text(format!( + "# Background Task Result: {}\n\n\ + **Task:** {}\n\ + **Status:** ✓ Completed\n\ + **Duration:** {} ({} turns)\n\n\ + ## Output\n\n{}", + task_id, + task.description, + round_duration(task.started_at.elapsed()), + task.turns.load(Ordering::Relaxed), + output + ))]); } _ = tokio::time::sleep(Duration::from_secs(300)) => { - handle.abort(); - ("Task timed out waiting for completion (aborted after 5 min)".to_string(), true) + self.background_tasks.lock().await.insert(task_id.to_string(), task); + + return Err(format!( + "Task '{task_id}' is still running after waiting 5 min. \ + Use load(source: \"{task_id}\") to wait again, or \ + load(source: \"{task_id}\", cancel: true) to stop." + )); } - }; - - let duration = task.started_at.elapsed(); - let turns_taken = task.turns.load(Ordering::Relaxed); - let status = if timed_out { - "⏱ Timed out" - } else { - "✓ Completed" - }; - - return Ok(vec![Content::text(format!( - "# Background Task Result: {}\n\n\ - **Task:** {}\n\ - **Status:** {}\n\ - **Duration:** {} ({} turns)\n\n\ - ## Output\n\n{}", - task_id, - description, - status, - round_duration(duration), - turns_taken, - output - ))]); + } } Err(format!("Task '{}' not found.", task_id)) @@ -1628,7 +1622,8 @@ impl SummonClient { .insert(task_id.clone(), task); Ok(vec![Content::text(format!( - "Task {} started in background: \"{}\"\nUse load(source: \"{}\") to wait for the result (it will block until complete).", + "Task {} started in background: \"{}\"\n\ + Continue with other work. When you need the result, use load(source: \"{}\").", task_id, description, task_id ))]) } @@ -1714,8 +1709,6 @@ impl McpClientTrait for SummonClient { let mut lines = vec!["Background tasks:".to_string()]; let now = current_epoch_millis(); - let mut shortest_elapsed_secs: Option = None; - let mut sorted_running: Vec<_> = running.values().collect(); sorted_running.sort_by_key(|t| &t.id); @@ -1723,10 +1716,6 @@ impl McpClientTrait for SummonClient { let elapsed = task.started_at.elapsed(); let idle_ms = now.saturating_sub(task.last_activity.load(Ordering::Relaxed)); - let elapsed_secs = elapsed.as_secs(); - shortest_elapsed_secs = - Some(shortest_elapsed_secs.map_or(elapsed_secs, |s| s.min(elapsed_secs))); - lines.push(format!( "• {}: \"{}\" - running {}, {} turns, idle {}", task.id, @@ -1757,12 +1746,11 @@ impl McpClientTrait for SummonClient { )); } - if let Some(shortest) = shortest_elapsed_secs { - let sleep_secs = 300u64.saturating_sub(shortest).max(10); - lines.push(format!( - "\n→ sleep {} to wait, or load(source: \"id\", cancel: true) to stop", - sleep_secs - )); + if !running.is_empty() { + lines.push( + "\n→ Use load(source: \"\") to wait for a task, or load(source: \"\", cancel: true) to stop it" + .to_string(), + ); } Some(lines.join("\n")) diff --git a/crates/goose/src/dictation/mod.rs b/crates/goose/src/dictation/mod.rs index 9cef90aa8c..d14fb21642 100644 --- a/crates/goose/src/dictation/mod.rs +++ b/crates/goose/src/dictation/mod.rs @@ -1,3 +1,2 @@ -pub mod download_manager; pub mod providers; pub mod whisper; diff --git a/crates/goose/src/dictation/download_manager.rs b/crates/goose/src/download_manager.rs similarity index 82% rename from crates/goose/src/dictation/download_manager.rs rename to crates/goose/src/download_manager.rs index 8342ab5cc2..6fc4718cbc 100644 --- a/crates/goose/src/dictation/download_manager.rs +++ b/crates/goose/src/download_manager.rs @@ -1,12 +1,33 @@ -use crate::dictation::whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tokio::io::AsyncWriteExt; +use tracing::info; use utoipa::ToSchema; +fn partial_path_for(destination: &Path) -> PathBuf { + destination.with_extension( + destination + .extension() + .map(|e| format!("{}.part", e.to_string_lossy())) + .unwrap_or_else(|| "part".to_string()), + ) +} + +/// Remove any leftover `.part` files in the given directory. +pub fn cleanup_partial_downloads(dir: &Path) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "part") { + let _ = std::fs::remove_file(&path); + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DownloadProgress { /// Model ID being downloaded @@ -78,8 +99,9 @@ impl DownloadManager { model_id: String, url: String, destination: PathBuf, + on_complete: Option>, ) -> Result<()> { - // Initialize progress + info!(model_id = %model_id, url = %url, destination = ?destination, "Starting model download"); { let mut downloads = self .downloads @@ -115,10 +137,13 @@ impl DownloadManager { let downloads = self.downloads.clone(); let model_id_clone = model_id.clone(); + let destination_for_cleanup = destination.clone(); + // Download in background task tokio::spawn(async move { match Self::download_file(&url, &destination, &downloads, &model_id_clone).await { Ok(_) => { + info!(model_id = %model_id_clone, "Download completed successfully"); if let Ok(mut downloads) = downloads.lock() { if let Some(progress) = downloads.get_mut(&model_id_clone) { progress.status = DownloadStatus::Completed; @@ -126,10 +151,15 @@ impl DownloadManager { } } - let _ = crate::config::Config::global() - .set_param(LOCAL_WHISPER_MODEL_CONFIG_KEY, model_id_clone.clone()); + if let Some(callback) = on_complete { + callback(); + } } Err(e) => { + // Clean up partial file on failure + let partial = partial_path_for(&destination_for_cleanup); + let _ = tokio::fs::remove_file(&partial).await; + if let Ok(mut downloads) = downloads.lock() { if let Some(progress) = downloads.get_mut(&model_id_clone) { progress.status = DownloadStatus::Failed; @@ -166,7 +196,8 @@ impl DownloadManager { } } - let mut file = tokio::fs::File::create(destination).await?; + let partial_path = partial_path_for(destination); + let mut file = tokio::fs::File::create(&partial_path).await?; let mut bytes_downloaded = 0u64; let start_time = std::time::Instant::now(); @@ -185,8 +216,7 @@ impl DownloadManager { }; if should_cancel { - // Clean up partial download - let _ = tokio::fs::remove_file(destination).await; + let _ = tokio::fs::remove_file(&partial_path).await; return Ok(()); } @@ -226,6 +256,8 @@ impl DownloadManager { } file.flush().await?; + drop(file); + tokio::fs::rename(&partial_path, destination).await?; Ok(()) } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 579534ab69..322927d093 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod context_mgmt; pub mod conversation; pub mod dictation; +pub mod download_manager; pub mod execution; pub mod goose_apps; pub mod hints; diff --git a/crates/goose/src/prompt_template.rs b/crates/goose/src/prompt_template.rs index 1685799c65..d4ec7eb848 100644 --- a/crates/goose/src/prompt_template.rs +++ b/crates/goose/src/prompt_template.rs @@ -39,6 +39,14 @@ static TEMPLATE_REGISTRY: &[(&str, &str)] = &[ "plan.md", "Prompt used when goose creates step-by-step plans. CLI only", ), + ( + "tiny_model_system.md", + "System prompt for tiny local models using shell command emulation", + ), + ( + "session_name.md", + "System prompt for generating short session names from conversation history", + ), ]; /// Information about a template including its content and customization status diff --git a/crates/goose/src/prompts/session_name.md b/crates/goose/src/prompts/session_name.md new file mode 100644 index 0000000000..1a40184a4d --- /dev/null +++ b/crates/goose/src/prompts/session_name.md @@ -0,0 +1,6 @@ +Generate a short title (four words or less) that describes the topic of the user's messages. Reply with only the title, nothing else. + +Examples: +- "how do I reverse a list in python?" → Python list reversal +- "what's the weather in Tokyo?" → Tokyo weather +- "explain how transformers work in ML" → ML transformers explained \ No newline at end of file diff --git a/crates/goose/src/prompts/tiny_model_system.md b/crates/goose/src/prompts/tiny_model_system.md new file mode 100644 index 0000000000..08b1f116c0 --- /dev/null +++ b/crates/goose/src/prompts/tiny_model_system.md @@ -0,0 +1,22 @@ +You are goose, an autonomous AI agent created by Block. You act on the user's +behalf — you do not explain how to do things, you DO them directly. + +The OS is {{os}}, the shell is {{shell}}, and the working directory is {{working_directory}} + +When the user asks you to do something, take action immediately. Do not describe +what you would do or give instructions — execute the commands yourself. + +To run a shell command, start a new line with $: + +$ ls + +Keep your responses brief. State what you are doing, then do it. For example: + +User: how many files are in /tmp? +You: Let me check. +$ ls -1 /tmp | wc -l + +After a command runs, you will see its output. Use the output to answer the user +or take the next step. Do not repeat commands you have already run. + +Do not use shell commands if you already know the answer. \ No newline at end of file diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 99559dc11f..4a0c4c1bbc 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -17,10 +17,22 @@ use rmcp::model::Tool; use utoipa::ToSchema; use once_cell::sync::Lazy; +use regex::Regex; use std::ops::{Add, AddAssign}; use std::pin::Pin; +use std::sync::LazyLock; use std::sync::Mutex; +fn strip_xml_tags(text: &str) -> String { + static BLOCK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)<([a-zA-Z][a-zA-Z0-9_]*)[^>]*>.*?").unwrap() + }); + static TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"]*>").unwrap()); + let pass1 = BLOCK_RE.replace_all(text, ""); + TAG_RE.replace_all(&pass1, "").into_owned() +} + /// A global store for the current model being used, we use this as when a provider returns, it tells us the real model, not an alias pub static CURRENT_MODEL: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -594,20 +606,28 @@ pub trait Provider: Send + Sync { messages: &Conversation, ) -> Result { let context = self.get_initial_user_messages(messages); - let prompt = self.create_session_name_prompt(&context); - let message = Message::user().with_text(&prompt); + let system = crate::prompt_template::render_template( + "session_name.md", + &std::collections::HashMap::::new(), + ) + .map_err(|e| ProviderError::ContextLengthExceeded(e.to_string()))?; + + let user_text = format!( + "---BEGIN USER MESSAGES---\n{}\n---END USER MESSAGES---\n\nGenerate a short title for the above messages.", + context.join("\n") + ); + let message = Message::user().with_text(&user_text); let result = self - .complete_fast( - session_id, - "Reply with only a description in four words or less", - &[message], - &[], - ) + .complete_fast(session_id, &system, &[message], &[]) .await?; - let description = result + let raw: String = result .0 - .as_concat_text() + .content + .iter() + .filter_map(|c| c.as_text()) + .collect(); + let description = strip_xml_tags(&raw) .split_whitespace() .collect::>() .join(" "); @@ -615,21 +635,6 @@ pub trait Provider: Send + Sync { Ok(safe_truncate(&description, 100)) } - // Generate a prompt for a session name based on the conversation history - fn create_session_name_prompt(&self, context: &[String]) -> String { - // Create a prompt for a concise description - let mut prompt = "Based on the conversation so far, provide a concise description of this session in 4 words or less. This will be used for finding the session later in a UI with limited space - reply *ONLY* with the description".to_string(); - - if !context.is_empty() { - prompt = format!( - "Here are the first few user messages:\n{}\n\n{}", - context.join("\n"), - prompt - ); - } - prompt - } - /// Configure OAuth authentication for this provider /// /// This method is called when a provider has configuration keys marked with oauth_flow = true. @@ -719,6 +724,42 @@ mod tests { use test_case::test_case; use serde_json::json; + + #[test] + fn test_strip_xml_tags() { + assert_eq!(strip_xml_tags("reasoninganswer"), "answer"); + assert_eq!(strip_xml_tags("beforemidafter"), "beforeafter"); + assert_eq!(strip_xml_tags("xyz"), "z"); + assert_eq!(strip_xml_tags("no tags here"), "no tags here"); + assert_eq!(strip_xml_tags("a < b > c"), "a < b > c"); + assert_eq!(strip_xml_tags("überok"), "ok"); + assert_eq!(strip_xml_tags("日本語hello"), "hello"); + assert_eq!(strip_xml_tags(""), ""); + assert_eq!(strip_xml_tags("<>stuff"), "<>stuff"); + // attributes + assert_eq!( + strip_xml_tags(r#"reasoninganswer"#), + "answer" + ); + // self-closing tags + assert_eq!(strip_xml_tags("
self closing"), "self closing"); + // orphan closing tags + assert_eq!(strip_xml_tags("orphan tag"), "orphan tag"); + // multiline content + assert_eq!( + strip_xml_tags("\nline1\nline2\nresult"), + "result" + ); + } + + #[test] + fn test_usage_creation() { + let usage = Usage::new(Some(10), Some(20), Some(30)); + assert_eq!(usage.input_tokens, Some(10)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(30)); + } + fn content_from_str(s: String) -> MessageContent { if let Some(img_data) = s.strip_prefix("*img:") { MessageContent::image(format!("http://example.com/{}", img_data), "image/png") @@ -798,11 +839,10 @@ mod tests { #[tokio::test] async fn test_collect_stream_defaults_usage() { - // Should not error when usage is missing let stream = create_test_stream(vec!["Hello".to_string()]); let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap(); assert_eq!(content_to_strings(&msg), vec!["Hello"]); - assert_eq!(usage.model, "unknown"); // Default usage + assert_eq!(usage.model, "unknown"); } #[test] diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index c1f69e61d6..8300f5a6df 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -16,6 +16,7 @@ use super::{ google::GoogleProvider, lead_worker::LeadWorkerProvider, litellm::LiteLLMProvider, + local_inference::LocalInferenceProvider, ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, @@ -47,6 +48,7 @@ async fn init_registry() -> RwLock { registry.register::(true); registry.register::(false); registry.register::(false); + registry.register::(false); registry.register::(true); registry.register::(true); registry.register::(true); diff --git a/crates/goose/src/providers/local_inference.rs b/crates/goose/src/providers/local_inference.rs new file mode 100644 index 0000000000..32503e9b3b --- /dev/null +++ b/crates/goose/src/providers/local_inference.rs @@ -0,0 +1,647 @@ +pub mod hf_models; +mod inference_emulated_tools; +mod inference_engine; +mod inference_native_tools; +pub mod local_model_registry; +mod tool_parsing; + +use inference_emulated_tools::{ + build_emulator_tool_description, generate_with_emulated_tools, load_tiny_model_prompt, +}; +use inference_engine::GenerationContext; +use inference_engine::LoadedModel; +use inference_native_tools::generate_with_native_tools; +use tool_parsing::compact_tools_json; + +use crate::config::ExtensionConfig; +use crate::conversation::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::{ + MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage, +}; +use crate::providers::errors::ProviderError; +use crate::providers::formats::openai::format_tools; +use crate::providers::utils::RequestLog; +use anyhow::Result; +use async_stream::try_stream; +use async_trait::async_trait; +use futures::future::BoxFuture; +use llama_cpp_2::llama_backend::LlamaBackend; +use llama_cpp_2::model::params::LlamaModelParams; +use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel}; +use llama_cpp_2::{list_llama_ggml_backend_devices, LlamaBackendDeviceType}; +use rmcp::model::{Role, Tool}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex as StdMutex, Weak}; +use tokio::sync::Mutex; +use uuid::Uuid; + +const SHELL_TOOL: &str = "developer__shell"; +const CODE_EXECUTION_TOOL: &str = "code_execution__execute"; + +type ModelSlot = Arc>>; + +/// Owns the llama backend and all cached models. Field order matters: +/// `models` is declared before `backend` so Rust drops all loaded models +/// (and their Metal/GPU resources) before the backend calls +/// `llama_backend_free()`, avoiding the ggml-metal assertion on shutdown. +pub struct InferenceRuntime { + models: StdMutex>, + backend: LlamaBackend, +} + +/// Global weak reference used to share a single `InferenceRuntime` across +/// all providers and server routes. Only a `Weak` is stored — strong `Arc`s +/// live in providers and `AppState`. When all strong refs drop (normal +/// shutdown), the runtime is deallocated and the backend freed. The `Weak` +/// left behind is inert during `__cxa_finalize`, so no ggml statics race. +static RUNTIME: StdMutex> = StdMutex::new(Weak::new()); + +impl InferenceRuntime { + pub fn get_or_init() -> Arc { + let mut guard = RUNTIME.lock().expect("runtime lock poisoned"); + if let Some(runtime) = guard.upgrade() { + return runtime; + } + // Safety invariant: the Weak::upgrade() check and LlamaBackend::init() + // both execute inside this same mutex guard, so there is no window where + // another thread could drop the Arc and re-enter concurrently. + // BackendAlreadyInitialized therefore means LlamaBackend::drop() did not + // reset the C library's init flag — a llama-cpp-rs bug, not a race. + let backend = match LlamaBackend::init() { + Ok(b) => b, + Err(llama_cpp_2::LlamaCppError::BackendAlreadyInitialized) => { + unreachable!( + "LlamaBackend already initialized but Weak was dead; \ + the mutex guard prevents concurrent re-init" + ) + } + Err(e) => panic!("Failed to init llama backend: {}", e), + }; + let runtime = Arc::new(Self { + models: StdMutex::new(HashMap::new()), + backend, + }); + *guard = Arc::downgrade(&runtime); + runtime + } + + pub fn backend(&self) -> &LlamaBackend { + &self.backend + } + + fn get_or_create_model_slot(&self, model_id: &str) -> ModelSlot { + let mut map = self.models.lock().expect("model cache lock poisoned"); + map.entry(model_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone() + } + + fn other_model_slots(&self, keep_model_id: &str) -> Vec { + let map = self.models.lock().expect("model cache lock poisoned"); + map.iter() + .filter(|(id, _)| id.as_str() != keep_model_id) + .map(|(_, slot)| slot.clone()) + .collect() + } +} + +const PROVIDER_NAME: &str = "local"; +const DEFAULT_MODEL: &str = "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M"; + +pub const LOCAL_LLM_MODEL_CONFIG_KEY: &str = "LOCAL_LLM_MODEL"; + +/// Resolve model path, context limit, and settings for a model ID from the registry. +pub fn resolve_model_path( + model_id: &str, +) -> Option<( + PathBuf, + usize, + crate::providers::local_inference::local_model_registry::ModelSettings, +)> { + use crate::providers::local_inference::local_model_registry::get_registry; + + if let Ok(registry) = get_registry().lock() { + if let Some(entry) = registry.get_model(model_id) { + let ctx = entry.settings.context_size.unwrap_or(0) as usize; + return Some((entry.local_path.clone(), ctx, entry.settings.clone())); + } + } + + None +} + +pub fn available_inference_memory_bytes(runtime: &InferenceRuntime) -> u64 { + let _ = &runtime.backend; + let devices = list_llama_ggml_backend_devices(); + + let accel_memory = devices + .iter() + .filter(|d| { + matches!( + d.device_type, + LlamaBackendDeviceType::Gpu + | LlamaBackendDeviceType::IntegratedGpu + | LlamaBackendDeviceType::Accelerator + ) + }) + .map(|d| d.memory_free as u64) + .max() + .unwrap_or(0); + + if accel_memory > 0 { + accel_memory + } else { + devices + .iter() + .filter(|d| d.device_type == LlamaBackendDeviceType::Cpu) + .map(|d| d.memory_free as u64) + .max() + .unwrap_or(0) + } +} + +pub fn recommend_local_model(runtime: &InferenceRuntime) -> String { + use local_model_registry::{get_registry, is_featured_model, FEATURED_MODELS}; + + let available_memory = available_inference_memory_bytes(runtime); + + if let Ok(registry) = get_registry().lock() { + let mut models: Vec<_> = registry + .list_models() + .iter() + .filter(|m| is_featured_model(&m.id) && m.size_bytes > 0) + .collect(); + models.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)); + + // Return largest that fits in available memory + for model in &models { + if available_memory >= model.size_bytes { + return model.id.clone(); + } + } + + // If nothing fits, return smallest + if let Some(smallest) = models.last() { + return smallest.id.clone(); + } + } + + // Fallback to first featured model + FEATURED_MODELS[0].to_string() +} + +fn build_openai_messages_json(system: &str, messages: &[Message]) -> String { + use crate::providers::formats::openai::format_messages; + use crate::providers::utils::ImageFormat; + + let mut arr: Vec = vec![json!({"role": "system", "content": system})]; + arr.extend(format_messages(messages, &ImageFormat::OpenAi)); + serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string()) +} + +/// Convert a message into plain text for the emulator path's chat history. +/// +/// This is the emulator-path counterpart of [`format_messages`] used by the native +/// path. It reconstructs the text-based tool syntax that the emulator prompt teaches +/// the model: +/// +/// - `ToolRequest` with a `"command"` argument → `$ command` +/// - `ToolRequest` with a `"code"` argument → `` ```execute\n…\n``` `` +/// - `ToolResponse` → `Command output:\n…` +/// +/// Only `developer__shell` and `code_execution__execute` style tool calls are +/// recognized (by argument shape, not tool name). Tool calls from other extensions +/// (e.g. custom MCP tools made by a native-tool-calling model earlier in the +/// conversation) are silently dropped, since the emulator path has no syntax to +/// represent them. +fn extract_text_content(msg: &Message) -> String { + let mut parts = Vec::new(); + + for content in &msg.content { + match content { + MessageContent::Text(text) => { + parts.push(text.text.clone()); + } + MessageContent::ToolRequest(req) => { + if let Ok(call) = &req.tool_call { + if let Some(cmd) = call + .arguments + .as_ref() + .and_then(|a| a.get("command")) + .and_then(|v| v.as_str()) + { + parts.push(format!("$ {}", cmd)); + } else if let Some(code) = call + .arguments + .as_ref() + .and_then(|a| a.get("code")) + .and_then(|v| v.as_str()) + { + parts.push(format!("```execute\n{}\n```", code)); + } + } + } + MessageContent::ToolResponse(response) => match &response.tool_result { + Ok(result) => { + let mut output_parts = Vec::new(); + for content_item in &result.content { + if let Some(text_content) = content_item.as_text() { + output_parts.push(text_content.text.to_string()); + } + } + if !output_parts.is_empty() { + parts.push(format!("Command output:\n{}", output_parts.join("\n"))); + } + } + Err(e) => { + parts.push(format!("Command error: {}", e)); + } + }, + _ => {} + } + } + + parts.join("\n") +} + +/// Build a `ProviderUsage` and write the request log entry. +fn finalize_usage( + log: &mut RequestLog, + model_name: String, + path_label: &str, + prompt_token_count: usize, + output_token_count: i32, + extra_log_fields: Option<(&str, &str)>, +) -> ProviderUsage { + let input_tokens = prompt_token_count as i32; + let total_tokens = input_tokens + output_token_count; + let usage = Usage::new( + Some(input_tokens), + Some(output_token_count), + Some(total_tokens), + ); + let mut log_json = serde_json::json!({ + "path": path_label, + "prompt_tokens": input_tokens, + "output_tokens": output_token_count, + }); + if let Some((key, value)) = extra_log_fields { + log_json[key] = serde_json::json!(value); + } + let _ = log.write(&log_json, Some(&usage)); + ProviderUsage::new(model_name, usage) +} + +type StreamSender = + tokio::sync::mpsc::Sender, Option), ProviderError>>; + +pub struct LocalInferenceProvider { + runtime: Arc, + model: ModelSlot, + model_config: ModelConfig, + name: String, +} + +impl LocalInferenceProvider { + pub async fn from_env(model: ModelConfig, _extensions: Vec) -> Result { + let runtime = InferenceRuntime::get_or_init(); + let model_slot = runtime.get_or_create_model_slot(&model.model_name); + Ok(Self { + runtime, + model: model_slot, + model_config: model, + name: PROVIDER_NAME.to_string(), + }) + } + + fn load_model_sync( + runtime: &InferenceRuntime, + model_id: &str, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + ) -> Result { + let (model_path, _context_limit, _) = resolve_model_path(model_id) + .ok_or_else(|| ProviderError::ExecutionError(format!("Unknown model: {}", model_id)))?; + + if !model_path.exists() { + return Err(ProviderError::ExecutionError(format!( + "Model not downloaded: {}. Please download it from Settings > Local Inference.", + model_id + ))); + } + + tracing::info!("Loading {} from: {}", model_id, model_path.display()); + + let backend = runtime.backend(); + + let mut params = LlamaModelParams::default(); + if let Some(n_gpu_layers) = settings.n_gpu_layers { + params = params.with_n_gpu_layers(n_gpu_layers); + } + if settings.use_mlock { + params = params.with_use_mlock(true); + } + let model = LlamaModel::load_from_file(backend, &model_path, ¶ms) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + + let template = match model.chat_template(None) { + Ok(t) => t, + Err(_) => { + tracing::warn!("Model has no embedded chat template, falling back to chatml"); + LlamaChatTemplate::new("chatml").map_err(|e| { + ProviderError::ExecutionError(format!( + "Failed to create fallback chat template: {}", + e + )) + })? + } + }; + + tracing::info!("Model loaded successfully"); + + Ok(LoadedModel { model, template }) + } +} + +impl ProviderDef for LocalInferenceProvider { + type Provider = Self; + + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + use crate::providers::local_inference::local_model_registry::{ + get_registry, FEATURED_MODELS, + }; + + let mut known_models: Vec<&str> = FEATURED_MODELS.to_vec(); + + // Add any registry models not already in the featured list + let mut dynamic_models = Vec::new(); + if let Ok(registry) = get_registry().lock() { + for entry in registry.list_models() { + if !known_models.contains(&entry.id.as_str()) { + dynamic_models.push(entry.id.clone()); + } + } + } + let dynamic_refs: Vec<&str> = dynamic_models.iter().map(|s| s.as_str()).collect(); + known_models.extend(dynamic_refs); + + ProviderMetadata::new( + PROVIDER_NAME, + "Local Inference", + "Local inference using quantized GGUF models (llama.cpp)", + DEFAULT_MODEL, + known_models, + "https://github.com/utilityai/llama-cpp-rs", + vec![], + ) + } + + fn from_env( + model: ModelConfig, + extensions: Vec, + ) -> BoxFuture<'static, Result> + where + Self: Sized, + { + Box::pin(Self::from_env(model, extensions)) + } +} + +#[async_trait] +impl Provider for LocalInferenceProvider { + fn get_name(&self) -> &str { + &self.name + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + use crate::providers::local_inference::local_model_registry::get_registry; + + let mut all_models: Vec = Vec::new(); + + if let Ok(registry) = get_registry().lock() { + for entry in registry.list_models() { + all_models.push(entry.id.clone()); + } + } + + Ok(all_models) + } + + async fn stream( + &self, + model_config: &ModelConfig, + _session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let (_model_path, model_context_limit, model_settings) = + resolve_model_path(&model_config.model_name).ok_or_else(|| { + ProviderError::ExecutionError(format!( + "Model not found: {}", + model_config.model_name + )) + })?; + + // Ensure model is loaded — unload any other models first to free memory. + { + let mut model_lock = self.model.lock().await; + if model_lock.is_none() { + for slot in self.runtime.other_model_slots(&model_config.model_name) { + let mut other = slot.lock().await; + if other.is_some() { + tracing::info!("Unloading previous model to free memory"); + *other = None; + } + } + + let model_id = model_config.model_name.clone(); + let settings_for_load = model_settings.clone(); + let runtime_for_load = self.runtime.clone(); + let loaded = tokio::task::spawn_blocking(move || { + Self::load_model_sync(&runtime_for_load, &model_id, &settings_for_load) + }) + .await + .map_err(|e| ProviderError::ExecutionError(e.to_string()))??; + *model_lock = Some(loaded); + } + } + + // Models that support native OpenAI-compatible tool-call JSON use the + // native path (template-based tool calling with JSON output). All other + // models use the emulator which parses `$ command` and ```execute blocks. + // Only use emulator when there are actually tools to emulate - utility calls + // like compaction and session naming pass empty tools and should preserve + // their system prompts. + let use_emulator = !model_settings.native_tool_calling && !tools.is_empty(); + let system_prompt = if use_emulator { + load_tiny_model_prompt() + } else { + system.to_string() + }; + + // Build chat messages for the template + let mut chat_messages = + vec![ + LlamaChatMessage::new("system".to_string(), system_prompt.clone()).map_err( + |e| { + ProviderError::ExecutionError(format!( + "Failed to create system message: {}", + e + )) + }, + )?, + ]; + + let code_mode_enabled = tools.iter().any(|t| t.name == CODE_EXECUTION_TOOL); + + if use_emulator && !tools.is_empty() { + let tool_desc = build_emulator_tool_description(tools, code_mode_enabled); + chat_messages = vec![LlamaChatMessage::new( + "system".to_string(), + format!("{}{}", system_prompt, tool_desc), + ) + .map_err(|e| { + ProviderError::ExecutionError(format!("Failed to create system message: {}", e)) + })?]; + } + + for msg in messages { + let role = match msg.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + let content = extract_text_content(msg); + if !content.trim().is_empty() { + chat_messages.push(LlamaChatMessage::new(role.to_string(), content).map_err( + |e| ProviderError::ExecutionError(format!("Failed to create message: {}", e)), + )?); + } + } + + let (full_tools_json, compact_tools) = if !use_emulator && !tools.is_empty() { + let full = format_tools(tools) + .ok() + .and_then(|spec| serde_json::to_string(&spec).ok()); + let compact = compact_tools_json(tools); + (full, compact) + } else { + (None, None) + }; + + let oai_messages_json = if model_settings.use_jinja { + Some(build_openai_messages_json(&system_prompt, messages)) + } else { + None + }; + + let model_arc = self.model.clone(); + let runtime = self.runtime.clone(); + let model_name = model_config.model_name.clone(); + let context_limit = model_context_limit; + let settings = model_settings; + + let log_payload = serde_json::json!({ + "system": &system_prompt, + "messages": messages.iter().map(|m| { + serde_json::json!({ + "role": match m.role { Role::User => "user", Role::Assistant => "assistant" }, + "content": extract_text_content(m), + }) + }).collect::>(), + "tools": tools.iter().map(|t| &t.name).collect::>(), + "settings": { + "use_jinja": settings.use_jinja, + "native_tool_calling": settings.native_tool_calling, + "context_size": settings.context_size, + "sampling": settings.sampling, + }, + }); + + let mut log = RequestLog::start(&self.model_config, &log_payload) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + + let (tx, mut rx) = tokio::sync::mpsc::channel::< + Result<(Option, Option), ProviderError>, + >(32); + + tokio::task::spawn_blocking(move || { + // Macro to log errors before sending them through the channel + macro_rules! send_err { + ($err:expr) => {{ + let err = $err; + let msg = match &err { + ProviderError::ExecutionError(s) => s.as_str(), + ProviderError::ContextLengthExceeded(s) => s.as_str(), + _ => "unknown error", + }; + let _ = log.error(msg); + let _ = tx.blocking_send(Err(err)); + return; + }}; + } + + let model_guard = model_arc.blocking_lock(); + let loaded = match model_guard.as_ref() { + Some(l) => l, + None => { + send_err!(ProviderError::ExecutionError( + "Model not loaded".to_string() + )); + } + }; + + let message_id = Uuid::new_v4().to_string(); + + let mut gen_ctx = GenerationContext { + loaded, + runtime: &runtime, + chat_messages: &chat_messages, + settings: &settings, + context_limit, + model_name, + message_id: &message_id, + tx: &tx, + log: &mut log, + }; + + let result = if use_emulator { + generate_with_emulated_tools(&mut gen_ctx, code_mode_enabled) + } else { + generate_with_native_tools( + &mut gen_ctx, + &oai_messages_json, + full_tools_json.as_deref(), + compact_tools.as_deref(), + ) + }; + + if let Err(err) = result { + let msg = match &err { + ProviderError::ExecutionError(s) => s.as_str(), + ProviderError::ContextLengthExceeded(s) => s.as_str(), + _ => "unknown error", + }; + let _ = log.error(msg); + let _ = tx.blocking_send(Err(err)); + } + }); + + Ok(Box::pin(try_stream! { + while let Some(result) = rx.recv().await { + let item = result?; + yield item; + } + + })) + } +} diff --git a/crates/goose/src/providers/local_inference/hf_models.rs b/crates/goose/src/providers/local_inference/hf_models.rs new file mode 100644 index 0000000000..9729fcd832 --- /dev/null +++ b/crates/goose/src/providers/local_inference/hf_models.rs @@ -0,0 +1,486 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use utoipa::ToSchema; + +const HF_API_BASE: &str = "https://huggingface.co/api/models"; +const HF_DOWNLOAD_BASE: &str = "https://huggingface.co"; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfModelInfo { + pub repo_id: String, + pub author: String, + pub model_name: String, + pub downloads: u64, + pub gguf_files: Vec, +} + +/// A single downloadable GGUF file (used internally and for downloads). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfGgufFile { + pub filename: String, + pub size_bytes: u64, + pub quantization: String, + pub download_url: String, +} + +/// A quantization variant — groups sharded files into one logical entry. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HfQuantVariant { + pub quantization: String, + pub size_bytes: u64, + pub filename: String, + pub download_url: String, + pub description: &'static str, + pub quality_rank: u8, +} + +#[derive(Debug, Deserialize)] +struct HfApiModel { + id: Option, + author: Option, + downloads: Option, + siblings: Option>, +} + +#[derive(Debug, Deserialize)] +struct HfApiSibling { + rfilename: String, + #[serde(default)] + size: Option, +} + +struct QuantInfo { + description: &'static str, + quality_rank: u8, +} + +const QUANT_TABLE: &[(&str, &str, u8)] = &[ + ("IQ1_S", "Extremely small, very low quality", 1), + ("IQ1_M", "Extremely small, very low quality", 2), + ("IQ2_XXS", "Very small, low quality", 3), + ("IQ2_XS", "Very small, low quality", 4), + ("IQ2_S", "Very small, low quality", 5), + ("IQ2_M", "Very small, low quality", 6), + ("Q2_K", "Small, low quality", 7), + ("Q2_K_S", "Small, low quality", 7), + ("IQ3_XXS", "Very small, moderate quality loss", 8), + ("IQ3_XS", "Small, moderate quality loss", 9), + ("IQ3_S", "Small, moderate quality loss", 9), + ("Q3_K_S", "Small, moderate quality loss", 10), + ("IQ3_M", "Small, moderate quality loss", 11), + ("Q3_K_M", "Small, balanced quality/size", 12), + ("Q3_K_L", "Medium-small, decent quality", 13), + ("IQ4_XS", "Medium, good quality", 14), + ("IQ4_NL", "Medium, good quality", 15), + ("Q4_0", "Medium, good quality", 16), + ("Q4_1", "Medium, good quality", 17), + ("Q4_K_S", "Medium, good quality/size balance", 18), + ( + "Q4_K_M", + "Medium, recommended balance of quality and size", + 19, + ), + ("Q5_0", "Medium-large, high quality", 20), + ("Q5_1", "Medium-large, high quality", 21), + ("Q5_K_S", "Medium-large, high quality", 22), + ("Q5_K_M", "Medium-large, very high quality", 23), + ("Q6_K", "Large, near-lossless quality", 24), + ("Q8_0", "Large, near-lossless quality", 25), + ("F16", "Full size, original quality (16-bit)", 26), + ("BF16", "Full size, original quality (bfloat16)", 27), + ("F32", "Full size, original quality (32-bit)", 28), + ( + "MXFP4_MOE", + "Medium, mixed-precision 4-bit for MoE models", + 18, + ), + ("TQ1_0", "Tiny, ternary quantization", 1), + ("Q2_K_XL", "Extended-layer variant", 15), + ("Q3_K_XL", "Extended-layer variant", 15), + ("Q4_K_XL", "Extended-layer variant", 15), + ("Q2_K_L", "Small, low quality (large variant)", 8), + ("Q4_K_L", "Medium, good quality (large variant)", 20), +]; + +fn quant_info(quant: &str) -> QuantInfo { + QUANT_TABLE + .iter() + .find(|(name, _, _)| *name == quant) + .map(|(_, description, quality_rank)| QuantInfo { + description, + quality_rank: *quality_rank, + }) + .unwrap_or(QuantInfo { + description: "", + quality_rank: 15, + }) +} + +pub fn parse_quantization_from_filename(filename: &str) -> String { + parse_quantization(filename) +} + +fn parse_quantization(filename: &str) -> String { + // Strip directory prefix (e.g. "Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf") + let basename = filename.rsplit('/').next().unwrap_or(filename); + let stem = basename.trim_end_matches(".gguf"); + + // Strip shard suffix like "-00001-of-00004" + let stem = if let Some(pos) = stem.rfind("-of-") { + stem.get(..pos) + .and_then(|s| s.rsplit_once('-').map(|(prefix, _)| prefix)) + .unwrap_or(stem) + } else { + stem + }; + + // The quantization tag is typically the last hyphen-separated component + // that looks like a quant identifier (starts with Q, IQ, F, BF, TQ, MXFP, etc.) + // e.g. "Qwen3-Coder-Next-Q4_K_M" -> "Q4_K_M" + // "Model-UD-IQ1_M" -> "IQ1_M" + if let Some((_, candidate)) = stem.rsplit_once('-') { + if looks_like_quant(candidate) { + return candidate.to_string(); + } + } + + // Fallback: try dot separator (e.g. "model.Q4_K_M") + if let Some((_, candidate)) = stem.rsplit_once('.') { + if looks_like_quant(candidate) { + return candidate.to_string(); + } + } + + "unknown".to_string() +} + +fn looks_like_quant(s: &str) -> bool { + let upper = s.to_uppercase(); + upper.starts_with("Q") + || upper.starts_with("IQ") + || upper.starts_with("TQ") + || upper.starts_with("MXFP") + || upper == "F16" + || upper == "F32" + || upper == "BF16" +} + +fn is_shard_file(filename: &str) -> bool { + // Matches patterns like "-00001-of-00003.gguf" + let basename = filename.rsplit('/').next().unwrap_or(filename); + let stem = basename.trim_end_matches(".gguf"); + if let Some(pos) = stem.rfind("-of-") { + stem.get(..pos) + .and_then(|before| before.rsplit('-').next()) + .map(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(false) + } else { + false + } +} + +fn build_download_url(repo_id: &str, filename: &str) -> String { + format!("{}/{}/resolve/main/{}", HF_DOWNLOAD_BASE, repo_id, filename) +} + +/// Collect single-file GGUFs into quantization variants (sharded files are excluded). +fn group_into_variants(repo_id: &str, files: Vec) -> Vec { + let mut variants: Vec = files + .into_iter() + .filter(|s| { + s.rfilename.ends_with(".gguf") + && !is_shard_file(&s.rfilename) + && parse_quantization(&s.rfilename) != "unknown" + }) + .map(|s| { + let quant = parse_quantization(&s.rfilename); + let info = quant_info(&quant); + let download_url = build_download_url(repo_id, &s.rfilename); + HfQuantVariant { + quantization: quant, + size_bytes: s.size.unwrap_or(0), + filename: s.rfilename, + download_url, + description: info.description, + quality_rank: info.quality_rank, + } + }) + .collect(); + + variants.sort_by_key(|v| v.quality_rank); + variants +} + +pub async fn search_gguf_models(query: &str, limit: usize) -> Result> { + let client = reqwest::Client::new(); + let url = format!( + "{}?search={}&filter=gguf&sort=downloads&direction=-1&limit={}", + HF_API_BASE, query, limit + ); + + let response = client + .get(&url) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!("HuggingFace API returned status {}", response.status()); + } + + let models: Vec = response.json().await?; + + let results = models + .into_iter() + .filter_map(|m| { + let repo_id = m.id?; + let siblings = m.siblings.unwrap_or_default(); + + // The search endpoint may not include `siblings`; parse whatever + // is available. Files are fetched on-demand via `get_repo_gguf_variants`. + let gguf_files: Vec = siblings + .into_iter() + .filter(|s| s.rfilename.ends_with(".gguf")) + .map(|s| { + let quantization = parse_quantization(&s.rfilename); + let download_url = build_download_url(&repo_id, &s.rfilename); + HfGgufFile { + filename: s.rfilename, + size_bytes: s.size.unwrap_or(0), + quantization, + download_url, + } + }) + .collect(); + + let author = m + .author + .unwrap_or_else(|| repo_id.split('/').next().unwrap_or_default().to_string()); + let model_name = repo_id + .split('/') + .next_back() + .unwrap_or(&repo_id) + .to_string(); + + Some(HfModelInfo { + repo_id, + author, + model_name, + downloads: m.downloads.unwrap_or(0), + gguf_files, + }) + }) + .collect(); + + Ok(results) +} + +/// Fetch GGUF files for a repo and return them grouped by quantization. +pub async fn get_repo_gguf_variants(repo_id: &str) -> Result> { + let client = reqwest::Client::new(); + let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); + + let response = client + .get(&url) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + let siblings = model.siblings.unwrap_or_default(); + + Ok(group_into_variants(repo_id, siblings)) +} + +/// Fetch raw GGUF files (kept for resolve_model_spec). +pub async fn get_repo_gguf_files(repo_id: &str) -> Result> { + let client = reqwest::Client::new(); + let url = format!("{}/{}?blobs=true", HF_API_BASE, repo_id); + + let response = client + .get(&url) + .header("User-Agent", "goose-ai-agent") + .send() + .await?; + + if !response.status().is_success() { + bail!( + "HuggingFace API returned status {} for repo {}", + response.status(), + repo_id + ); + } + + let model: HfApiModel = response.json().await?; + let siblings = model.siblings.unwrap_or_default(); + + let files = siblings + .into_iter() + .filter(|s| s.rfilename.ends_with(".gguf")) + .filter(|s| !is_shard_file(&s.rfilename)) + .map(|s| { + let quantization = parse_quantization(&s.rfilename); + let download_url = build_download_url(repo_id, &s.rfilename); + HfGgufFile { + filename: s.rfilename, + size_bytes: s.size.unwrap_or(0), + quantization, + download_url, + } + }) + .collect(); + + Ok(files) +} + +/// Parse a model spec like "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M" into (repo_id, quantization). +pub fn parse_model_spec(spec: &str) -> Result<(String, String)> { + let (repo_id, quant) = spec.rsplit_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid model spec '{}': expected format 'user/repo:quantization'", + spec + ) + })?; + + if !repo_id.contains('/') { + bail!("Invalid repo_id '{}': expected format 'user/repo'", repo_id); + } + + Ok((repo_id.to_string(), quant.to_string())) +} + +/// Resolve a model spec to a specific GGUF file from the repo. +pub async fn resolve_model_spec(spec: &str) -> Result<(String, HfGgufFile)> { + let (repo_id, quant) = parse_model_spec(spec)?; + let files = get_repo_gguf_files(&repo_id).await?; + + let file = files + .into_iter() + .find(|f| f.quantization.eq_ignore_ascii_case(&quant)) + .ok_or_else(|| { + anyhow::anyhow!( + "No GGUF file with quantization '{}' found in {}", + quant, + repo_id + ) + })?; + + Ok((repo_id, file)) +} + +/// Recommend which quantization variant to use based on available memory. +pub fn recommend_variant( + variants: &[HfQuantVariant], + available_memory_bytes: u64, +) -> Option { + // We need ~10-20% overhead beyond model size for inference context. + // Pick the highest-quality variant that fits. + let usable = (available_memory_bytes as f64 * 0.85) as u64; + + let mut best: Option = None; + for (i, v) in variants.iter().enumerate() { + if v.size_bytes <= usable { + match best { + Some(bi) if variants[bi].quality_rank < v.quality_rank => best = Some(i), + None => best = Some(i), + _ => {} + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_quantization() { + assert_eq!(parse_quantization("Model-Q4_K_M.gguf"), "Q4_K_M"); + assert_eq!(parse_quantization("Model-Q8_0.gguf"), "Q8_0"); + assert_eq!(parse_quantization("Model-IQ4_NL.gguf"), "IQ4_NL"); + assert_eq!(parse_quantization("Model-F16.gguf"), "F16"); + assert_eq!(parse_quantization("random-name.gguf"), "unknown"); + } + + #[test] + fn test_parse_quantization_with_directory() { + assert_eq!( + parse_quantization("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf"), + "Q5_K_M" + ); + } + + #[test] + fn test_parse_quantization_extended_tags() { + assert_eq!(parse_quantization("Model-MXFP4_MOE.gguf"), "MXFP4_MOE"); + assert_eq!(parse_quantization("Model-UD-TQ1_0.gguf"), "TQ1_0"); + assert_eq!(parse_quantization("Model-Q2_K_L.gguf"), "Q2_K_L"); + assert_eq!(parse_quantization("Model-UD-Q4_K_XL.gguf"), "Q4_K_XL"); + assert_eq!(parse_quantization("Model-UD-IQ1_M.gguf"), "IQ1_M"); + } + + #[test] + fn test_is_shard_file() { + assert!(is_shard_file("Q5_K_M/Model-Q5_K_M-00001-of-00002.gguf")); + assert!(is_shard_file("Model-BF16-00003-of-00004.gguf")); + assert!(!is_shard_file("Model-Q4_K_M.gguf")); + } + + #[test] + fn test_parse_model_spec() { + let (repo, quant) = + parse_model_spec("bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M").unwrap(); + assert_eq!(repo, "bartowski/Llama-3.2-1B-Instruct-GGUF"); + assert_eq!(quant, "Q4_K_M"); + } + + #[test] + fn test_parse_model_spec_invalid() { + assert!(parse_model_spec("no-colon").is_err()); + assert!(parse_model_spec("noslash:Q4_K_M").is_err()); + } + + #[test] + fn test_recommend_variant() { + let variants = vec![ + HfQuantVariant { + quantization: "Q2_K".into(), + size_bytes: 2_000_000_000, + filename: "m-Q2_K.gguf".into(), + download_url: String::new(), + description: "Small", + quality_rank: 7, + }, + HfQuantVariant { + quantization: "Q4_K_M".into(), + size_bytes: 4_000_000_000, + filename: "m-Q4_K_M.gguf".into(), + download_url: String::new(), + description: "Medium", + quality_rank: 19, + }, + HfQuantVariant { + quantization: "Q8_0".into(), + size_bytes: 8_000_000_000, + filename: "m-Q8_0.gguf".into(), + download_url: String::new(), + description: "Large", + quality_rank: 25, + }, + ]; + + assert_eq!(recommend_variant(&variants, 5_000_000_000), Some(1)); + assert_eq!(recommend_variant(&variants, 10_000_000_000), Some(2)); + assert_eq!(recommend_variant(&variants, 1_000_000_000), None); + } +} diff --git a/crates/goose/src/providers/local_inference/inference_emulated_tools.rs b/crates/goose/src/providers/local_inference/inference_emulated_tools.rs new file mode 100644 index 0000000000..4f9fb0f870 --- /dev/null +++ b/crates/goose/src/providers/local_inference/inference_emulated_tools.rs @@ -0,0 +1,685 @@ +//! Tool call emulation for models without native tool-calling support. +//! +//! The model is prompted to emit shell commands as `$ command` on a new line and +//! code blocks as `` ```execute `` fenced blocks. A streaming parser detects these +//! patterns and converts them into tool-call messages. +//! +//! # Known false-positive scenarios +//! +//! Because detection is purely text-based, the parser can misinterpret model output: +//! +//! - **`$` at line start in explanatory text.** If the model writes a line starting +//! with `$` as an example (e.g. "$ is the jQuery selector"), it will be treated as +//! a shell command. Mid-sentence `$` (e.g. "costs $50") is safe — only `\n$` or +//! `$` at the very start of output triggers command detection. +//! +//! - **`` ```execute `` in explanatory code fences.** If the model uses this exact +//! fence tag in prose, the content will be executed. Standard `` ```js `` or +//! `` ```python `` fences are not affected. +//! +//! These are inherent to text-based tool emulation. Models with native tool-calling +//! support should use the `inference_native_tools` path instead. + +use crate::conversation::message::{Message, MessageContent}; +use crate::providers::errors::ProviderError; +use llama_cpp_2::model::AddBos; +use rmcp::model::{CallToolRequestParams, Tool}; +use serde_json::json; +use std::borrow::Cow; +use uuid::Uuid; + +use super::inference_engine::{ + create_and_prefill_context, generation_loop, validate_and_compute_context, GenerationContext, + TokenAction, +}; +use super::{finalize_usage, StreamSender, CODE_EXECUTION_TOOL, SHELL_TOOL}; + +const HOLD_BACK_CODE_MODE: usize = " ```execute\n".len(); +const HOLD_BACK_SHELL_ONLY: usize = "\n$".len(); + +pub(super) fn load_tiny_model_prompt() -> String { + use std::env; + + let os = if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + }; + + let working_directory = env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + + let context = json!({ + "os": os, + "working_directory": working_directory, + "shell": shell, + }); + + crate::prompt_template::render_template("tiny_model_system.md", &context).unwrap_or_else(|e| { + tracing::warn!("Failed to load tiny_model_system.md: {:?}", e); + "You are Goose, an AI assistant. You can execute shell commands by starting lines with $." + .to_string() + }) +} + +pub(super) fn build_emulator_tool_description(tools: &[Tool], code_mode_enabled: bool) -> String { + let mut tool_desc = String::new(); + + if code_mode_enabled { + tool_desc.push_str("\n\n# Running Code\n\n"); + tool_desc.push_str( + "You can call tools by writing code in a ```execute block. \ + The code runs immediately — do not explain it, just run it.\n\n", + ); + tool_desc.push_str("Example — counting files in /tmp:\n\n"); + tool_desc.push_str("```execute\nasync function run() {\n"); + tool_desc.push_str( + " const result = await Developer.shell({ command: \"ls -1 /tmp | wc -l\" });\n", + ); + tool_desc.push_str(" return result;\n}\n```\n\n"); + tool_desc.push_str("Rules:\n"); + tool_desc.push_str("- Code MUST define async function run() and return a result\n"); + tool_desc.push_str("- All function calls are async — use await\n"); + tool_desc.push_str("- Use ```execute for tool calls, $ for simple shell one-liners\n\n"); + tool_desc.push_str("Available functions:\n\n"); + + for tool in tools { + if tool.name.starts_with("code_execution__") { + continue; + } + let parts: Vec<&str> = tool.name.splitn(2, "__").collect(); + if parts.len() == 2 { + let namespace = { + let mut c = parts[0].chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect::(), + } + }; + let camel_name: String = parts[1] + .split('_') + .enumerate() + .map(|(i, part)| { + if i == 0 { + part.to_string() + } else { + let mut c = part.chars(); + match c.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(c).collect(), + } + } + }) + .collect(); + let desc = tool.description.as_ref().map(|d| d.as_ref()).unwrap_or(""); + tool_desc.push_str(&format!("- {namespace}.{camel_name}(): {desc}\n")); + } + } + } else { + tool_desc.push_str("\n\n# Tools\n\nYou have access to the following tools:\n\n"); + for tool in tools { + let desc = tool + .description + .as_ref() + .map(|d| d.as_ref()) + .unwrap_or("No description"); + tool_desc.push_str(&format!("- {}: {}\n", tool.name, desc)); + } + } + + tool_desc +} + +enum EmulatorAction { + Text(String), + ShellCommand(String), + ExecuteCode(String), +} + +enum ParserState { + Normal, + InCommand, + InExecuteBlock, +} + +struct StreamingEmulatorParser { + buffer: String, + state: ParserState, + code_mode_enabled: bool, +} + +impl StreamingEmulatorParser { + fn new(code_mode_enabled: bool) -> Self { + Self { + buffer: String::new(), + state: ParserState::Normal, + code_mode_enabled, + } + } + + fn process_chunk(&mut self, chunk: &str) -> Vec { + self.buffer.push_str(chunk); + let mut results = Vec::new(); + + loop { + match self.state { + ParserState::InCommand => { + if let Some((command_line, rest)) = self.buffer.split_once('\n') { + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + } else { + break; + } + } + ParserState::InExecuteBlock => { + // Look for closing ``` to end the execute block + if let Some(end_idx) = self.buffer.find("\n```") { + #[allow(clippy::string_slice)] + let code = self.buffer[..end_idx].to_string(); + // Skip past the closing ``` and any trailing newline + #[allow(clippy::string_slice)] + let rest = &self.buffer[end_idx + 4..]; + let rest = rest.strip_prefix('\n').unwrap_or(rest); + self.buffer = rest.to_string(); + self.state = ParserState::Normal; + if !code.trim().is_empty() { + results.push(EmulatorAction::ExecuteCode(code)); + } + } else { + // Still accumulating code — wait for closing fence + break; + } + } + ParserState::Normal => { + // Check for ```execute block (code mode) + if self.code_mode_enabled { + if let Some((before, after)) = self.buffer.split_once("```execute\n") { + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer = after.to_string(); + self.state = ParserState::InExecuteBlock; + continue; + } + // Also handle without newline after tag (accumulating) + if self.buffer.ends_with("```execute") { + let before = self.buffer.trim_end_matches("```execute"); + if !before.trim().is_empty() { + results.push(EmulatorAction::Text(before.to_string())); + } + self.buffer.clear(); + self.state = ParserState::InExecuteBlock; + continue; + } + } + + // Check for $ command + if let Some((before_dollar, from_dollar)) = self.buffer.split_once("\n$") { + let text = format!("{}\n", before_dollar); + if !text.trim().is_empty() { + results.push(EmulatorAction::Text(text)); + } + self.buffer = format!("${}", from_dollar); + self.state = ParserState::InCommand; + } else if self.buffer.starts_with('$') && self.buffer.len() == chunk.len() { + self.state = ParserState::InCommand; + } else { + let hold_back = if self.code_mode_enabled { + HOLD_BACK_CODE_MODE + } else { + HOLD_BACK_SHELL_ONLY + }; + let char_count = self.buffer.chars().count(); + if char_count > hold_back && !self.buffer.ends_with('\n') { + let mut chars = self.buffer.chars(); + let emit_count = char_count - hold_back; + let emit_text: String = chars.by_ref().take(emit_count).collect(); + let keep_text: String = chars.collect(); + if !emit_text.is_empty() { + results.push(EmulatorAction::Text(emit_text)); + } + self.buffer = keep_text; + } + break; + } + } + } + } + + results + } + + fn flush(&mut self) -> Vec { + let mut results = Vec::new(); + + if !self.buffer.is_empty() { + match self.state { + ParserState::InCommand => { + let command_line = self.buffer.trim(); + if let Some(command) = command_line.strip_prefix('$') { + let command = command.trim(); + if !command.is_empty() { + results.push(EmulatorAction::ShellCommand(command.to_string())); + } + } else if !command_line.is_empty() { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + ParserState::InExecuteBlock => { + let code = self.buffer.trim(); + if !code.is_empty() { + results.push(EmulatorAction::ExecuteCode(code.to_string())); + } + } + ParserState::Normal => { + results.push(EmulatorAction::Text(self.buffer.clone())); + } + } + self.buffer.clear(); + self.state = ParserState::Normal; + } + + results + } +} + +fn send_emulator_action( + action: &EmulatorAction, + message_id: &str, + tx: &StreamSender, +) -> Result { + match action { + EmulatorAction::Text(text) => { + let mut message = Message::assistant().with_text(text); + message.id = Some(message_id.to_string()); + tx.blocking_send(Ok((Some(message), None))) + .map_err(|_| ())?; + Ok(false) + } + EmulatorAction::ShellCommand(command) => { + let tool_id = Uuid::new_v4().to_string(); + let mut args = serde_json::Map::new(); + args.insert("command".to_string(), json!(command)); + let tool_call = CallToolRequestParams { + meta: None, + task: None, + name: Cow::Borrowed(SHELL_TOOL), + arguments: Some(args), + }; + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + tx.blocking_send(Ok((Some(message), None))) + .map_err(|_| ())?; + Ok(true) + } + EmulatorAction::ExecuteCode(code) => { + let tool_id = Uuid::new_v4().to_string(); + let wrapped = if code.contains("async function run()") { + code.clone() + } else { + format!("async function run() {{\n{}\n}}", code) + }; + let mut args = serde_json::Map::new(); + args.insert("code".to_string(), json!(wrapped)); + let tool_call = CallToolRequestParams { + meta: None, + task: None, + name: Cow::Borrowed(CODE_EXECUTION_TOOL), + arguments: Some(args), + }; + let mut message = Message::assistant(); + message + .content + .push(MessageContent::tool_request(tool_id, Ok(tool_call))); + message.id = Some(message_id.to_string()); + tx.blocking_send(Ok((Some(message), None))) + .map_err(|_| ())?; + Ok(true) + } + } +} + +pub(super) fn generate_with_emulated_tools( + ctx: &mut GenerationContext<'_>, + code_mode_enabled: bool, +) -> Result<(), ProviderError> { + let prompt = ctx + .loaded + .model + .apply_chat_template(&ctx.loaded.template, ctx.chat_messages, true) + .map_err(|e| { + ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e)) + })?; + + let tokens = ctx + .loaded + .model + .str_to_token(&prompt, AddBos::Never) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + + let (prompt_token_count, effective_ctx) = validate_and_compute_context( + ctx.loaded, + ctx.runtime, + tokens.len(), + ctx.context_limit, + ctx.settings, + )?; + let mut llama_ctx = create_and_prefill_context( + ctx.loaded, + ctx.runtime, + &tokens, + effective_ctx, + ctx.settings, + )?; + + let message_id = ctx.message_id; + let tx = ctx.tx; + let mut emulator_parser = StreamingEmulatorParser::new(code_mode_enabled); + let mut tool_call_emitted = false; + let mut send_failed = false; + + let output_token_count = generation_loop( + &ctx.loaded.model, + &mut llama_ctx, + ctx.settings, + prompt_token_count, + effective_ctx, + |piece| { + let actions = emulator_parser.process_chunk(piece); + for action in actions { + match send_emulator_action(&action, message_id, tx) { + Ok(is_tool) => { + if is_tool { + tool_call_emitted = true; + } + } + Err(_) => { + send_failed = true; + return Ok(TokenAction::Stop); + } + } + } + if tool_call_emitted { + Ok(TokenAction::Stop) + } else { + Ok(TokenAction::Continue) + } + }, + )?; + + if !send_failed { + for action in emulator_parser.flush() { + if send_emulator_action(&action, message_id, tx).is_err() { + break; + } + } + } + + let provider_usage = finalize_usage( + ctx.log, + std::mem::take(&mut ctx.model_name), + "emulator", + prompt_token_count, + output_token_count, + None, + ); + let _ = ctx.tx.blocking_send(Ok((None, Some(provider_usage)))); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Collect all actions from feeding chunks through the parser, then flushing. + fn parse_chunks(chunks: &[&str], code_mode: bool) -> Vec { + let mut parser = StreamingEmulatorParser::new(code_mode); + let mut actions = Vec::new(); + for chunk in chunks { + actions.extend(parser.process_chunk(chunk)); + } + actions.extend(parser.flush()); + actions + } + + fn parse_all(input: &str, code_mode: bool) -> Vec { + parse_chunks(&[input], code_mode) + } + + fn assert_text(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::Text(t) => assert_eq!(t.trim(), expected.trim(), "text mismatch"), + other => panic!("expected Text, got {:?}", action_label(other)), + } + } + + fn assert_shell(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ShellCommand(cmd) => { + assert_eq!(cmd, expected, "shell command mismatch") + } + other => panic!("expected ShellCommand, got {:?}", action_label(other)), + } + } + + fn assert_execute(action: &EmulatorAction, expected: &str) { + match action { + EmulatorAction::ExecuteCode(code) => { + assert_eq!(code.trim(), expected.trim(), "execute code mismatch") + } + other => panic!("expected ExecuteCode, got {:?}", action_label(other)), + } + } + + fn action_label(a: &EmulatorAction) -> &'static str { + match a { + EmulatorAction::Text(_) => "Text", + EmulatorAction::ShellCommand(_) => "ShellCommand", + EmulatorAction::ExecuteCode(_) => "ExecuteCode", + } + } + + #[test] + fn plain_text_no_tools() { + let actions = parse_all("Hello, world!", false); + // Hold-back may split text across actions; concatenate all text + let all_text: String = actions + .iter() + .map(|a| match a { + EmulatorAction::Text(t) => t.as_str(), + _ => panic!("expected only Text actions"), + }) + .collect(); + assert_eq!(all_text.trim(), "Hello, world!"); + } + + #[test] + fn single_shell_command() { + let actions = parse_all("$ ls -la\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "ls -la"); + } + + #[test] + fn text_then_shell_command() { + let actions = parse_all("Let me check:\n$ ls -la\n", false); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Let me check:"); + assert_shell(&actions[actions.len() - 1], "ls -la"); + } + + #[test] + fn shell_command_at_start_of_output() { + let actions = parse_all("$ whoami\n", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn shell_command_without_trailing_newline() { + // Flush should handle unterminated command + let actions = parse_all("$ whoami", false); + assert_eq!(actions.len(), 1); + assert_shell(&actions[0], "whoami"); + } + + #[test] + fn dollar_sign_mid_sentence_is_not_command() { + let actions = parse_all("It costs $50 per month", false); + for action in &actions { + assert!( + matches!(action, EmulatorAction::Text(_)), + "mid-sentence $ should not trigger a shell command" + ); + } + let all_text: String = actions + .iter() + .filter_map(|a| match a { + EmulatorAction::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert_eq!(all_text.trim(), "It costs $50 per month"); + } + + #[test] + fn execute_block() { + let input = "Here's the code:\n```execute\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, true); + assert!(actions.len() >= 2); + assert_text(&actions[0], "Here's the code:"); + assert_execute(&actions[actions.len() - 1], "console.log('hi');"); + } + + #[test] + fn execute_block_not_detected_without_code_mode() { + let input = "```execute\nconsole.log('hi');\n```\n"; + let actions = parse_all(input, false); + // Should be treated as plain text + for action in &actions { + assert!(matches!(action, EmulatorAction::Text(_))); + } + } + + #[test] + fn dollar_split_across_chunks() { + // The \n and $ arrive in separate chunks + let actions = parse_chunks(&["Let me check\n", "$ ls -la\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "ls -la"); + } + + #[test] + fn execute_fence_split_across_chunks() { + let actions = parse_chunks(&["Here:\n```ex", "ecute\nlet x = 1;\n", "```\n"], true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } + + #[test] + fn multiple_commands_on_separate_lines() { + // In practice, generation stops after the first tool call. But the + // parser should detect commands separated by \n$ when fed as chunks. + let actions = parse_chunks(&["Here:\n$ cd /tmp\n", "Done.\n$ ls\n"], false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 2); + assert_shell(shells[0], "cd /tmp"); + assert_shell(shells[1], "ls"); + } + + #[test] + fn regular_code_fence_not_treated_as_execute() { + let input = "```python\nprint('hi')\n```\n"; + let actions = parse_all(input, true); + for action in &actions { + assert!( + matches!(action, EmulatorAction::Text(_)), + "regular code fence should be text" + ); + } + } + + #[test] + fn empty_command_ignored() { + let actions = parse_all("$\n", false); + // Empty command after $ should not produce a ShellCommand + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 0); + } + + #[test] + fn token_by_token_streaming() { + // Simulate LLM generating one token at a time + let input = "$ echo hello\n"; + let chars: Vec = input.chars().map(|c| c.to_string()).collect(); + let chunks: Vec<&str> = chars.iter().map(|s| s.as_str()).collect(); + let actions = parse_chunks(&chunks, false); + let shells: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ShellCommand(_))) + .collect(); + assert_eq!(shells.len(), 1); + assert_shell(shells[0], "echo hello"); + } + + #[test] + fn execute_block_with_multiline_code() { + let input = "```execute\nasync function run() {\n const r = await Developer.shell({ command: \"ls\" });\n return r;\n}\n```\n"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + match executes[0] { + EmulatorAction::ExecuteCode(code) => { + assert!(code.contains("async function run()")); + assert!(code.contains("Developer.shell")); + } + _ => unreachable!(), + } + } + + #[test] + fn unclosed_execute_block_flushed() { + // Model stops generating mid-block + let input = "```execute\nlet x = 1;"; + let actions = parse_all(input, true); + let executes: Vec<_> = actions + .iter() + .filter(|a| matches!(a, EmulatorAction::ExecuteCode(_))) + .collect(); + assert_eq!(executes.len(), 1); + assert_execute(executes[0], "let x = 1;"); + } +} diff --git a/crates/goose/src/providers/local_inference/inference_engine.rs b/crates/goose/src/providers/local_inference/inference_engine.rs new file mode 100644 index 0000000000..06256fca90 --- /dev/null +++ b/crates/goose/src/providers/local_inference/inference_engine.rs @@ -0,0 +1,387 @@ +use crate::providers::errors::ProviderError; +use crate::providers::local_inference::local_model_registry::ModelSettings; +use crate::providers::utils::RequestLog; +use llama_cpp_2::context::params::LlamaContextParams; +use llama_cpp_2::llama_batch::LlamaBatch; +use llama_cpp_2::model::{LlamaChatMessage, LlamaChatTemplate, LlamaModel}; +use llama_cpp_2::sampling::LlamaSampler; +use std::num::NonZeroU32; + +use super::{InferenceRuntime, StreamSender}; + +pub(super) struct GenerationContext<'a> { + pub loaded: &'a LoadedModel, + pub runtime: &'a InferenceRuntime, + pub chat_messages: &'a [LlamaChatMessage], + pub settings: &'a ModelSettings, + pub context_limit: usize, + pub model_name: String, + pub message_id: &'a str, + pub tx: &'a StreamSender, + pub log: &'a mut RequestLog, +} + +pub(super) struct LoadedModel { + pub model: LlamaModel, + pub template: LlamaChatTemplate, +} + +/// Estimate the maximum context length that can fit in available accelerator/CPU +/// memory based on the model's KV cache requirements. +/// +/// Returns `None` if the model architecture values are unavailable. +pub(super) fn estimate_max_context_for_memory( + model: &LlamaModel, + runtime: &InferenceRuntime, +) -> Option { + let available = super::available_inference_memory_bytes(runtime); + if available == 0 { + return None; + } + + // Reserve memory for computation scratch buffers (attention, etc.) and other overhead. + // The compute buffer can be 40-50% of the KV cache size for large models, so we + // conservatively use only half the available memory for the KV cache. + let usable = (available as f64 * 0.5) as u64; + + let n_layer = model.n_layer() as u64; + let n_head_kv = model.n_head_kv() as u64; + let n_head = model.n_head() as u64; + let n_embd = model.n_embd() as u64; + + if n_head == 0 || n_layer == 0 || n_head_kv == 0 || n_embd == 0 { + return None; + } + + // For MLA (Multi-head Latent Attention) models like DeepSeek/GLM, the actual KV cache + // dimensions differ from n_head_kv * head_dim. Read the true dimensions from GGUF metadata. + let arch = model + .meta_val_str("general.architecture") + .unwrap_or_default(); + let head_dim = n_embd / n_head; + let k_per_head = model + .meta_val_str(&format!("{arch}.attention.key_length")) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(head_dim); + let v_per_head = model + .meta_val_str(&format!("{arch}.attention.value_length")) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(head_dim); + + // Total KV dimensions across all KV heads, times n_layer, times 2 bytes (f16) per element + let bytes_per_token = (k_per_head + v_per_head) * n_head_kv * n_layer * 2; + + if bytes_per_token == 0 { + return None; + } + + Some((usable / bytes_per_token) as usize) +} + +pub(super) fn context_cap( + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + context_limit: usize, + n_ctx_train: usize, + memory_max_ctx: Option, +) -> usize { + if let Some(ctx_size) = settings.context_size { + return ctx_size as usize; + } + + let limit = if context_limit > 0 { + context_limit + } else { + n_ctx_train + }; + + match memory_max_ctx { + Some(mem_max) if mem_max < limit => { + tracing::info!( + "Capping context from {} to {} based on available memory", + limit, + mem_max, + ); + mem_max + } + _ => limit, + } +} + +pub(super) fn effective_context_size( + prompt_token_count: usize, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + context_limit: usize, + n_ctx_train: usize, + memory_max_ctx: Option, +) -> usize { + let limit = context_cap(settings, context_limit, n_ctx_train, memory_max_ctx); + let min_generation_headroom = 512; + let needed = prompt_token_count + min_generation_headroom; + if needed > limit { + tracing::warn!( + "Prompt ({} tokens) + headroom exceeds context limit ({}), capping to limit", + prompt_token_count, + limit, + ); + } + needed.min(limit) +} + +pub(super) fn build_context_params( + ctx_size: u32, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, +) -> LlamaContextParams { + let mut params = LlamaContextParams::default().with_n_ctx(NonZeroU32::new(ctx_size)); + + if let Some(n_batch) = settings.n_batch { + params = params.with_n_batch(n_batch); + } + if let Some(n_threads) = settings.n_threads { + params = params.with_n_threads(n_threads); + params = params.with_n_threads_batch(n_threads); + } + if let Some(flash_attn) = settings.flash_attention { + let policy = if flash_attn { 1 } else { 0 }; + params = params.with_flash_attention_policy(policy); + } + + params +} + +pub(super) fn build_sampler( + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, +) -> LlamaSampler { + use crate::providers::local_inference::local_model_registry::SamplingConfig; + + let has_penalties = settings.repeat_penalty != 1.0 + || settings.frequency_penalty != 0.0 + || settings.presence_penalty != 0.0; + + let mut samplers: Vec = Vec::new(); + + if has_penalties { + samplers.push(LlamaSampler::penalties( + settings.repeat_last_n, + settings.repeat_penalty, + settings.frequency_penalty, + settings.presence_penalty, + )); + } + + match &settings.sampling { + SamplingConfig::Greedy => { + samplers.push(LlamaSampler::greedy()); + } + SamplingConfig::Temperature { + temperature, + top_k, + top_p, + min_p, + seed, + } => { + samplers.push(LlamaSampler::top_k(*top_k)); + samplers.push(LlamaSampler::top_p(*top_p, 1)); + samplers.push(LlamaSampler::min_p(*min_p, 1)); + samplers.push(LlamaSampler::temp(*temperature)); + samplers.push(LlamaSampler::dist(seed.unwrap_or(0))); + } + SamplingConfig::MirostatV2 { tau, eta, seed } => { + samplers.push(LlamaSampler::mirostat_v2(seed.unwrap_or(0), *tau, *eta)); + } + } + + if samplers.len() == 1 { + samplers.pop().unwrap() + } else { + LlamaSampler::chain_simple(samplers) + } +} + +/// Validate prompt tokens against memory limits and compute the effective +/// context size. Returns `(prompt_token_count, effective_ctx)`. +pub(super) fn validate_and_compute_context( + loaded: &LoadedModel, + runtime: &InferenceRuntime, + prompt_token_count: usize, + context_limit: usize, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, +) -> Result<(usize, usize), ProviderError> { + let n_ctx_train = loaded.model.n_ctx_train() as usize; + let memory_max_ctx = estimate_max_context_for_memory(&loaded.model, runtime); + let effective_ctx = effective_context_size( + prompt_token_count, + settings, + context_limit, + n_ctx_train, + memory_max_ctx, + ); + if let Some(mem_max) = memory_max_ctx { + if prompt_token_count > mem_max { + return Err(ProviderError::ContextLengthExceeded(format!( + "Prompt ({} tokens) exceeds estimated memory capacity ({} tokens). \ + Try a smaller model or reduce conversation length.", + prompt_token_count, mem_max, + ))); + } + } + Ok((prompt_token_count, effective_ctx)) +} + +/// Create a llama context and prefill (decode) all prompt tokens. +pub(super) fn create_and_prefill_context<'model>( + loaded: &'model LoadedModel, + runtime: &InferenceRuntime, + tokens: &[llama_cpp_2::token::LlamaToken], + effective_ctx: usize, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, +) -> Result, ProviderError> { + let ctx_params = build_context_params(effective_ctx as u32, settings); + let mut ctx = loaded + .model + .new_context(runtime.backend(), ctx_params) + .map_err(|e| ProviderError::ExecutionError(format!("Failed to create context: {}", e)))?; + + let n_batch = ctx.n_batch() as usize; + for chunk in tokens.chunks(n_batch) { + let mut batch = LlamaBatch::get_one(chunk) + .map_err(|e| ProviderError::ExecutionError(format!("Failed to create batch: {}", e)))?; + ctx.decode(&mut batch) + .map_err(|e| ProviderError::ExecutionError(format!("Prefill decode failed: {}", e)))?; + } + + Ok(ctx) +} + +/// Action to take after processing a generated token piece. +pub(super) enum TokenAction { + Continue, + Stop, +} + +/// Run the autoregressive generation loop. Calls `on_piece` for each non-empty +/// token piece. The callback returns `TokenAction::Stop` to break early. +/// Returns the total number of generated tokens. +pub(super) fn generation_loop( + model: &LlamaModel, + ctx: &mut llama_cpp_2::context::LlamaContext<'_>, + settings: &crate::providers::local_inference::local_model_registry::ModelSettings, + prompt_token_count: usize, + effective_ctx: usize, + mut on_piece: impl FnMut(&str) -> Result, +) -> Result { + let mut sampler = build_sampler(settings); + let max_output = if let Some(max) = settings.max_output_tokens { + effective_ctx.saturating_sub(prompt_token_count).min(max) + } else { + effective_ctx.saturating_sub(prompt_token_count) + }; + let mut decoder = encoding_rs::UTF_8.new_decoder(); + let mut output_token_count: i32 = 0; + + for _ in 0..max_output { + let token = sampler.sample(ctx, -1); + sampler.accept(token); + + if model.is_eog_token(token) { + break; + } + + output_token_count += 1; + + let piece = model + .token_to_piece(token, &mut decoder, true, None) + .map_err(|e| ProviderError::ExecutionError(format!("Failed to decode token: {}", e)))?; + + if !piece.is_empty() && matches!(on_piece(&piece)?, TokenAction::Stop) { + break; + } + + let next_tokens = [token]; + let mut next_batch = LlamaBatch::get_one(&next_tokens) + .map_err(|e| ProviderError::ExecutionError(format!("Failed to create batch: {}", e)))?; + ctx.decode(&mut next_batch) + .map_err(|e| ProviderError::ExecutionError(format!("Decode failed: {}", e)))?; + } + + Ok(output_token_count) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::local_inference::local_model_registry::ModelSettings; + + fn default_settings() -> ModelSettings { + ModelSettings::default() + } + + #[test] + fn test_effective_context_size_basic() { + assert_eq!( + effective_context_size(100, &default_settings(), 4096, 4096, None), + 612 + ); + } + + #[test] + fn test_effective_context_size_capped_by_limit() { + assert_eq!( + effective_context_size(100, &default_settings(), 1024, 8192, None), + 612 + ); + } + + #[test] + fn test_effective_context_size_capped_by_memory() { + assert_eq!( + effective_context_size(100, &default_settings(), 4096, 4096, Some(800)), + 612 + ); + } + + #[test] + fn test_effective_context_size_memory_smaller_than_needed() { + assert_eq!( + effective_context_size(600, &default_settings(), 4096, 4096, Some(700)), + 700 + ); + } + + #[test] + fn test_effective_context_size_zero_limit_uses_train() { + assert_eq!( + effective_context_size(100, &default_settings(), 0, 2048, None), + 612 + ); + } + + #[test] + fn test_effective_context_size_prompt_exceeds_all_limits() { + assert_eq!( + effective_context_size(5000, &default_settings(), 4096, 4096, None), + 4096 + ); + } + + #[test] + fn test_context_cap_with_settings_override() { + let mut settings = default_settings(); + settings.context_size = Some(2048); + assert_eq!(context_cap(&settings, 4096, 8192, Some(1024)), 2048); + } + + #[test] + fn test_context_cap_without_override() { + assert_eq!(context_cap(&default_settings(), 4096, 8192, None), 4096); + } + + #[test] + fn test_context_cap_memory_limited() { + assert_eq!( + context_cap(&default_settings(), 4096, 8192, Some(2048)), + 2048 + ); + } +} diff --git a/crates/goose/src/providers/local_inference/inference_native_tools.rs b/crates/goose/src/providers/local_inference/inference_native_tools.rs new file mode 100644 index 0000000000..656a6e08cf --- /dev/null +++ b/crates/goose/src/providers/local_inference/inference_native_tools.rs @@ -0,0 +1,197 @@ +use crate::conversation::message::Message; +use crate::providers::errors::ProviderError; +use llama_cpp_2::model::AddBos; +use llama_cpp_2::openai::OpenAIChatTemplateParams; + +use super::finalize_usage; +use super::inference_engine::{ + context_cap, create_and_prefill_context, estimate_max_context_for_memory, generation_loop, + validate_and_compute_context, GenerationContext, TokenAction, +}; +use super::tool_parsing::{ + extract_tool_call_messages, extract_xml_tool_call_messages, safe_stream_end, + split_content_and_tool_calls, split_content_and_xml_tool_calls, +}; + +pub(super) fn generate_with_native_tools( + ctx: &mut GenerationContext<'_>, + oai_messages_json: &Option, + full_tools_json: Option<&str>, + compact_tools: Option<&str>, +) -> Result<(), ProviderError> { + let min_generation_headroom = 512; + let n_ctx_train = ctx.loaded.model.n_ctx_train() as usize; + let memory_max_ctx = estimate_max_context_for_memory(&ctx.loaded.model, ctx.runtime); + let cap = context_cap(ctx.settings, ctx.context_limit, n_ctx_train, memory_max_ctx); + let token_budget = cap.saturating_sub(min_generation_headroom); + + let apply_template = |tools: Option<&str>| { + if let Some(ref messages_json) = oai_messages_json { + let params = OpenAIChatTemplateParams { + messages_json: messages_json.as_str(), + tools_json: tools, + tool_choice: None, + json_schema: None, + grammar: None, + reasoning_format: None, + chat_template_kwargs: None, + add_generation_prompt: true, + use_jinja: true, + parallel_tool_calls: false, + enable_thinking: false, + add_bos: false, + add_eos: false, + parse_tool_calls: true, + }; + ctx.loaded + .model + .apply_chat_template_oaicompat(&ctx.loaded.template, ¶ms) + } else { + ctx.loaded.model.apply_chat_template_with_tools_oaicompat( + &ctx.loaded.template, + ctx.chat_messages, + tools, + None, + true, + ) + } + }; + + let template_result = match apply_template(full_tools_json) { + Ok(r) => { + let token_count = ctx + .loaded + .model + .str_to_token(&r.prompt, AddBos::Never) + .map(|t| t.len()) + .unwrap_or(0); + if token_count > token_budget { + apply_template(compact_tools).unwrap_or(r) + } else { + r + } + } + Err(_) => apply_template(compact_tools).map_err(|e| { + ProviderError::ExecutionError(format!("Failed to apply chat template: {}", e)) + })?, + }; + + let _ = ctx.log.write( + &serde_json::json!({"applied_prompt": &template_result.prompt}), + None, + ); + + let tokens = ctx + .loaded + .model + .str_to_token(&template_result.prompt, AddBos::Never) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + + let (prompt_token_count, effective_ctx) = validate_and_compute_context( + ctx.loaded, + ctx.runtime, + tokens.len(), + ctx.context_limit, + ctx.settings, + )?; + let mut llama_ctx = create_and_prefill_context( + ctx.loaded, + ctx.runtime, + &tokens, + effective_ctx, + ctx.settings, + )?; + + let message_id = ctx.message_id; + let tx = ctx.tx; + let mut generated_text = String::new(); + let mut streamed_len: usize = 0; + + let output_token_count = generation_loop( + &ctx.loaded.model, + &mut llama_ctx, + ctx.settings, + prompt_token_count, + effective_ctx, + |piece| { + generated_text.push_str(piece); + + let has_xml_tc = split_content_and_xml_tool_calls(&generated_text).is_some(); + let (content, tc) = split_content_and_tool_calls(&generated_text); + let stream_up_to = if tc.is_some() { + content.len() + } else if has_xml_tc { + split_content_and_xml_tool_calls(&generated_text) + .map(|(c, _)| c.len()) + .unwrap_or(0) + } else { + safe_stream_end(&generated_text) + }; + if stream_up_to > streamed_len { + #[allow(clippy::string_slice)] + let new_text = &generated_text[streamed_len..stream_up_to]; + if !new_text.is_empty() { + let mut msg = Message::assistant().with_text(new_text); + msg.id = Some(message_id.to_string()); + if tx.blocking_send(Ok((Some(msg), None))).is_err() { + return Ok(TokenAction::Stop); + } + } + streamed_len = stream_up_to; + } + + let should_stop = template_result + .additional_stops + .iter() + .any(|stop| generated_text.ends_with(stop)); + if should_stop { + Ok(TokenAction::Stop) + } else { + Ok(TokenAction::Continue) + } + }, + )?; + + let (content, tool_call_msgs) = + if let Some((xml_content, xml_calls)) = split_content_and_xml_tool_calls(&generated_text) { + let msgs = extract_xml_tool_call_messages(xml_calls, message_id); + (xml_content, msgs) + } else { + let (json_content, tool_calls_json) = split_content_and_tool_calls(&generated_text); + let msgs = tool_calls_json + .map(|tc| extract_tool_call_messages(&tc, message_id)) + .unwrap_or_default(); + (json_content, msgs) + }; + + if content.len() > streamed_len { + #[allow(clippy::string_slice)] + let remaining = &content[streamed_len..]; + if !remaining.is_empty() { + let mut msg = Message::assistant().with_text(remaining); + msg.id = Some(message_id.to_string()); + let _ = tx.blocking_send(Ok((Some(msg), None))); + } + } + + if !tool_call_msgs.is_empty() { + for msg in tool_call_msgs { + let _ = tx.blocking_send(Ok((Some(msg), None))); + } + } else if content.is_empty() && !generated_text.is_empty() { + let mut msg = Message::assistant().with_text(&generated_text); + msg.id = Some(message_id.to_string()); + let _ = tx.blocking_send(Ok((Some(msg), None))); + } + + let provider_usage = finalize_usage( + ctx.log, + std::mem::take(&mut ctx.model_name), + "native", + prompt_token_count, + output_token_count, + Some(("generated_text", &generated_text)), + ); + let _ = ctx.tx.blocking_send(Ok((None, Some(provider_usage)))); + Ok(()) +} diff --git a/crates/goose/src/providers/local_inference/local_model_registry.rs b/crates/goose/src/providers/local_inference/local_model_registry.rs new file mode 100644 index 0000000000..3ef80c6ae1 --- /dev/null +++ b/crates/goose/src/providers/local_inference/local_model_registry.rs @@ -0,0 +1,307 @@ +use crate::config::paths::Paths; +use crate::download_manager::{get_download_manager, DownloadStatus}; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type")] +pub enum SamplingConfig { + Greedy, + Temperature { + temperature: f32, + top_k: i32, + top_p: f32, + min_p: f32, + seed: Option, + }, + MirostatV2 { + tau: f32, + eta: f32, + seed: Option, + }, +} + +impl Default for SamplingConfig { + fn default() -> Self { + SamplingConfig::Temperature { + temperature: 0.8, + top_k: 40, + top_p: 0.95, + min_p: 0.05, + seed: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ModelSettings { + pub context_size: Option, + pub max_output_tokens: Option, + #[serde(default)] + pub sampling: SamplingConfig, + #[serde(default = "default_repeat_penalty")] + pub repeat_penalty: f32, + #[serde(default = "default_repeat_last_n")] + pub repeat_last_n: i32, + #[serde(default)] + pub frequency_penalty: f32, + #[serde(default)] + pub presence_penalty: f32, + pub n_batch: Option, + pub n_gpu_layers: Option, + #[serde(default)] + pub use_mlock: bool, + pub flash_attention: Option, + pub n_threads: Option, + #[serde(default)] + pub native_tool_calling: bool, + #[serde(default)] + pub use_jinja: bool, +} + +fn default_repeat_penalty() -> f32 { + 1.0 +} + +fn default_repeat_last_n() -> i32 { + 64 +} + +impl Default for ModelSettings { + fn default() -> Self { + Self { + context_size: None, + max_output_tokens: None, + sampling: SamplingConfig::default(), + repeat_penalty: 1.0, + repeat_last_n: 64, + frequency_penalty: 0.0, + presence_penalty: 0.0, + n_batch: None, + n_gpu_layers: None, + use_mlock: false, + flash_attention: None, + n_threads: None, + native_tool_calling: false, + use_jinja: false, + } + } +} + +/// Featured models — HuggingFace specs in "author/repo-GGUF:quantization" format. +pub const FEATURED_MODELS: &[&str] = &[ + "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", + "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M", + "bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M", + "bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M", +]; + +/// Check if a model ID corresponds to a featured model. +pub fn is_featured_model(model_id: &str) -> bool { + use super::hf_models::parse_model_spec; + FEATURED_MODELS.iter().any(|spec| { + if let Ok((repo_id, quant)) = parse_model_spec(spec) { + model_id_from_repo(&repo_id, &quant) == model_id + } else { + false + } + }) +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +pub fn get_registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + let registry = LocalModelRegistry::load().unwrap_or_default(); + Mutex::new(registry) + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalModelEntry { + pub id: String, + pub repo_id: String, + pub filename: String, + pub quantization: String, + pub local_path: PathBuf, + pub source_url: String, + #[serde(default)] + pub settings: ModelSettings, + #[serde(default)] + pub size_bytes: u64, +} + +impl LocalModelEntry { + pub fn is_downloaded(&self) -> bool { + self.local_path.exists() + } + + pub fn is_downloading(&self) -> bool { + let download_id = format!("{}-model", self.id); + let manager = get_download_manager(); + manager.get_progress(&download_id).is_some() + } + + pub fn download_status(&self) -> ModelDownloadStatus { + if self.local_path.exists() { + return ModelDownloadStatus::Downloaded; + } + + let download_id = format!("{}-model", self.id); + let manager = get_download_manager(); + if let Some(progress) = manager.get_progress(&download_id) { + return match progress.status { + DownloadStatus::Downloading => ModelDownloadStatus::Downloading { + progress_percent: progress.progress_percent, + bytes_downloaded: progress.bytes_downloaded, + total_bytes: progress.total_bytes, + speed_bps: progress.speed_bps.unwrap_or(0), + }, + DownloadStatus::Completed => ModelDownloadStatus::Downloaded, + DownloadStatus::Failed | DownloadStatus::Cancelled => { + ModelDownloadStatus::NotDownloaded + } + }; + } + + ModelDownloadStatus::NotDownloaded + } + + pub fn file_size(&self) -> u64 { + if self.size_bytes > 0 { + return self.size_bytes; + } + std::fs::metadata(&self.local_path) + .map(|m| m.len()) + .unwrap_or(0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ModelDownloadStatus { + NotDownloaded, + Downloading { + progress_percent: f32, + bytes_downloaded: u64, + total_bytes: u64, + speed_bps: u64, + }, + Downloaded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LocalModelRegistry { + pub models: Vec, +} + +impl LocalModelRegistry { + fn registry_path() -> PathBuf { + Paths::in_data_dir("models/registry.json") + } + + pub fn load() -> Result { + let path = Self::registry_path(); + if path.exists() { + let lock_path = path.with_extension("json.lock"); + let lock_file = std::fs::File::create(&lock_path)?; + fs2::FileExt::lock_shared(&lock_file)?; + let contents = std::fs::read_to_string(&path)?; + fs2::FileExt::unlock(&lock_file)?; + let registry: LocalModelRegistry = serde_json::from_str(&contents)?; + Ok(registry) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self) -> Result<()> { + let path = Self::registry_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let lock_path = path.with_extension("json.lock"); + let lock_file = std::fs::File::create(&lock_path)?; + fs2::FileExt::lock_exclusive(&lock_file)?; + + let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + let contents = serde_json::to_string_pretty(self)?; + std::io::Write::write_all(&mut tmp, contents.as_bytes())?; + tmp.persist(&path)?; + + fs2::FileExt::unlock(&lock_file)?; + Ok(()) + } + + /// Sync registry with featured models: + /// add any featured models that are missing, remove non-downloaded non-featured models. + pub fn sync_with_featured(&mut self, featured_entries: Vec) { + let mut changed = false; + + for entry in featured_entries { + if !self.models.iter().any(|m| m.id == entry.id) { + self.models.push(entry); + changed = true; + } + } + + let before_len = self.models.len(); + self.models + .retain(|m| m.is_downloaded() || m.is_downloading() || is_featured_model(&m.id)); + if self.models.len() != before_len { + changed = true; + } + + if changed { + let _ = self.save(); + } + } + + pub fn add_model(&mut self, entry: LocalModelEntry) -> Result<()> { + if let Some(existing) = self.models.iter_mut().find(|m| m.id == entry.id) { + *existing = entry; + } else { + self.models.push(entry); + } + self.save() + } + + pub fn remove_model(&mut self, id: &str) -> Result<()> { + self.models.retain(|m| m.id != id); + self.save() + } + + pub fn get_model(&self, id: &str) -> Option<&LocalModelEntry> { + self.models.iter().find(|m| m.id == id) + } + + pub fn has_model(&self, id: &str) -> bool { + self.models.iter().any(|m| m.id == id) + } + + pub fn get_model_settings(&self, id: &str) -> Option<&ModelSettings> { + self.models.iter().find(|m| m.id == id).map(|m| &m.settings) + } + + pub fn update_model_settings(&mut self, id: &str, settings: ModelSettings) -> Result<()> { + let entry = self + .models + .iter_mut() + .find(|m| m.id == id) + .ok_or_else(|| anyhow::anyhow!("Model not found: {}", id))?; + entry.settings = settings; + self.save() + } + + pub fn list_models(&self) -> &[LocalModelEntry] { + &self.models + } +} + +/// Generate a unique ID for a model from its repo_id and quantization. +pub fn model_id_from_repo(repo_id: &str, quantization: &str) -> String { + format!("{}:{}", repo_id, quantization) +} diff --git a/crates/goose/src/providers/local_inference/tool_parsing.rs b/crates/goose/src/providers/local_inference/tool_parsing.rs new file mode 100644 index 0000000000..cf6124a6e1 --- /dev/null +++ b/crates/goose/src/providers/local_inference/tool_parsing.rs @@ -0,0 +1,601 @@ +use crate::conversation::message::{Message, MessageContent}; +use rmcp::model::{CallToolRequestParams, Tool}; +use serde_json::{json, Value}; +use std::borrow::Cow; +use uuid::Uuid; + +pub(super) fn compact_tools_json(tools: &[Tool]) -> Option { + let compact: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description.as_ref().map(|d| d.as_ref()).unwrap_or(""), + } + }) + }) + .collect(); + serde_json::to_string(&compact).ok() +} + +/// Split generated text into (content, tool_calls_json). +/// Looks for the last top-level JSON object containing `"tool_calls"`. +/// Returns the text before it as content, and the JSON string if found. +#[allow(clippy::string_slice)] +pub(super) fn split_content_and_tool_calls(text: &str) -> (String, Option) { + let trimmed = text.trim_end(); + if !trimmed.ends_with('}') { + return (text.to_string(), None); + } + + // Scan backwards for the matching '{' of the final '}'. + // We only match on ASCII braces so `start` is always a char boundary. + let bytes = trimmed.as_bytes(); + let mut depth = 0i32; + let mut json_start = None; + for i in (0..bytes.len()).rev() { + match bytes[i] { + b'}' => depth += 1, + b'{' => { + depth -= 1; + if depth == 0 { + json_start = Some(i); + break; + } + } + _ => {} + } + } + + let Some(start) = json_start else { + return (text.to_string(), None); + }; + + let json_str = &trimmed[start..]; + let parsed: Value = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(_) => return (text.to_string(), None), + }; + + if parsed + .get("tool_calls") + .and_then(|v| v.as_array()) + .is_none() + { + return (text.to_string(), None); + } + + let content = trimmed[..start].trim_end().to_string(); + (content, Some(json_str.to_string())) +} + +/// Return the byte length of text that is safe to stream. +/// Everything before the last unmatched top-level `{` is safe — the `{` could +/// be the start of a tool-call JSON block still being generated. +/// If all braces are balanced the entire text is safe. +pub(super) fn safe_stream_end(text: &str) -> usize { + // Hold back from the start of any incomplete tag. + // If we find an unmatched opening, nothing from that point should be streamed. + let xml_hold = text.find("").unwrap_or(text.len()); + + let bytes = text.as_bytes(); + let mut safe_end = bytes.len(); + let mut depth = 0i32; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'{' => { + if depth == 0 { + safe_end = i; + } + depth += 1; + } + b'}' => { + depth -= 1; + if depth == 0 { + safe_end = i + 1; + } + } + _ => { + if depth == 0 { + safe_end = i + 1; + } + } + } + } + + // Also hold back a partial ``, hold them. + let tag = b""; + let tail_hold = { + let mut hold = safe_end; + let check_len = tag.len().min(bytes.len()); + for start in (safe_end.saturating_sub(check_len))..safe_end { + let tail = &bytes[start..safe_end]; + if tag.starts_with(tail) { + hold = start; + break; + } + } + hold + }; + + safe_end.min(xml_hold).min(tail_hold) +} + +/// Extract tool call messages from a JSON object containing "tool_calls". +/// Handles both the model's native format (name/arguments at top level) +/// and the OpenAI format (function.name/function.arguments). +pub(super) fn extract_tool_call_messages(tool_calls_json: &str, message_id: &str) -> Vec { + let parsed: Value = match serde_json::from_str(tool_calls_json) { + Ok(v) => v, + Err(_) => return vec![], + }; + + let Some(tool_calls) = parsed.get("tool_calls").and_then(|v| v.as_array()) else { + return vec![]; + }; + + let mut messages = Vec::new(); + for tc in tool_calls { + // Try OpenAI format first: {"function": {"name": ..., "arguments": ...}, "id": ...} + // Then model's native format: {"name": ..., "arguments": {...}, "id": ...} + let (name, arguments) = if let Some(func) = tc.get("function") { + let n = func.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args_str = func + .get("arguments") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let args: Option> = serde_json::from_str(args_str).ok(); + (n.to_string(), args) + } else { + let n = tc.get("name").and_then(|v| v.as_str()).unwrap_or(""); + // Arguments may be an object directly (model format) or a string (OAI format) + let args = if let Some(obj) = tc.get("arguments").and_then(|v| v.as_object()) { + Some(obj.clone()) + } else if let Some(s) = tc.get("arguments").and_then(|v| v.as_str()) { + serde_json::from_str(s).ok() + } else { + None + }; + (n.to_string(), args) + }; + + if name.is_empty() { + continue; + } + + let id = tc + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let tool_call = CallToolRequestParams { + meta: None, + task: None, + name: Cow::Owned(name), + arguments, + }; + + let mut msg = Message::assistant(); + msg.content + .push(MessageContent::tool_request(id, Ok(tool_call))); + msg.id = Some(message_id.to_string()); + messages.push(msg); + } + + messages +} + +/// Parse XML-style tool calls used by models like qwen3-coder. +/// Format: +/// ```text +/// +/// +/// value1 +/// value2 +/// +/// +/// ``` +/// Returns (content_before_tool_calls, vec_of_tool_calls) or None if no XML tool calls found. +#[allow(clippy::type_complexity)] +pub(super) fn split_content_and_xml_tool_calls( + text: &str, +) -> Option<(String, Vec<(String, serde_json::Map)>)> { + let (content, first_block_and_rest) = text.split_once("")?; + let content = content.trim_end().to_string(); + let mut tool_calls = Vec::new(); + + // Process the first block, then keep splitting on subsequent tags + let mut remaining = first_block_and_rest; + loop { + // Split off the block up to (or take the rest if unclosed) + let (block, after_close) = remaining + .split_once("") + .unwrap_or((remaining, "")); + + if let Some(tool_call) = parse_single_xml_tool_call(block) { + tool_calls.push(tool_call); + } + + // Try to find the next in what remains + match after_close.split_once("") { + Some((_between, next_remaining)) => remaining = next_remaining, + None => break, + } + } + + if tool_calls.is_empty() { + None + } else { + Some((content, tool_calls)) + } +} + +fn parse_single_xml_tool_call(block: &str) -> Option<(String, serde_json::Map)> { + // Try V... format first + if let Some(result) = parse_xml_function_format(block) { + return Some(result); + } + // Try GLM-style: TOOL_NAMEKV... + parse_xml_arg_key_value_format(block) +} + +fn parse_xml_function_format(block: &str) -> Option<(String, serde_json::Map)> { + let (_, after_func_eq) = block.split_once("')?; + let func_name = func_name.trim().to_string(); + + let mut args = serde_json::Map::new(); + let mut rest = func_body; + + while let Some((_, after_param_eq)) = rest.split_once("') else { + break; + }; + let param_name = param_name.trim().to_string(); + + let (value, after_value) = after_name_close + .split_once("") + .unwrap_or((after_name_close, "")); + let value = value.trim(); + + let json_value = + serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())); + args.insert(param_name, json_value); + + rest = after_value; + } + + Some((func_name, args)) +} + +/// Parse GLM-style tool calls: `NAMEKV...` +/// Also handles zero-argument calls like just `NAME`. +fn parse_xml_arg_key_value_format(block: &str) -> Option<(String, serde_json::Map)> { + let func_name_end = block.find("").unwrap_or(block.len()); + // Safe: find returns a byte offset at the start of an ASCII '<' character, + // and block.len() is always a valid boundary. + #[allow(clippy::string_slice)] + let func_name = block[..func_name_end].trim().to_string(); + if func_name.is_empty() { + return None; + } + + let mut args = serde_json::Map::new(); + #[allow(clippy::string_slice)] + let mut rest = &block[func_name_end..]; + + while let Some((_, after_key_open)) = rest.split_once("") { + let Some((key, after_key_close)) = after_key_open.split_once("") else { + break; + }; + let key = key.trim().to_string(); + + let Some((_, after_val_open)) = after_key_close.split_once("") else { + break; + }; + let (value, after_val_close) = after_val_open + .split_once("") + .unwrap_or((after_val_open, "")); + let value = value.trim(); + + let json_value = + serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())); + args.insert(key, json_value); + + rest = after_val_close; + } + + Some((func_name, args)) +} + +pub(super) fn extract_xml_tool_call_messages( + tool_calls: Vec<(String, serde_json::Map)>, + message_id: &str, +) -> Vec { + tool_calls + .into_iter() + .map(|(name, args)| { + let tool_call = CallToolRequestParams { + meta: None, + task: None, + name: Cow::Owned(name), + arguments: if args.is_empty() { None } else { Some(args) }, + }; + let mut msg = Message::assistant(); + msg.content.push(MessageContent::tool_request( + Uuid::new_v4().to_string(), + Ok(tool_call), + )); + msg.id = Some(message_id.to_string()); + msg + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const SHELL_TOOL: &str = "developer__shell"; + + #[test] + fn test_parse_xml_tool_call_single() { + let text = "I'll search for that.\n\n\n\nlocal.*inference\n\n"; + let result = split_content_and_xml_tool_calls(text); + assert!(result.is_some()); + let (content, calls) = result.unwrap(); + assert_eq!(content, "I'll search for that."); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "search__files"); + assert_eq!(calls[0].1.get("pattern").unwrap(), "local.*inference"); + } + + #[test] + fn test_parse_xml_tool_call_multiple_params() { + let text = "\n\nls -la\n30\n\n"; + let result = split_content_and_xml_tool_calls(text); + assert!(result.is_some()); + let (content, calls) = result.unwrap(); + assert!(content.is_empty()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, SHELL_TOOL); + assert_eq!(calls[0].1.get("command").unwrap(), "ls -la"); + // 30 should be parsed as a number + assert_eq!(calls[0].1.get("timeout").unwrap(), &json!(30)); + } + + #[test] + fn test_parse_xml_tool_call_no_tool_call() { + let text = "Just some regular text with no tool calls."; + assert!(split_content_and_xml_tool_calls(text).is_none()); + } + + #[test] + fn test_parse_xml_tool_call_multiple_calls() { + let text = "Doing two things:\n\n\n1\n\n\n\n\nhello\n\n"; + let result = split_content_and_xml_tool_calls(text); + assert!(result.is_some()); + let (content, calls) = result.unwrap(); + assert_eq!(content, "Doing two things:"); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].0, "foo__bar"); + assert_eq!(calls[1].0, "baz__qux"); + } + + #[test] + fn test_parse_xml_tool_call_multiline_value() { + let text = "\n\ntest.py\ndef hello():\n print(\"world\")\n\n"; + let result = split_content_and_xml_tool_calls(text); + assert!(result.is_some()); + let (_content, calls) = result.unwrap(); + assert_eq!(calls[0].0, "developer__write_file"); + assert_eq!( + calls[0].1.get("content").unwrap(), + "def hello():\n print(\"world\")" + ); + } + + #[test] + fn test_safe_stream_end_holds_back_tool_call_tag() { + let text = "Some text before \n"; + let safe = safe_stream_end(text); + assert!(safe <= text.find("").unwrap()); + } + + #[test] + fn test_safe_stream_end_holds_back_partial_tag() { + let text = "Some text "; + let result = split_content_and_xml_tool_calls(text); + assert!(result.is_some()); + let (content, calls) = result.unwrap(); + assert_eq!(content, "Let me check."); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "execute"); + assert_eq!( + calls[0].1.get("code").unwrap(), + "async function run() { return 1; }" + ); + // tool_graph should be parsed as JSON array + assert!(calls[0].1.get("tool_graph").unwrap().is_array()); + } + + #[test] + fn test_extract_xml_tool_call_messages() { + let calls = vec![( + SHELL_TOOL.to_string(), + serde_json::Map::from_iter(vec![("command".to_string(), json!("ls"))]), + )]; + let msgs = extract_xml_tool_call_messages(calls, "test-id"); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].id, Some("test-id".to_string())); + match &msgs[0].content[0] { + MessageContent::ToolRequest(req) => { + let call = req.tool_call.as_ref().unwrap(); + assert_eq!(&*call.name, SHELL_TOOL); + assert_eq!( + call.arguments.as_ref().unwrap().get("command").unwrap(), + "ls" + ); + } + _ => panic!("Expected ToolRequest"), + } + } + + #[test] + fn test_split_content_and_tool_calls_with_tool() { + let text = "Here is the result.\n{\"tool_calls\": [{\"function\": {\"name\": \"shell\", \"arguments\": \"{}\"}, \"id\": \"abc\"}]}"; + let (content, tc) = split_content_and_tool_calls(text); + assert_eq!(content, "Here is the result."); + assert!(tc.is_some()); + let parsed: serde_json::Value = serde_json::from_str(&tc.unwrap()).unwrap(); + assert_eq!(parsed["tool_calls"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_split_content_and_tool_calls_no_tool() { + let text = "Just regular text, no JSON."; + let (content, tc) = split_content_and_tool_calls(text); + assert_eq!(content, text); + assert!(tc.is_none()); + } + + #[test] + fn test_split_content_and_tool_calls_json_without_tool_calls_key() { + let text = "{\"key\": \"value\"}"; + let (content, tc) = split_content_and_tool_calls(text); + assert_eq!(content, text); + assert!(tc.is_none()); + } + + #[test] + fn test_extract_tool_call_messages_openai_format() { + let json_str = r#"{"tool_calls": [{"function": {"name": "developer__shell", "arguments": "{\"command\": \"ls\"}"}, "id": "call-1"}]}"#; + let msgs = extract_tool_call_messages(json_str, "msg-1"); + assert_eq!(msgs.len(), 1); + match &msgs[0].content[0] { + MessageContent::ToolRequest(req) => { + let call = req.tool_call.as_ref().unwrap(); + assert_eq!(&*call.name, SHELL_TOOL); + assert_eq!( + call.arguments.as_ref().unwrap().get("command").unwrap(), + "ls" + ); + } + _ => panic!("Expected ToolRequest"), + } + } + + #[test] + fn test_extract_tool_call_messages_native_format() { + let json_str = r#"{"tool_calls": [{"name": "developer__shell", "arguments": {"command": "ls"}, "id": "call-2"}]}"#; + let msgs = extract_tool_call_messages(json_str, "msg-2"); + assert_eq!(msgs.len(), 1); + match &msgs[0].content[0] { + MessageContent::ToolRequest(req) => { + let call = req.tool_call.as_ref().unwrap(); + assert_eq!(&*call.name, SHELL_TOOL); + } + _ => panic!("Expected ToolRequest"), + } + } + + #[test] + fn test_extract_tool_call_messages_invalid_json() { + assert!(extract_tool_call_messages("not json", "msg-3").is_empty()); + } + + #[test] + fn test_extract_tool_call_messages_empty_name_skipped() { + let json_str = r#"{"tool_calls": [{"name": "", "arguments": {}, "id": "x"}]}"#; + assert!(extract_tool_call_messages(json_str, "msg-4").is_empty()); + } + + #[test] + fn test_compact_tools_json_produces_minimal_output() { + use rmcp::model::Tool; + use rmcp::object; + + let tools = vec![Tool::new( + "developer__shell".to_string(), + "Run shell commands".to_string(), + object!({"type": "object", "properties": {"command": {"type": "string"}}}), + )]; + let result = compact_tools_json(&tools); + assert!(result.is_some()); + let parsed: Vec = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(parsed.len(), 1); + let func = &parsed[0]["function"]; + assert_eq!(func["name"], "developer__shell"); + assert_eq!(func["description"], "Run shell commands"); + // Should not contain full parameter schemas + assert!(func.get("parameters").is_none()); + } + + #[test] + fn test_compact_tools_json_empty() { + let result = compact_tools_json(&[]); + assert!(result.is_some()); + let parsed: Vec = serde_json::from_str(&result.unwrap()).unwrap(); + assert!(parsed.is_empty()); + } + + #[test] + fn test_safe_stream_end_balanced_braces() { + let text = "Result: {\"key\": \"value\"} done"; + assert_eq!(safe_stream_end(text), text.len()); + } + + #[test] + fn test_safe_stream_end_unbalanced_open_brace() { + let text = "Some text {\"tool_calls\": ["; + assert_eq!(safe_stream_end(text), "Some text ".len()); + } + + #[test] + fn test_safe_stream_end_empty() { + assert_eq!(safe_stream_end(""), 0); + } + + #[test] + fn test_safe_stream_end_no_braces() { + let text = "plain text here"; + assert_eq!(safe_stream_end(text), text.len()); + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 6e4862f0df..581fc2747e 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -23,6 +23,7 @@ pub mod google; mod init; pub mod lead_worker; pub mod litellm; +pub mod local_inference; pub mod oauth; pub mod ollama; pub mod openai; diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 30a53b5c54..7b6104d658 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -7,16 +7,13 @@ use super::utils::{ImageFormat, RequestLog}; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::config::GooseMode; use crate::conversation::message::Message; -use crate::conversation::Conversation; use crate::model::ModelConfig; use crate::providers::formats::ollama::{create_request, response_to_streaming_message_ollama}; -use crate::utils::safe_truncate; use anyhow::{Error, Result}; use async_stream::try_stream; use async_trait::async_trait; use futures::future::BoxFuture; use futures::TryStreamExt; -use regex::Regex; use reqwest::Response; use rmcp::model::Tool; use serde_json::{json, Value}; @@ -215,30 +212,6 @@ impl Provider for OllamaProvider { self.model.clone() } - async fn generate_session_name( - &self, - session_id: &str, - messages: &Conversation, - ) -> Result { - let context = self.get_initial_user_messages(messages); - let message = Message::user().with_text(self.create_session_name_prompt(&context)); - let model_config = self.get_model_config(); - let result = self - .complete( - &model_config, - session_id, - "You are a title generator. Output only the requested title of 4 words or less, with no additional text, reasoning, or explanations.", - &[message], - &[], - ) - .await?; - - let mut description = result.0.as_concat_text(); - description = Self::filter_reasoning_tokens(&description); - - Ok(safe_truncate(&description, 100)) - } - async fn stream( &self, model_config: &ModelConfig, @@ -318,46 +291,6 @@ impl Provider for OllamaProvider { } } -impl OllamaProvider { - fn filter_reasoning_tokens(text: &str) -> String { - let mut filtered = text.to_string(); - - let reasoning_patterns = [ - r".*?", - r".*?", - r"Let me think.*?\n", - r"I need to.*?\n", - r"First, I.*?\n", - r"Okay, .*?\n", - r"So, .*?\n", - r"Well, .*?\n", - r"Hmm, .*?\n", - r"Actually, .*?\n", - r"Based on.*?I think", - r"Looking at.*?I would say", - ]; - - for pattern in reasoning_patterns { - if let Ok(re) = Regex::new(pattern) { - filtered = re.replace_all(&filtered, "").to_string(); - } - } - filtered = filtered - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", ""); - filtered = filtered - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" "); - - filtered - } -} - /// Ollama-specific streaming handler with XML tool call fallback. /// Uses the Ollama format module which buffers text when XML tool calls are detected, /// preventing duplicate content from being emitted to the UI. diff --git a/crates/goose/tests/local_inference_integration.rs b/crates/goose/tests/local_inference_integration.rs new file mode 100644 index 0000000000..35002904a5 --- /dev/null +++ b/crates/goose/tests/local_inference_integration.rs @@ -0,0 +1,128 @@ +//! Integration tests for LocalInferenceProvider. +//! +//! These tests require a downloaded GGUF model and are ignored by default. +//! Run with: cargo test -p goose --test local_inference_integration -- --ignored + +use futures::StreamExt; +use goose::conversation::message::Message; +use goose::model::ModelConfig; +use goose::providers::create; +use std::time::Instant; + +const TEST_MODEL: &str = "llama-3.2-1b"; + +#[tokio::test] +#[ignore] +async fn test_local_inference_stream_produces_output() { + let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config"); + let provider = create("local", model_config.clone(), Vec::new()) + .await + .expect("provider creation should succeed"); + + let system = "You are a helpful assistant. Be brief."; + let messages = vec![Message::user().with_text("Say hello.")]; + + let mut stream = provider + .stream(&model_config, "test-session", system, &messages, &[]) + .await + .expect("stream should start"); + + let mut got_text = false; + let mut got_usage = false; + + while let Some(result) = stream.next().await { + let (msg, usage) = result.expect("stream item should be Ok"); + if msg.is_some() { + got_text = true; + } + if let Some(u) = usage { + got_usage = true; + let usage_inner = u.usage; + assert!( + usage_inner.input_tokens.unwrap_or(0) > 0, + "should have input tokens" + ); + assert!( + usage_inner.output_tokens.unwrap_or(0) > 0, + "should have output tokens" + ); + } + } + + assert!(got_text, "stream should produce at least one text message"); + assert!(got_usage, "stream should produce usage info"); +} + +#[tokio::test] +#[ignore] +async fn test_local_inference_cold_and_warm_performance() { + let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config"); + let provider = create("local", model_config.clone(), Vec::new()) + .await + .expect("provider creation should succeed"); + + // Cold start (includes model loading) + let messages = vec![Message::user().with_text("what is the capital of Moldova?")]; + let start = Instant::now(); + let (response, _usage) = provider + .complete(&model_config, "test-session", "", &messages, &[]) + .await + .expect("cold completion should succeed"); + let cold_elapsed = start.elapsed(); + + let text = response.as_concat_text(); + assert!(!text.is_empty(), "cold start should produce a response"); + println!( + "Cold start: {cold_elapsed:.2?}, response length: {}", + text.len() + ); + + // Warm run (model already loaded) + let messages2 = vec![Message::user().with_text("what is the capital of France?")]; + let start2 = Instant::now(); + let (response2, _usage2) = provider + .complete(&model_config, "test-session", "", &messages2, &[]) + .await + .expect("warm completion should succeed"); + let warm_elapsed = start2.elapsed(); + + let text2 = response2.as_concat_text(); + assert!(!text2.is_empty(), "warm run should produce a response"); + println!( + "Warm run: {warm_elapsed:.2?}, response length: {}", + text2.len() + ); + assert!( + warm_elapsed < cold_elapsed, + "warm run ({warm_elapsed:.2?}) should be faster than cold start ({cold_elapsed:.2?})" + ); +} + +#[tokio::test] +#[ignore] +async fn test_local_inference_large_prompt() { + let model_config = ModelConfig::new(TEST_MODEL).expect("valid model config"); + let provider = create("local", model_config.clone(), Vec::new()) + .await + .expect("provider creation should succeed"); + + // Build a large prompt (~3500 tokens) to exercise prefill performance + let padding = "You are Goose, a highly capable AI assistant.\n".repeat(80); + let prompt = format!("{padding}\nNow answer this: what is the capital of Moldova?"); + let messages = vec![Message::user().with_text(&prompt)]; + + let start = Instant::now(); + let (response, _usage) = provider + .complete(&model_config, "test-session", "", &messages, &[]) + .await + .expect("large prompt completion should succeed"); + let elapsed = start.elapsed(); + + let text = response.as_concat_text(); + assert!(!text.is_empty(), "large prompt should produce a response"); + println!( + "Large prompt: {elapsed:.2?}, prompt ~{} chars, response length: {}", + prompt.len(), + text.len() + ); +} diff --git a/documentation/blog/2026-02-19-gastown-explained-goosetown/goosetown.png b/documentation/blog/2026-02-19-gastown-explained-goosetown/goosetown.png new file mode 100644 index 0000000000..c4f8ec33fe Binary files /dev/null and b/documentation/blog/2026-02-19-gastown-explained-goosetown/goosetown.png differ diff --git a/documentation/blog/2026-02-19-gastown-explained-goosetown/index.md b/documentation/blog/2026-02-19-gastown-explained-goosetown/index.md new file mode 100644 index 0000000000..a30600d99a --- /dev/null +++ b/documentation/blog/2026-02-19-gastown-explained-goosetown/index.md @@ -0,0 +1,118 @@ +--- +title: "Gas Town Explained: How to Use Goosetown for Parallel Agentic Engineering" +description: "Learn how Gas Town and Goosetown lead the industrial coding revolution by teaching AI agents to work together in a team. This beginner guide explains the infrastructure we're using to move from talking to one AI to coordinating many agents at once." +authors: + - rizel + - tyler +--- + +![Goosetown](goosetown.png) + +On New Year's Day 2026, while many were recovering from the night before, a different kind of hangover took hold of every AI-pilled, chronically online software engineer. Steve Yegge published a new blog post: "[Welcome to Gas Town](https://steve-yegge.medium.com/welcome-to-gas-town-4f25ee16dd04)." Some walked away inspired to finally use their agents optimally; others were just plain confused. If you're like me, you felt a bit of both. + +Yegge's 34 minute post is a sprawling vision filled with futuristic ideas, playful characters, and enough side tangents to make your head spin. But underneath the lore is a massive architectural shift. I want to take a step back and simplify the "Big Idea" for everyone: Gas Town is a philosophy and a proof of concept to help people coordinate multiple agents working together. + + + +## The Paradigm Shift of Agentic Engineering + +Most people use AI agents sequentially. The workflow can look like this: + +* **1:00 PM:** You: "goose, build the API endpoint." +* *[Wait 10 minutes, check back.]* +* **1:10 PM:** You: "Now build the frontend." +* *[Wait 10 minutes, check back.]* +* **1:20 PM:** You: "Now write the tests." +* *[Wait 10 minutes, check back.]* +* **1:30 PM:** Project complete. + +You've built a project in 30 minutes, which is fast, but you spent most of that time just watching a progress bar. Some engineers started to realize that if we are running one agent, we can run another five at the same time. + +For example, Agent A builds the API, Agent B can start the frontend, Agent C can write tests, and Agent D can investigate a bug in that legacy codebase you've been avoiding. + +This is how people are buying their time back. They're getting entire sprints done in an hour by running parallel threads. (Just don't tell your boss because the reward for finishing work is always more work.) + +However, since agents don't communicate with each other, this approach introduces new problems: + +* **Merge conflicts**: Two agents change the same line in the same file and break everything. +* **Lost context**: Sessions crash or the agent starts hallucinating because it's been talking too long, and suddenly an hour of "work" vanishes. +* **Human bottleneck**: You end up constantly checking your phone at a party on the weekend or in bed to see if your agents are still on track making you a babysitter for agents. + +## Gas Town Explained + +Gas Town is designed to stop the babysitting. It coordinates the distribution of tasks among parallel agents so you don't have to. The system uses: + +* **Worktrees**: These automatically put each agent in its own separate workspace so they don't step on each other's toes. +* **Beads**: It uses beads to track progress. If a session crashes, the next agent session can pick up exactly where the last agent left off. +* **Communication**: Each agent reports aloud what it's up to or observing, so other agents gain the necessary context. + +This system also introduces a cast of characters: + +* **The Mayor**: your main agent interface that coordinates all the other agents +* **The Polecat(s)**: These are worker agents. They work on separate work trees, and take instruction from the Mayor. +* **The Witness**: Observes the worker agents, nudges them when they get stuck, and escalates issues to keep the system running + +I won't list every single character here (it gets deep), but the takeaway is: Gas Town creates a chain of command with a shared way to communicate. + +## Introducing Goosetown + +This is exactly the kind of futuristic thinking we're building toward at [goose](https://block.github.io/goose). So the goose team, specifically Tyler Longwell, built our own take on this called [Goosetown](https://github.com/block/goosetown). + +Goosetown is a multi-agent orchestration layer built on top of goose. Like Gas Town, it coordinates parallel agents. Unlike Gas Town, it's deliberately minimal and built for research-first parallel work. + +When you give Goosetown a task, the main agent acts as an Orchestrator, breaking the job into phases: research, build, and review. Then, it spawns parallel delegates to get it done. Each delegate communicates via a shared Town Wall, an append-only log where every agent posts what they're doing and what they've found. + +Here's a real Town Wall snippet from a session where parallel researchers converged on a pivot quickly: + +* **[10:14] researcher-api-auth** - 🚨 POTENTIAL SHOWSTOPPER: Service callers have EMPTY capabilities. Planned auth path will silently reject every request. This needs a code change, not just config. +* **[10:14] researcher-endpoints** - 💡 Found: native endpoint already exists with minimal deps. Alternative path viable. +* **[10:15] researcher-source** - ✅ Done. Confirmed: native path requires zero new dependencies. Recommending pivot. + +Goosetown operates on 4 components: [skills](/docs/guides/context-engineering/using-skills), [subagents](/docs/guides/subagents), [beads](https://github.com/steveyegge/beads), and a [gtwall](https://github.com/block/goosetown/blob/main/gtwall). + +### Skills + +[Skills](/docs/guides/context-engineering/using-skills) are Markdown files that describe how to do something like "how to deploy to production." Goosetown uses these to tell each Delegate how to do its specific job. When a Delegate spawns, it's "pre-loaded" with the skill for its role (Orchestrator, Researcher, Writer, Reviewer). + +### Subagents + +Instead of doing everything in one long conversation that eventually hits a "context cliff," Goosetown uses [subagents](/docs/guides/subagents), ephemeral agent instances. These are triggered by the [summon extension](/docs/mcp/summon-mcp), using `delegate()` to hand off work to a fresh agent instance. They do the work in their own clean context and return a summary, keeping your main session fast and focused. + +### Beads + +Goosetown uses [Beads](https://github.com/steveyegge/beads) to track progress so work survives crashes. It's a local issue tracker based on Git. The Orchestrator creates issues, delegates update them, and if a session fails, the next agent picks up the "bead" and continues the work. + +### gtwall + +[gtwall](https://github.com/block/goosetown/blob/main/gtwall) is an append-only log that delegates use to communicate and coordinate. All delegates post and read activity. + +## A Note from the Creator + +> *Goosetown started as a fun experiment to push the subagent upgrade I was working on for goose to its limits. I had spent some time with Gas Town and thought that a much less sprawling riff on it would be a whimsical way to show off background subagents and how good modern goose is at orchestrating and managing projects at a very high level. I was just totally surprised at how well it worked when I started using Goosetown as my daily driver.* +> +> *Watching the subagents chatter to each other, each one given a personality and task by the orchestrator, was eye-opening. And funny. They rubber-duck and go back and forth just like I do with my colleagues. Even just having one model getting to bounce ideas off itself automatically in the form of different agents with different contexts makes the output better.* +> +> *Running flocks (swarms) of agents is obviously expensive, but the overall quality of the work is higher and much less of my time is required to get it right. Definitely a tradeoff there. goose does help with this by allowing you to set the default subagent model to a less pricey one ahead of time and by allowing your main agent to select the models it wants to use for subagents explicitly, ad-hoc.* +> +> *There are always new and exciting features being added to goose. Refactors and refinements. For Goosetown, I'll continue to make its artifact (memory) system more robust, make communications inside Goosetown flow more smoothly, and keep it just a little silly.* +> +> — Tyler + +## Get Started + +Ready to try parallel agentic engineering for yourself? [Goosetown](https://github.com/block/goosetown) is open source and available on GitHub. Clone the [repo](https://github.com/block/goosetown), follow the setup instructions in the README, and you'll be orchestrating multiple agents in no time. If you're new to this workflow, watching the video below is a great way to see what a real session looks like before diving in. + + + + + + + + + + + + + + + diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 1298fa9757..97c448f22d 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -168,7 +168,7 @@ To configure your chosen provider, see available options, or select a model, vis │ ○ goose Settings └ ``` - 3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options. + 3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ``` ┌ goose-configure @@ -526,7 +526,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T └ ``` - 3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options. + 3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ```sh ┌ goose-configure @@ -1156,6 +1156,10 @@ Gemini 3 models support configurable thinking levels to balance response latency - **Low** (default) - Faster responses, lighter reasoning - **High** - Deeper reasoning, higher latency +:::tip +When thinking is enabled, you can view the model's reasoning process. See [Viewing Model Reasoning](#viewing-model-reasoning) for details. +::: + When selecting a Gemini 3 model, a "Thinking Level" dropdown appears automatically. Select your preference and the setting persists across sessions. @@ -1181,6 +1185,41 @@ The thinking level is determined in this order (highest to lowest priority): 3. Default value: `low` ::: +## Viewing Model Reasoning + +Some models expose their internal reasoning or "chain of thought" as part of their response. goose automatically captures this reasoning output and makes it available to you. The following models and providers support reasoning output: + +| Provider / Model | How It Works | +|---|---| +| **DeepSeek-R1** (via OpenAI, Ollama, OpenRouter, OVHcloud, etc.) | Reasoning captured from the `reasoning_content` field in the API response | +| **Kimi** (via Groq or other OpenAI-compatible endpoints) | Reasoning captured from the `reasoning_content` field in the API response | +| **Gemini CLI** (Google Gemini models with thinking enabled) | Thinking blocks captured from the streaming response | +| **Claude** (Anthropic, with [extended thinking](/docs/guides/environment-variables#claude-extended-thinking) enabled) | Thinking blocks captured from the API response | + + + + Reasoning output appears automatically in a collapsible **"Show reasoning"** toggle above the model's response. Click it to expand and view the model's thought process. + + + + Reasoning output is **hidden by default** in the CLI. To display it, set the `GOOSE_CLI_SHOW_THINKING` environment variable: + + ```bash + export GOOSE_CLI_SHOW_THINKING=1 + ``` + + When enabled, reasoning appears under a "Thinking:" header in dimmed text before the model's main response. + + :::note + This requires stdout to be a terminal (reasoning output won't appear when piping output to a file or another command). + ::: + + + +:::tip +Reasoning output can be useful for understanding how the model arrived at its answer, debugging unexpected behavior, or learning from the model's problem-solving approach. However, it can also be verbose — toggle it on only when you need it. +::: + --- If you have any questions or need help with a specific provider, feel free to reach out to us on [Discord](https://discord.gg/goose-oss) or on the [goose repo](https://github.com/block/goose). diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 43b4d80155..64a1e9f325 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -188,6 +188,10 @@ export CLAUDE_THINKING_ENABLED=1 export CLAUDE_THINKING_BUDGET=8000 ``` +:::tip Viewing Thinking Output +To see Claude's thinking output in the **CLI**, you also need to set `GOOSE_CLI_SHOW_THINKING=1`. In **goose Desktop**, thinking output is shown automatically in a collapsible "Show reasoning" toggle. +::: + ### Planning Mode Configuration These variables control goose's [planning functionality](/docs/guides/creating-plans). @@ -262,6 +266,7 @@ These variables control how goose manages conversation sessions and context. | `GOOSE_CLI_LIGHT_THEME` | Custom [bat theme](https://github.com/sharkdp/bat#adding-new-themes) for syntax highlighting when using light mode | bat theme name (e.g., "Solarized (light)", "OneHalfLight") | "GitHub" | | `GOOSE_CLI_DARK_THEME` | Custom [bat theme](https://github.com/sharkdp/bat#adding-new-themes) for syntax highlighting when using dark mode | bat theme name (e.g., "Dracula", "Nord") | "zenburn" | | `GOOSE_CLI_NEWLINE_KEY` | Customize the keyboard shortcut for [inserting newlines in CLI input](/docs/guides/goose-cli-commands#keyboard-shortcuts) | Single character (e.g., "n", "m") | "j" (Ctrl+J) | +| `GOOSE_CLI_SHOW_THINKING` | Shows model reasoning/thinking output in CLI responses. Some models (e.g., DeepSeek-R1, Kimi, Gemini) expose their internal reasoning process — this variable makes it visible in the CLI. | Set to any value to enable | Disabled | | `GOOSE_RANDOM_THINKING_MESSAGES` | Controls whether to show amusing random messages during processing | "true", "false" | "true" | | `GOOSE_CLI_SHOW_COST` | Toggles display of model cost estimates in CLI output | "1", "true" (case-insensitive) to enable | false | | `GOOSE_AUTO_COMPACT_THRESHOLD` | Set the percentage threshold at which goose [automatically summarizes your session](/docs/guides/sessions/smart-context-management#automatic-compaction). | Float between 0.0 and 1.0 (disabled at 0.0) | 0.8 | @@ -313,6 +318,9 @@ export GOOSE_CLI_NEWLINE_KEY=n # Disable random thinking messages for less distraction export GOOSE_RANDOM_THINKING_MESSAGES=false +# Show reasoning/thinking output from models that support it (e.g., DeepSeek-R1, Kimi, Gemini) +export GOOSE_CLI_SHOW_THINKING=1 + # Enable model cost display in CLI export GOOSE_CLI_SHOW_COST=true @@ -460,6 +468,14 @@ When the keyring is disabled (or cannot be accessed and goose [falls back to fil * Windows: `%APPDATA%\Block\goose\config\secrets.yaml` ::: +### macOS Sandbox for goose Desktop + +Optional [macOS sandbox](/docs/guides/sandbox) for goose Desktop that restricts file access, network connections, and process execution using Apple's `sandbox-exec` technology. + +| Variable | Purpose | Values | Default | +|----------|---------|--------|---------| +| `GOOSE_SANDBOX` | Enable the sandbox with [customizable security controls](/docs/guides/sandbox#configuration) | `true` or `1` to enable | `false` | + ## Network Configuration These variables configure network proxy settings for goose. @@ -611,14 +627,27 @@ These variables are automatically set by goose during command execution. | Variable | Purpose | Values | Default | |----------|---------|---------|---------| | `GOOSE_TERMINAL` | Indicates that a command is being executed by goose, enables [customizing shell behavior](#customizing-shell-behavior) | "1" when set | Unset | +| `AGENT` | Generic agent identifier for cross-tool compatibility, enables tools and scripts to detect when they're being run by goose | "goose" when set | Unset | | `AGENT_SESSION_ID` | The current session ID for [session-isolated workflows](#using-session-ids-in-workflows), automatically available to STDIO extensions and the Developer extension shell commands | Session ID string (e.g., `20260217_5`) | Unset (only set in extension/shell contexts) | ### Customizing Shell Behavior -Sometimes you want goose to use different commands or have different shell behavior than your normal terminal usage. For example, you might want goose to use a different tool, prevent goose from running `git commit`, or block long-running development servers that could hang the AI agent. This is most useful when using goose CLI, where shell commands are executed directly in your terminal environment. +Sometimes you want goose to use different commands or have different shell behavior than your normal terminal usage. Common use cases include: +- Skipping expensive shell initialization (e.g. syntax highlighting, custom prompts) +- Blocking interactive commands that would hang the agent (e.g., `git commit`) +- Redirecting to agent-friendly tools (e.g., `rg` instead of `find`) +- Building cross-agent tools and scripts that detect AI agent execution +- Integrating with MCP servers and LLM gateways + +This is most useful when using goose CLI, where shell commands are executed directly in your terminal environment. **How it works:** -1. When goose runs commands, `GOOSE_TERMINAL` is automatically set to "1" + +goose provides the `GOOSE_TERMINAL` and `AGENT` variables you can use to detect whether goose is the executing agent. + +1. When goose runs commands: + - `GOOSE_TERMINAL` is automatically set to "1" + - `AGENT` is automatically set to "goose" 2. Your shell configuration can detect this and change behavior while keeping your normal terminal usage unchanged **Examples:** @@ -645,6 +674,17 @@ if [[ -n "$GOOSE_TERMINAL" ]]; then fi ``` +```bash +# Detect AI agent execution using standard naming convention +if [[ -n "$AGENT" ]]; then + echo "Running under AI agent: $AGENT" + # Apply agent-specific behavior if needed + if [[ "$AGENT" == "goose" ]]; then + echo "Detected goose - applying goose-specific settings" + fi +fi +``` + ### Using Session IDs in Workflows STDIO extensions (local extensions that communicate via standard input/output) and the Developer extension's shell commands automatically receive the `AGENT_SESSION_ID` environment variable. This enables you to create session-isolated workflows and make it easier to: @@ -663,6 +703,12 @@ echo "Results from step 1" > ~/Desktop/${AGENT_SESSION_ID}/handoff/output.txt cat ~/Desktop/${AGENT_SESSION_ID}/handoff/output.txt ``` +## Environment Variable Passthrough + +The Developer extension's `shell` tool inherits environment variables from your session. This enables workflows that depend on environment configuration, such as authenticated CLI operations and build processes. + +See [Environment Variables in Shell Commands](/docs/mcp/developer-mcp#environment-variables-in-shell-commands) for details. + ## Enterprise Environments When deploying goose in enterprise environments, administrators might need to control behavior and infrastructure, or enforce consistent settings across teams. The following environment variables are commonly used: diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index c893a75278..50d026632c 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -45,6 +45,10 @@ Configure goose settings - providers, extensions, etc. goose configure ``` +:::tip Type to Filter +When selecting from menus in `goose configure`, start typing to filter options in real-time. This works for lists of providers, extensions, and tools. +::: + --- #### info [options] diff --git a/documentation/docs/guides/sandbox.md b/documentation/docs/guides/sandbox.md index 71b1d77fb8..8d185f9fd9 100644 --- a/documentation/docs/guides/sandbox.md +++ b/documentation/docs/guides/sandbox.md @@ -1,83 +1,116 @@ -# macOS Sandbox for goosed +--- +title: macOS Sandbox for goose Desktop +sidebar_label: Sandbox for goose Desktop +description: Optional sandboxing for goose Desktop to control file access, filter network traffic, and enforce security policies on macOS +--- -goose includes an optional macOS sandbox that restricts the goosed process using Apple's seatbelt (`sandbox-exec`) and routes all network traffic through a local egress proxy. This limits what the agent can do on your system — blocking sensitive file writes, raw sockets, tunneling tools, and unapproved network destinations. +goose Desktop includes an optional macOS sandbox that you can enable when you need stricter control and visibility over what goose can access on your system. Use it to: -> **Requirements:** macOS only. The sandbox relies on `/usr/bin/sandbox-exec` which is only available on macOS. +- **Restrict file system access** — Block writes to SSH keys, shell configs, and goose configuration files +- **Control network connections** — Force all traffic through a filtering proxy that blocks unapproved domains +- **Prevent security bypasses** — Block tunneling tools, raw sockets, and other techniques that could circumvent restrictions +- **Audit and enforce policies** — Log all network activity and enforce compliance requirements + +goose runs with full tool access, but the sandbox uses two layers of protection: +- **File access control** - Apple's `sandbox-exec` restricts file and network access at the system level +- **Outbound connections** - A local egress proxy filters and logs outgoing connections + +:::info macOS Requirement +The sandbox relies on `/usr/bin/sandbox-exec`, which is only available on macOS and is also known as Apple's seatbelt technology. +::: ## Quick Start -Set the environment variable before launching the goose desktop app: +To enable the sandbox, launch goose Desktop from the terminal with the environment variable set. For example: ```bash -GOOSE_SANDBOX=true +export GOOSE_SANDBOX=true +open -a Goose ``` -Then start the desktop app as normal. goose will: +When the app starts with sandboxing enabled, it will: 1. Generate a seatbelt sandbox profile 2. Start a local HTTP CONNECT proxy on localhost -3. Launch goosed inside `sandbox-exec`, forcing all traffic through the proxy +3. Launch the `goosed` backend for goose Desktop inside `sandbox-exec`, forcing all traffic through the proxy -If `sandbox-exec` is not available (e.g. you're on Linux), goose will fail fast with a clear error rather than running unsandboxed. - -## What Gets Restricted - -### File System (seatbelt) - -By default, the sandbox blocks writes to: - -| Path | Purpose | -|------|---------| -| `~/.ssh/` | Prevent SSH key tampering | -| `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` | Prevent shell config injection | -| `~/.config/goose/sandbox/` | Protect sandbox config from the sandboxed process | -| `~/.config/goose/config.yaml` | Protect goose config | - -### Network (seatbelt) - -All direct network access is denied. The only allowed paths are: - -- **Localhost** — so the process can reach the egress proxy and its own server port -- **Unix sockets** — for local IPC -- **mDNSResponder** — for DNS resolution - -Everything else must go through the proxy. - -### Process Restrictions (seatbelt) - -- **Tunneling tools blocked:** `nc`, `ncat`, `netcat`, `socat`, `telnet` — prevents the agent from bypassing the proxy -- **Raw sockets blocked:** `SOCK_RAW` on `AF_INET`/`AF_INET6` — prevents raw packet crafting -- **Kernel extensions blocked:** `system-kext-load` denied - -### Network (proxy) - -The egress proxy checks connections in this order: - -1. **Loopback detection** — prevents using the proxy as a relay back to localhost -2. **Raw IP blocking** — connections to bare IP addresses (no domain) are blocked -3. **Domain blocklist** — domains listed in `blocked.txt` are denied (including all subdomains) -4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts -5. **LaunchDarkly allowlist** (optional) — dynamic egress control via feature flag +The sandbox remains active until you quit goose Desktop. To disable it, quit the app and relaunch normally (or set `GOOSE_SANDBOX=false` when opening from the terminal). ## Configuration -All configuration is via environment variables. Defaults are designed to be secure out of the box. +All configuration is via environment variables. Defaults are designed to be secure out of the box, but you can adjust them to match your security requirements. ### Core | Variable | Default | Description | |----------|---------|-------------| -| `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox | +| `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox. See [Quick Start](#quick-start) for launch instructions. | -### Seatbelt Profile +---- + +### File System + +The [seatbelt sandbox profile](https://github.com/block/goose/blob/main/ui/desktop/src/sandbox/index.ts) blocks write operations to these sensitive files: + +- `~/.ssh/` - Prevent SSH key tampering +- `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` - Prevent shell config injection +- `~/.config/goose/sandbox/` - Protect sandbox config from the sandboxed process +- `~/.config/goose/config.yaml` - Protect goose config + + +#### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect sensitive files listed above. Set to `false` to disable | + +---- + +### Direct Network Access + +The seatbelt sandbox denies all direct network access, forcing traffic through the proxy. The only allowed connections are: + +- **Localhost** — Allows the `goosed` process to reach the egress proxy and its own server port +- **Unix sockets** — For local inter-process communication (IPC) +- **mDNSResponder** — For DNS resolution + +:::info Not Configurable +These restrictions are always active when the sandbox is enabled. +::: + +---- + +### Process Restrictions + +The seatbelt sandbox blocks tools and system calls that could bypass security controls: + +- **Tunneling tools** — `nc`, `ncat`, `netcat`, `socat`, `telnet` are blocked to prevent bypassing the proxy +- **Raw sockets** — `SOCK_RAW` on `AF_INET`/`AF_INET6` is blocked to prevent raw packet crafting +- **Kernel extensions** — `system-kext-load` is denied + +#### Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect `~/.ssh` and shell configs. Set to `false` to disable | | `GOOSE_SANDBOX_BLOCK_RAW_SOCKETS` | `true` | Block `SOCK_RAW`. Set to `false` to disable | | `GOOSE_SANDBOX_BLOCK_TUNNELING` | `true` | Block `nc`/`netcat`/`socat`/`telnet`. Set to `false` to disable | -### Proxy +---- + +### Network Filtering + +The egress proxy inspects and filters all outgoing connections. You can customize filtering rules through the blocklist file and configuration variables. + +The egress proxy checks connections in this order: + +1. **Loopback detection** — Prevents using the proxy as a relay back to localhost +2. **Raw IP blocking** — Connections to bare IP addresses (no domain) are blocked +3. **Domain blocklist** — Domains listed in `blocked.txt` are denied (including all subdomains) +4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts + +For optional LaunchDarkly-based egress control, see [LaunchDarkly](#launchdarkly-optional). + +#### Environment Variables | Variable | Default | Description | |----------|---------|-------------| @@ -87,16 +120,7 @@ All configuration is via environment variables. Defaults are designed to be secu | `GOOSE_SANDBOX_GIT_HOSTS` | built-in list | Comma-separated list of allowed SSH git hosts (e.g. `github.com,gitlab.com`) | | `GOOSE_SANDBOX_SSH_ALL_HOSTS` | `false` | Set to `true` to allow SSH to any host (not just git hosts) | -### LaunchDarkly (optional — not required) - -LaunchDarkly is **not required**. The sandbox works fully without it using the local `blocked.txt` blocklist. These settings only apply if your organization uses LaunchDarkly for dynamic egress control. - -| Variable | Default | Description | -|----------|---------|-------------| -| `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control | -| `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` | - -## Domain Blocklist +#### Managing the Domain Blocklist The file `~/.config/goose/sandbox/blocked.txt` controls which domains are blocked by the proxy. It's created automatically on first run from a bundled template. @@ -109,13 +133,15 @@ transfer.sh webhook.site ``` -**Live reload:** Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed. +:::tip Live Reload +Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed. +::: -## SSH and Git +#### Using Git Over SSH -SSH git operations (`git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules. +SSH git operations (e.g. `git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules. -By default, SSH is only allowed to well-known git hosting domains (GitHub, GitLab, Bitbucket, etc.). To customise: +By default, SSH is only allowed to well-known git hosting domains (e.g. GitHub, GitLab, Bitbucket). To customize: ```bash # Add custom git hosts @@ -125,6 +151,19 @@ export GOOSE_SANDBOX_GIT_HOSTS="github.com,gitlab.com,your-gitea.internal.com" export GOOSE_SANDBOX_SSH_ALL_HOSTS=true ``` +---- + +### LaunchDarkly (Optional) + +For enterprise environments, LaunchDarkly provides optional dynamic egress control. If not configured, the sandbox uses the local `blocked.txt` blocklist. + +#### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control | +| `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` | + ## Example Configurations ### Maximum security @@ -148,7 +187,7 @@ export GOOSE_SANDBOX=true export GOOSE_SANDBOX_ALLOW_SSH=false ``` -### Relaxed mode (sandbox on, fewer restrictions) +### Relaxed mode (fewer restrictions) ```bash export GOOSE_SANDBOX=true @@ -169,14 +208,15 @@ export GOOSE_SANDBOX_LD_FAILOVER=blocklist # fall back to local blocklist if LD ## Troubleshooting -**"GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"** -You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS. +- **Error: "GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"** + You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS. -**Extensions or tools can't reach the network** -Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints. +- **Extensions or tools can't reach the network** + Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints. -**Git clone over SSH fails** -The target host may not be in the default git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`. +- **git clone over SSH fails** + The target host may not be in the default Git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`. + +- **Want to inspect what the proxy is blocking?** + Check the [Desktop application logs](/docs/guides/logs#desktop-application-log). Blocked connections are logged with the prefix `[sandbox-proxy]` and include the reason for blocking. -**Want to inspect what the proxy is blocking?** -Check the Electron/goosed logs — blocked connections are logged with the reason. diff --git a/documentation/docs/guides/security/index.mdx b/documentation/docs/guides/security/index.mdx index 1905516d36..13e83a0da1 100644 --- a/documentation/docs/guides/security/index.mdx +++ b/documentation/docs/guides/security/index.mdx @@ -25,6 +25,11 @@ import styles from '@site/src/components/Card/styles.module.css'; description="API specification for self-hosting ML-based prompt injection detection endpoints." link="/docs/guides/security/classification-api-spec" /> + diff --git a/documentation/docs/guides/sessions/session-management.md b/documentation/docs/guides/sessions/session-management.md index 701a690cec..d5d112615a 100644 --- a/documentation/docs/guides/sessions/session-management.md +++ b/documentation/docs/guides/sessions/session-management.md @@ -182,35 +182,22 @@ Search allows you to find specific content within sessions or find specific sess 2. Enter your search term 3. Use shortcuts and search bar buttons to navigate the results - #### Search For Session By Name or Path + #### Search Across Sessions - To search all your sessions by name or working directory path: + To search conversation history across all your sessions: 1. Click the button in the top-left to open the sidebar 2. Click `View All` at the bottom of the `Chat` section - 3. Use `Cmd+F` to open the search bar + 3. Use `Cmd+F` (or `Ctrl+F`) to open the search bar 4. Enter your search term 5. Use keyboard shortcuts and search bar buttons to navigate the results (`Cmd+E` not supported) - This is a metadata-only search. It doesn't search conversation content. Note that searching by session ID (e.g. `20251108_1`) is supported, but this property isn't displayed in the UI. - - :::tip - You can [rename sessions](#name-session) to give them descriptive names that you'll remember later. - ::: - - #### Search Across All Session Content - - To search conversation content across all your sessions, ask goose directly in any chat session. For example: + This searches the content of messages in your conversations. The search is limited to the 10 most recent matching messages across sessions. If your search term appears in many messages, the search will only return a subset of sessions. + :::tip Ask goose directly + You can also use the built-in [Chat Recall extension](/docs/mcp/chatrecall-mcp) to ask goose to search your conversation history: - "Find my earlier conversation about React hooks from last week" - "Show me sessions where I worked on database migrations" - - goose will search your session history and show relevant conversations with context from matching sessions. - - :::info - This functionality requires the built-in [Chatrecall extension](/docs/mcp/chatrecall-mcp) to be enabled. You can enable it manually or, if [Extension Manager](/docs/mcp/extension-manager-mcp) is enabled, simply ask goose to use it: - - - "Use chatrecall to find the latest status of the database migration project" ::: diff --git a/documentation/docs/mcp/developer-mcp.md b/documentation/docs/mcp/developer-mcp.md index a200c30e52..c7bed24457 100644 --- a/documentation/docs/mcp/developer-mcp.md +++ b/documentation/docs/mcp/developer-mcp.md @@ -172,6 +172,23 @@ The Developer extension provides these tools: | `screen_capture` | Take screenshots | Debugging UI issues, documenting state | ✅ Low
Visual information only | | `image_processor` | Process and resize images | Optimizing assets, format conversion | ✅ Low
Image manipulation only | +### Environment Variables in Shell Commands + +Shell commands executed by the `shell` tool inherit the environment of the running goose process. This typically includes: +- System variables like `PATH`, `HOME`, and `USER` +- Environment variables present in the process that launched goose (for example, your terminal's environment when you start goose from a shell) +- Session-specific variables injected by goose, such as `AGENT_SESSION_ID` for [session-isolated workflows](/docs/guides/environment-variables#using-session-ids-in-workflows) + +This enables workflows that depend on environment configuration, such as authenticated CLI operations and build processes. + +:::info +goose Desktop or launcher-based starts may use a different environment and may not load your shell startup files. +::: + +:::warning Sensitive Information +Environment variables may contain sensitive values like API keys and tokens (e.g., `GITHUB_TOKEN`, `AWS_ACCESS_KEY_ID`). +::: + ### Access Control Features You can layer multiple controls to match your risk tolerance and workflow: @@ -222,4 +239,4 @@ As you become more comfortable with goose's behavior, you can adjust these setti :::info Also see the [Security Guide](/docs/guides/security/) for information about using goose safely. -::: \ No newline at end of file +::: diff --git a/documentation/docs/mcp/neighborhood-mcp.md b/documentation/docs/mcp/neighborhood-mcp.md new file mode 100644 index 0000000000..6024d0e593 --- /dev/null +++ b/documentation/docs/mcp/neighborhood-mcp.md @@ -0,0 +1,237 @@ +--- +title: Neighborhood Extension +description: Add Neighborhood as a goose Extension +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller'; +import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; + +This tutorial covers how to add the Neighborhood extension to goose, enabling you to discover nearby restaurants, browse menus, and place takeout orders through natural conversation. Sellers are US-based. + +:::tip TLDR + + + [Launch the installer](goose://extension?type=streamable_http&url=https%3A%2F%2Fconnect.squareup.com%2Fv2%2Fmcp%2Fneighborhood&id=neighborhood&name=Neighborhood&description=Discover%20nearby%20restaurants%2C%20browse%20menus%2C%20and%20place%20takeout%20orders%20through%20natural%20conversation.) + + + + 1. Run the `configure` command: + + ```sh + goose configure + ``` + + 2. Choose to add a `Remote Extension` + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◆ What type of extension would you like to add? + │ ○ Built-in Extension + │ ○ Command-line Extension (Run a local command or script) + // highlight-start + │ ● Remote Extension + // highlight-end + └ + ``` + + 3. Give your extension a name + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◇ What type of extension would you like to add? + │ Remote Extension + │ + // highlight-start + ◆ What would you like to call this extension? + │ Neighborhood + // highlight-end + └ + ``` + + 4. Enter the remote endpoint URL + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◇ What type of extension would you like to add? + │ Remote Extension + │ + ◇ What would you like to call this extension? + │ Neighborhood + │ + // highlight-start + ◆ What is the SSE endpoint URI? + │ https://connect.squareup.com/v2/mcp/neighborhood + // highlight-end + └ + ``` + + 5. Enter the number of seconds goose should wait for actions to complete before timing out. Default is 300s + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◇ What type of extension would you like to add? + │ Remote Extension + │ + ◇ What would you like to call this extension? + │ Neighborhood + │ + ◇ What is the SSE endpoint URI? + │ https://connect.squareup.com/v2/mcp/neighborhood + │ + // highlight-start + ◆ Please set the timeout for this tool (in secs): + │ 300 + // highlight-end + └ + ``` + + 6. Add a description if you want, otherwise select No to leave it blank. + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◇ What type of extension would you like to add? + │ Remote Extension + │ + ◇ What would you like to call this extension? + │ Neighborhood + │ + ◇ What is the SSE endpoint URI? + │ https://connect.squareup.com/v2/mcp/neighborhood + │ + ◇ Please set the timeout for this tool (in secs): + │ 300 + │ + // highlight-start + ◆ Would you like to add a description? + │ ○ Yes / ● No + // highlight-end + │ + └ + ``` + + 7. No environment variables are needed, so select No. + + ```sh + ┌ goose-configure + │ + ◇ What would you like to configure? + │ Add Extension (Connect to a new extension) + │ + ◇ What type of extension would you like to add? + │ Remote Extension + │ + ◇ What would you like to call this extension? + │ Neighborhood + │ + ◇ What is the SSE endpoint URI? + │ https://connect.squareup.com/v2/mcp/neighborhood + │ + ◇ Please set the timeout for this tool (in secs): + │ 300 + │ + ◇ Would you like to add a description? + │ No + │ + // highlight-start + ◆ Would you like to add environment variables? + │ ○ Yes / ● No + // highlight-end + └ Added Neighborhood extension + ``` + + + +::: + +## Configuration + + + + + + + + + + +## Example Usage + +Neighborhood is most powerful when goose combines it with context from your day — your schedule, your goals, your dietary needs. Instead of just searching for food, you can ask goose to reason about what to eat based on what you're doing. + +### Finding a meal that fits your activity + +#### goose Prompt +``` +Use the neighborhood extension for this. I'm looking to get lunch today. +I'm at 375 W Broadway in New York. I'm playing tennis later tonight. +Could you suggest some options so I can play well? +``` + +#### goose Output + +``` +Here are some restaurants near 375 W Broadway in SoHo! For pre-tennis fuel, +I'd recommend these options: + +Best picks for athletic performance: + +Kale & Things - Perfect for a healthy, nutrient-dense meal with greens and +lean proteins to give you sustained energy without feeling heavy + +Simply Natural Grill House NY (684 Broadway) - Great for grilled proteins +and wholesome meals that'll fuel your game + +Ba'al Falafel (71 Sullivan St) - Mediterranean food with falafel, hummus, +and fresh veggies provides good carbs and protein + +Tips for eating before tennis: + +- Eat 2-3 hours before playing to allow digestion +- Focus on complex carbs + lean protein (avoid heavy, fatty foods) +- Stay hydrated - consider grabbing a drink from Steam & Sip or Drip Drop Café + +Would you like me to pull up the menu for any of these restaurants so you can +see what they offer? Kale & Things sounds like it could be ideal for your +pre-match meal! +``` + +### More Prompt Ideas + +Try combining Neighborhood with other goose extensions for even more useful workflows: + +- **Pair with your calendar:** *"Check my calendar for today and find somewhere I can grab a quick pickup lunch before my 1pm meeting. I'm near Union Square."* +- **Track what you eat:** *"Order me a chicken bowl from that place on Sullivan St, and log the macros to my food diary."* +- **Plan for a group:** *"I'm meeting 3 friends near Bryant Park tonight. Find somewhere with good vegetarian options that's not too loud."* diff --git a/documentation/src/pages/skills/detail.tsx b/documentation/src/pages/skills/detail.tsx index bf46ca1004..fcde0c8bd8 100644 --- a/documentation/src/pages/skills/detail.tsx +++ b/documentation/src/pages/skills/detail.tsx @@ -227,7 +227,7 @@ export default function SkillDetailPage(): JSX.Element {

- Requires Goose Skills extension enabled + Requires Summon extension enabled

diff --git a/documentation/static/servers.json b/documentation/static/servers.json index 3c0c6f8597..f3f7939d74 100644 --- a/documentation/static/servers.json +++ b/documentation/static/servers.json @@ -756,5 +756,17 @@ "is_builtin": false, "endorsed": false, "environmentVariables": [] + }, + { + "id": "neighborhood", + "name": "Neighborhood", + "description": "Discover nearby restaurants, browse menus, and place takeout orders through natural conversation. Sellers are US-based.", + "type": "streamable-http", + "url": "https://connect.squareup.com/v2/mcp/neighborhood", + "link": "", + "installation_notes": "No local setup required. This extension connects to a remote service. Use the install button or run the command below.", + "is_builtin": false, + "endorsed": true, + "environmentVariables": [] } ] diff --git a/ui/acp/generate-schema.ts b/ui/acp/generate-schema.ts index ee63176c2d..909542f842 100644 --- a/ui/acp/generate-schema.ts +++ b/ui/acp/generate-schema.ts @@ -28,7 +28,6 @@ main().catch((err) => { async function main() { const schemaSrc = await fs.readFile(SCHEMA_PATH, "utf8"); const jsonSchema = JSON.parse( - // Convert JSON Schema $defs refs to OpenAPI component refs schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"), ); @@ -63,7 +62,6 @@ async function main() { async function postProcessTypes() { const tsPath = resolve(OUTPUT_DIR, "types.gen.ts"); let src = await fs.readFile(tsPath, "utf8"); - // Remove the ClientOptions type block injected by @hey-api (not part of our schema) src = src.replace(/\nexport type ClientOptions =[\s\S]*?^};\n/m, "\n"); await fs.writeFile(tsPath, src); } @@ -72,17 +70,14 @@ async function postProcessIndex(meta: { methods: unknown[] }) { const indexPath = resolve(OUTPUT_DIR, "index.ts"); let src = await fs.readFile(indexPath, "utf8"); - // Strip ClientOptions from re-exports src = src.replace(/,?\s*ClientOptions\s*,?/g, (match) => { if (match.startsWith(",") && match.endsWith(",")) return ","; if (match.startsWith(",")) return ""; return ""; }); - // Fix bare relative imports to use .js extensions (required by nodenext consumers) src = fixRelativeImports(src); - // Append method constants const methodConstants = await prettier.format( ` export const GOOSE_EXT_METHODS = ${JSON.stringify(meta.methods, null, 2)} as const; @@ -94,7 +89,6 @@ export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; await fs.writeFile(indexPath, `${src}\n${methodConstants}`); - // Also fix imports in zod.gen.ts (it may import from types.gen) for (const file of ["zod.gen.ts", "types.gen.ts"]) { const filePath = resolve(OUTPUT_DIR, file); try { @@ -127,9 +121,6 @@ interface MethodMeta { responseType: string | null; } -/** - * Convert a method path like "session/list" or "working_dir/update" to camelCase "sessionList", "workingDirUpdate". - */ function methodToCamelCase(method: string): string { return method .split(/[/_]/) @@ -139,10 +130,6 @@ function methodToCamelCase(method: string): string { .join(""); } -/** - * Generate a typed GooseClient class that wraps ClientSideConnection.extMethod() - * with proper TypeScript types and Zod runtime validation. - */ async function generateClient(meta: { methods: MethodMeta[] }) { const typeImports = new Set(); const zodImports = new Set(); @@ -153,7 +140,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) { const fnName = methodToCamelCase(m.method); const fullMethod = `_goose/${m.method}`; - // Build param type and arg let paramType = ""; let paramArg = ""; let callParams = "{}"; @@ -164,7 +150,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) { callParams = "params"; } - // Build return type and validation let returnType: string; let bodyLines: string[]; @@ -183,7 +168,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) { `await this.conn.extMethod("${fullMethod}", ${callParams});`, ]; } else { - // Both request and response are untyped (serde_json::Value) returnType = "Record"; bodyLines = [ `return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`, @@ -212,11 +196,7 @@ export interface ExtMethodProvider { ${typeImportLine} ${zodImportLine} -/** - * Typed client for Goose custom extension methods. - * Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation. - */ -export class GooseClient { +export class GooseExtClient { constructor(private conn: ExtMethodProvider) {} ${methodDefs.join("\n")} } diff --git a/ui/acp/src/generated/client.gen.ts b/ui/acp/src/generated/client.gen.ts index db0ae5eb21..8de00f5a1e 100644 --- a/ui/acp/src/generated/client.gen.ts +++ b/ui/acp/src/generated/client.gen.ts @@ -39,7 +39,7 @@ import { * Typed client for Goose custom extension methods. * Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation. */ -export class GooseClient { +export class GooseExtClient { constructor(private conn: ExtMethodProvider) {} async extensionsAdd(params: AddExtensionRequest): Promise { @@ -98,32 +98,4 @@ export class GooseClient { const raw = await this.conn.extMethod("_goose/config/extensions", {}); return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse; } - - async toolCall(): Promise> { - return await this.conn.extMethod("_goose/tool/call", {}); - } - - async providerUpdate(): Promise> { - return await this.conn.extMethod("_goose/provider/update", {}); - } - - async containerSet(): Promise> { - return await this.conn.extMethod("_goose/container/set", {}); - } - - async appsList(): Promise> { - return await this.conn.extMethod("_goose/apps/list", {}); - } - - async appsExport(): Promise> { - return await this.conn.extMethod("_goose/apps/export", {}); - } - - async appsImport(): Promise> { - return await this.conn.extMethod("_goose/apps/import", {}); - } - - async configProviders(): Promise> { - return await this.conn.extMethod("_goose/config/providers", {}); - } } diff --git a/ui/acp/src/generated/index.ts b/ui/acp/src/generated/index.ts index b44b568427..2f6a85b441 100644 --- a/ui/acp/src/generated/index.ts +++ b/ui/acp/src/generated/index.ts @@ -58,41 +58,6 @@ export const GOOSE_EXT_METHODS = [ requestType: null, responseType: "GetExtensionsResponse", }, - { - method: "tool/call", - requestType: null, - responseType: null, - }, - { - method: "provider/update", - requestType: null, - responseType: null, - }, - { - method: "container/set", - requestType: null, - responseType: null, - }, - { - method: "apps/list", - requestType: null, - responseType: null, - }, - { - method: "apps/export", - requestType: null, - responseType: null, - }, - { - method: "apps/import", - requestType: null, - responseType: null, - }, - { - method: "config/providers", - requestType: null, - responseType: null, - }, ] as const; export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; diff --git a/ui/acp/src/goose-client.ts b/ui/acp/src/goose-client.ts new file mode 100644 index 0000000000..f64a190329 --- /dev/null +++ b/ui/acp/src/goose-client.ts @@ -0,0 +1,118 @@ +import { + ClientSideConnection, + type Client, + type Stream, + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type PromptRequest, + type PromptResponse, + type CancelNotification, + type AuthenticateRequest, + type AuthenticateResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type ForkSessionRequest, + type ForkSessionResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, +} from "@agentclientprotocol/sdk"; +import { GooseExtClient } from "./generated/client.gen.js"; + +export class GooseClient { + private conn: ClientSideConnection; + private ext: GooseExtClient; + + constructor(toClient: () => Client, stream: Stream) { + this.conn = new ClientSideConnection(toClient, stream); + this.ext = new GooseExtClient(this.conn); + } + + get signal(): AbortSignal { + return this.conn.signal; + } + + get closed(): Promise { + return this.conn.closed; + } + + initialize(params: InitializeRequest): Promise { + return this.conn.initialize(params); + } + + newSession(params: NewSessionRequest): Promise { + return this.conn.newSession(params); + } + + loadSession(params: LoadSessionRequest): Promise { + return this.conn.loadSession(params); + } + + prompt(params: PromptRequest): Promise { + return this.conn.prompt(params); + } + + cancel(params: CancelNotification): Promise { + return this.conn.cancel(params); + } + + authenticate(params: AuthenticateRequest): Promise { + return this.conn.authenticate(params); + } + + setSessionMode( + params: SetSessionModeRequest, + ): Promise { + return this.conn.setSessionMode(params); + } + + setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + return this.conn.setSessionConfigOption(params); + } + + unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + return this.conn.unstable_forkSession(params); + } + + unstable_listSessions( + params: ListSessionsRequest, + ): Promise { + return this.conn.unstable_listSessions(params); + } + + unstable_resumeSession( + params: ResumeSessionRequest, + ): Promise { + return this.conn.unstable_resumeSession(params); + } + + unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + return this.conn.unstable_setSessionModel(params); + } + + extMethod( + method: string, + params: Record, + ): Promise> { + return this.conn.extMethod(method, params); + } + + get goose(): GooseExtClient { + return this.ext; + } +} diff --git a/ui/acp/src/index.ts b/ui/acp/src/index.ts index 04ae78458e..eb8a86ec5a 100644 --- a/ui/acp/src/index.ts +++ b/ui/acp/src/index.ts @@ -1,3 +1,9 @@ -export * from "./generated/index.js"; +export * from "./generated/types.gen.js"; export * from "./generated/zod.gen.js"; -export { GooseClient } from "./generated/client.gen.js"; +export { GooseClient } from "./goose-client.js"; + +export { + ClientSideConnection, + type Client, + type Stream, +} from "@agentclientprotocol/sdk"; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 6eebea0901..37764e2b3c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1827,6 +1827,308 @@ } } }, + "/local-inference/download": { + "post": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "download_hf_model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadModelRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Download started", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + } + } + } + }, + "/local-inference/models": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "list_local_models", + "responses": { + "200": { + "description": "List of available local LLM models", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalModelResponse" + } + } + } + } + } + } + } + }, + "/local-inference/models/{model_id}": { + "delete": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "delete_local_model", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model deleted" + }, + "404": { + "description": "Model not found" + } + } + } + }, + "/local-inference/models/{model_id}/download": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "get_local_model_download_progress", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Download progress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadProgress" + } + } + } + }, + "404": { + "description": "No active download" + } + } + }, + "delete": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "cancel_local_model_download", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Download cancelled" + }, + "404": { + "description": "No active download" + } + } + } + }, + "/local-inference/models/{model_id}/settings": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "get_model_settings", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettings" + } + } + } + }, + "404": { + "description": "Model not found" + } + } + }, + "put": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "update_model_settings", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Settings updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettings" + } + } + } + }, + "404": { + "description": "Model not found" + }, + "500": { + "description": "Failed to save settings" + } + } + } + }, + "/local-inference/repo/{author}/{repo}/files": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "get_repo_files", + "parameters": [ + { + "name": "author", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "GGUF files in the repo", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepoVariantsResponse" + } + } + } + } + } + } + }, + "/local-inference/search": { + "get": { + "tags": [ + "super::routes::local_inference" + ], + "operationId": "search_hf_models", + "parameters": [ + { + "name": "q", + "in": "query", + "description": "Search query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Max results", + "required": false, + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HfModelInfo" + } + } + } + } + }, + "500": { + "description": "Search failed" + } + } + } + }, "/mcp-ui-proxy": { "get": { "tags": [ @@ -3954,6 +4256,18 @@ } } }, + "DownloadModelRequest": { + "type": "object", + "required": [ + "spec" + ], + "properties": { + "spec": { + "type": "string", + "description": "Model spec like \"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M\"" + } + } + }, "DownloadProgress": { "type": "object", "required": [ @@ -4588,6 +4902,100 @@ } ] }, + "HfGgufFile": { + "type": "object", + "description": "A single downloadable GGUF file (used internally and for downloads).", + "required": [ + "filename", + "size_bytes", + "quantization", + "download_url" + ], + "properties": { + "download_url": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "quantization": { + "type": "string" + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "HfModelInfo": { + "type": "object", + "required": [ + "repo_id", + "author", + "model_name", + "downloads", + "gguf_files" + ], + "properties": { + "author": { + "type": "string" + }, + "downloads": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "gguf_files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HfGgufFile" + } + }, + "model_name": { + "type": "string" + }, + "repo_id": { + "type": "string" + } + } + }, + "HfQuantVariant": { + "type": "object", + "description": "A quantization variant — groups sharded files into one logical entry.", + "required": [ + "quantization", + "size_bytes", + "filename", + "download_url", + "description", + "quality_rank" + ], + "properties": { + "description": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "quality_rank": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "quantization": { + "type": "string" + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "Icon": { "type": "object", "required": [ @@ -4773,6 +5181,47 @@ } } }, + "LocalModelResponse": { + "type": "object", + "required": [ + "id", + "repo_id", + "filename", + "quantization", + "size_bytes", + "status", + "recommended", + "settings" + ], + "properties": { + "filename": { + "type": "string" + }, + "id": { + "type": "string" + }, + "quantization": { + "type": "string" + }, + "recommended": { + "type": "boolean" + }, + "repo_id": { + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/ModelSettings" + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/ModelDownloadStatus" + } + } + }, "McpAppResource": { "type": "object", "description": "MCP App Resource\nRepresents a UI resource that can be rendered in an MCP App", @@ -5293,6 +5742,78 @@ } } }, + "ModelDownloadStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "NotDownloaded" + ] + } + } + }, + { + "type": "object", + "required": [ + "progress_percent", + "bytes_downloaded", + "total_bytes", + "state" + ], + "properties": { + "bytes_downloaded": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "progress_percent": { + "type": "number", + "format": "float" + }, + "speed_bps": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "state": { + "type": "string", + "enum": [ + "Downloading" + ] + }, + "total_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "Downloaded" + ] + } + } + } + ], + "discriminator": { + "propertyName": "state" + } + }, "ModelInfo": { "type": "object", "description": "Information about a model's capabilities", @@ -5417,6 +5938,71 @@ } } }, + "ModelSettings": { + "type": "object", + "properties": { + "context_size": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "flash_attention": { + "type": "boolean", + "nullable": true + }, + "frequency_penalty": { + "type": "number", + "format": "float" + }, + "max_output_tokens": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "n_batch": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "n_gpu_layers": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "n_threads": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "native_tool_calling": { + "type": "boolean" + }, + "presence_penalty": { + "type": "number", + "format": "float" + }, + "repeat_last_n": { + "type": "integer", + "format": "int32" + }, + "repeat_penalty": { + "type": "number", + "format": "float" + }, + "sampling": { + "$ref": "#/components/schemas/SamplingConfig" + }, + "use_jinja": { + "type": "boolean" + }, + "use_mlock": { + "type": "boolean" + } + } + }, "ParseRecipeRequest": { "type": "object", "required": [ @@ -6005,6 +6591,25 @@ } } }, + "RepoVariantsResponse": { + "type": "object", + "required": [ + "variants" + ], + "properties": { + "recommended_index": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HfQuantVariant" + } + } + } + }, "ResourceContents": { "anyOf": [ { @@ -6196,6 +6801,97 @@ } } }, + "SamplingConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Greedy" + ] + } + } + }, + { + "type": "object", + "required": [ + "temperature", + "top_k", + "top_p", + "min_p", + "type" + ], + "properties": { + "min_p": { + "type": "number", + "format": "float" + }, + "seed": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "temperature": { + "type": "number", + "format": "float" + }, + "top_k": { + "type": "integer", + "format": "int32" + }, + "top_p": { + "type": "number", + "format": "float" + }, + "type": { + "type": "string", + "enum": [ + "Temperature" + ] + } + } + }, + { + "type": "object", + "required": [ + "tau", + "eta", + "type" + ], + "properties": { + "eta": { + "type": "number", + "format": "float" + }, + "seed": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, + "tau": { + "type": "number", + "format": "float" + }, + "type": { + "type": "string", + "enum": [ + "MirostatV2" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, "SavePromptRequest": { "type": "object", "required": [ diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 3b46f36a53..60c21f1f9c 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -128,10 +128,14 @@ "zod": "^3.25.76" }, "devDependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@hey-api/openapi-ts": "^0.92.3", "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "~5.9.3" + }, + "peerDependencies": { + "@agentclientprotocol/sdk": "*" } }, "node_modules/@acemir/cssom": { @@ -233,6 +237,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -602,6 +607,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -642,6 +648,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1099,6 +1106,7 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -2732,6 +2740,7 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -3207,6 +3216,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -6531,8 +6541,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6820,6 +6829,7 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6861,6 +6871,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6871,6 +6882,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7011,6 +7023,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -7396,6 +7409,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -7644,6 +7658,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7716,6 +7731,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8256,6 +8272,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9558,8 +9575,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -9617,6 +9633,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", @@ -10624,6 +10641,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11107,6 +11125,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -12336,10 +12355,11 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", + "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -13396,6 +13416,7 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -14638,7 +14659,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -14659,6 +14679,7 @@ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -17034,6 +17055,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17135,7 +17157,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -17151,7 +17172,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -17507,6 +17527,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17516,6 +17537,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17537,8 +17559,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-markdown": { "version": "10.1.0", @@ -19455,7 +19476,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -19986,6 +20008,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20410,6 +20433,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -20500,6 +20524,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -21148,6 +21173,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index 9a24bb7ac0..2e65e54472 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, cancelDownload, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteModel, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchSessions, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listLocalModels, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, searchSessions, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 012124b3b4..da2dc61d4b 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, CheckProviderData, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponses, ListModelsData, ListModelsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -308,6 +308,38 @@ export const startOpenrouterSetup = (optio export const startTetrateSetup = (options?: Options) => (options?.client ?? client).post({ url: '/handle_tetrate', ...options }); +export const downloadHfModel = (options: Options) => (options.client ?? client).post({ + url: '/local-inference/download', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const listLocalModels = (options?: Options) => (options?.client ?? client).get({ url: '/local-inference/models', ...options }); + +export const deleteLocalModel = (options: Options) => (options.client ?? client).delete({ url: '/local-inference/models/{model_id}', ...options }); + +export const cancelLocalModelDownload = (options: Options) => (options.client ?? client).delete({ url: '/local-inference/models/{model_id}/download', ...options }); + +export const getLocalModelDownloadProgress = (options: Options) => (options.client ?? client).get({ url: '/local-inference/models/{model_id}/download', ...options }); + +export const getModelSettings = (options: Options) => (options.client ?? client).get({ url: '/local-inference/models/{model_id}/settings', ...options }); + +export const updateModelSettings = (options: Options) => (options.client ?? client).put({ + url: '/local-inference/models/{model_id}/settings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getRepoFiles = (options: Options) => (options.client ?? client).get({ url: '/local-inference/repo/{author}/{repo}/files', ...options }); + +export const searchHfModels = (options: Options) => (options.client ?? client).get({ url: '/local-inference/search', ...options }); + export const mcpUiProxy = (options: Options) => (options.client ?? client).get({ url: '/mcp-ui-proxy', ...options }); export const createRecipe = (options: Options) => (options.client ?? client).post({ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index e11b7e0f9c..cbe65f5abe 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -232,6 +232,13 @@ export type DictationProviderStatus = { uses_provider_config: boolean; }; +export type DownloadModelRequest = { + /** + * Model spec like "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M" + */ + spec: string; +}; + export type DownloadProgress = { /** * Bytes downloaded so far @@ -446,6 +453,36 @@ export type GooseApp = McpAppResource & (WindowProps | null) & { prd?: string | null; }; +/** + * A single downloadable GGUF file (used internally and for downloads). + */ +export type HfGgufFile = { + download_url: string; + filename: string; + quantization: string; + size_bytes: number; +}; + +export type HfModelInfo = { + author: string; + downloads: number; + gguf_files: Array; + model_name: string; + repo_id: string; +}; + +/** + * A quantization variant — groups sharded files into one logical entry. + */ +export type HfQuantVariant = { + description: string; + download_url: string; + filename: string; + quality_rank: number; + quantization: string; + size_bytes: number; +}; + export type Icon = { mimeType?: string; sizes?: Array; @@ -511,6 +548,17 @@ export type LoadedProvider = { is_editable: boolean; }; +export type LocalModelResponse = { + filename: string; + id: string; + quantization: string; + recommended: boolean; + repo_id: string; + settings: ModelSettings; + size_bytes: number; + status: ModelDownloadStatus; +}; + /** * MCP App Resource * Represents a UI resource that can be rendered in an MCP App @@ -638,6 +686,18 @@ export type ModelConfig = { toolshim_model?: string | null; }; +export type ModelDownloadStatus = { + state: 'NotDownloaded'; +} | { + bytes_downloaded: number; + progress_percent: number; + speed_bps?: number | null; + state: 'Downloading'; + total_bytes: number; +} | { + state: 'Downloaded'; +}; + /** * Information about a model's capabilities */ @@ -690,6 +750,23 @@ export type ModelInfoResponse = { source: string; }; +export type ModelSettings = { + context_size?: number | null; + flash_attention?: boolean | null; + frequency_penalty?: number; + max_output_tokens?: number | null; + n_batch?: number | null; + n_gpu_layers?: number | null; + n_threads?: number | null; + native_tool_calling?: boolean; + presence_penalty?: number; + repeat_last_n?: number; + repeat_penalty?: number; + sampling?: SamplingConfig; + use_jinja?: boolean; + use_mlock?: boolean; +}; + export type ParseRecipeRequest = { content: string; }; @@ -909,6 +986,11 @@ export type RemoveExtensionRequest = { session_id: string; }; +export type RepoVariantsResponse = { + recommended_index?: number | null; + variants: Array; +}; + export type ResourceContents = { _meta?: { [key: string]: unknown; @@ -986,6 +1068,22 @@ export type RunNowResponse = { session_id: string; }; +export type SamplingConfig = { + type: 'Greedy'; +} | { + min_p: number; + seed?: number | null; + temperature: number; + top_k: number; + top_p: number; + type: 'Temperature'; +} | { + eta: number; + seed?: number | null; + tau: number; + type: 'MirostatV2'; +}; + export type SavePromptRequest = { content: string; }; @@ -2836,6 +2934,221 @@ export type StartTetrateSetupResponses = { export type StartTetrateSetupResponse = StartTetrateSetupResponses[keyof StartTetrateSetupResponses]; +export type DownloadHfModelData = { + body: DownloadModelRequest; + path?: never; + query?: never; + url: '/local-inference/download'; +}; + +export type DownloadHfModelErrors = { + /** + * Invalid request + */ + 400: unknown; +}; + +export type DownloadHfModelResponses = { + /** + * Download started + */ + 202: string; +}; + +export type DownloadHfModelResponse = DownloadHfModelResponses[keyof DownloadHfModelResponses]; + +export type ListLocalModelsData = { + body?: never; + path?: never; + query?: never; + url: '/local-inference/models'; +}; + +export type ListLocalModelsResponses = { + /** + * List of available local LLM models + */ + 200: Array; +}; + +export type ListLocalModelsResponse = ListLocalModelsResponses[keyof ListLocalModelsResponses]; + +export type DeleteLocalModelData = { + body?: never; + path: { + model_id: string; + }; + query?: never; + url: '/local-inference/models/{model_id}'; +}; + +export type DeleteLocalModelErrors = { + /** + * Model not found + */ + 404: unknown; +}; + +export type DeleteLocalModelResponses = { + /** + * Model deleted + */ + 200: unknown; +}; + +export type CancelLocalModelDownloadData = { + body?: never; + path: { + model_id: string; + }; + query?: never; + url: '/local-inference/models/{model_id}/download'; +}; + +export type CancelLocalModelDownloadErrors = { + /** + * No active download + */ + 404: unknown; +}; + +export type CancelLocalModelDownloadResponses = { + /** + * Download cancelled + */ + 200: unknown; +}; + +export type GetLocalModelDownloadProgressData = { + body?: never; + path: { + model_id: string; + }; + query?: never; + url: '/local-inference/models/{model_id}/download'; +}; + +export type GetLocalModelDownloadProgressErrors = { + /** + * No active download + */ + 404: unknown; +}; + +export type GetLocalModelDownloadProgressResponses = { + /** + * Download progress + */ + 200: DownloadProgress; +}; + +export type GetLocalModelDownloadProgressResponse = GetLocalModelDownloadProgressResponses[keyof GetLocalModelDownloadProgressResponses]; + +export type GetModelSettingsData = { + body?: never; + path: { + model_id: string; + }; + query?: never; + url: '/local-inference/models/{model_id}/settings'; +}; + +export type GetModelSettingsErrors = { + /** + * Model not found + */ + 404: unknown; +}; + +export type GetModelSettingsResponses = { + /** + * Model settings + */ + 200: ModelSettings; +}; + +export type GetModelSettingsResponse = GetModelSettingsResponses[keyof GetModelSettingsResponses]; + +export type UpdateModelSettingsData = { + body: ModelSettings; + path: { + model_id: string; + }; + query?: never; + url: '/local-inference/models/{model_id}/settings'; +}; + +export type UpdateModelSettingsErrors = { + /** + * Model not found + */ + 404: unknown; + /** + * Failed to save settings + */ + 500: unknown; +}; + +export type UpdateModelSettingsResponses = { + /** + * Settings updated + */ + 200: ModelSettings; +}; + +export type UpdateModelSettingsResponse = UpdateModelSettingsResponses[keyof UpdateModelSettingsResponses]; + +export type GetRepoFilesData = { + body?: never; + path: { + author: string; + repo: string; + }; + query?: never; + url: '/local-inference/repo/{author}/{repo}/files'; +}; + +export type GetRepoFilesResponses = { + /** + * GGUF files in the repo + */ + 200: RepoVariantsResponse; +}; + +export type GetRepoFilesResponse = GetRepoFilesResponses[keyof GetRepoFilesResponses]; + +export type SearchHfModelsData = { + body?: never; + path?: never; + query: { + /** + * Search query + */ + q: string; + /** + * Max results + */ + limit?: number | null; + }; + url: '/local-inference/search'; +}; + +export type SearchHfModelsErrors = { + /** + * Search failed + */ + 500: unknown; +}; + +export type SearchHfModelsResponses = { + /** + * Search results + */ + 200: Array; +}; + +export type SearchHfModelsResponse = SearchHfModelsResponses[keyof SearchHfModelsResponses]; + export type McpUiProxyData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/LocalModelSetup.tsx b/ui/desktop/src/components/LocalModelSetup.tsx new file mode 100644 index 0000000000..e26b514563 --- /dev/null +++ b/ui/desktop/src/components/LocalModelSetup.tsx @@ -0,0 +1,397 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useConfig } from './ConfigContext'; +import { + listLocalModels, + downloadHfModel, + getLocalModelDownloadProgress, + cancelLocalModelDownload, + type DownloadProgress, + type LocalModelResponse, +} from '../api'; +import { toastService } from '../toasts'; +import { trackOnboardingSetupFailed } from '../utils/analytics'; +import { Goose } from './icons'; + +interface LocalModelSetupProps { + onSuccess: () => void; + onCancel: () => void; +} + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`; +}; + +const formatSize = (bytes: number): string => { + const mb = bytes / (1024 * 1024); + return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${mb.toFixed(0)}MB`; +}; + +type SetupPhase = 'loading' | 'select' | 'downloading' | 'error'; + +export function LocalModelSetup({ onSuccess, onCancel }: LocalModelSetupProps) { + const { upsert } = useConfig(); + const [phase, setPhase] = useState('loading'); + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [showAllModels, setShowAllModels] = useState(false); + const pollRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + useEffect(() => { + const load = async () => { + try { + const response = await listLocalModels(); + if (response.data) { + setModels(response.data); + + const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded'); + if (alreadyDownloaded) { + setSelectedModelId(alreadyDownloaded.id); + } else { + const recommended = response.data.find((m: LocalModelResponse) => m.recommended); + if (recommended) setSelectedModelId(recommended.id); + } + } + } catch (error) { + console.error('Failed to load local models:', error); + setErrorMessage('Failed to load available models. Please try again.'); + setPhase('error'); + return; + } + setPhase('select'); + }; + load(); + }, []); + + const finishSetup = async (modelId: string) => { + await upsert('GOOSE_PROVIDER', 'local', false); + await upsert('GOOSE_MODEL', modelId, false); + toastService.success({ + title: 'Local Model Ready', + msg: `Running entirely on your machine with ${modelId}.`, + }); + onSuccess(); + }; + + const startDownload = async (modelId: string) => { + setPhase('downloading'); + setDownloadProgress(null); + setErrorMessage(null); + + const model = models.find((m) => m.id === modelId); + if (!model) { + setErrorMessage('Model not found'); + setPhase('error'); + return; + } + + try { + await downloadHfModel({ body: { spec: model.id } }); + } catch (error) { + console.error('Failed to start download:', error); + setErrorMessage('Failed to start download. Please try again.'); + trackOnboardingSetupFailed('local', 'download_start_failed'); + setPhase('error'); + return; + } + + pollRef.current = setInterval(async () => { + try { + const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } }); + if (response.data) { + setDownloadProgress(response.data); + if (response.data.status === 'completed') { + cleanup(); + await finishSetup(modelId); + } else if (response.data.status === 'failed') { + cleanup(); + setErrorMessage(response.data.error || 'Download failed.'); + trackOnboardingSetupFailed('local', response.data.error || 'download_failed'); + setPhase('error'); + } else if (response.data.status === 'cancelled') { + cleanup(); + setPhase('select'); + } + } + } catch { + cleanup(); + setErrorMessage('Lost connection to download. Please try again.'); + trackOnboardingSetupFailed('local', 'progress_poll_failed'); + setPhase('error'); + } + }, 500); + }; + + const handleCancel = async () => { + if (phase === 'downloading' && selectedModelId) { + cleanup(); + try { + await cancelLocalModelDownload({ path: { model_id: selectedModelId } }); + } catch { + // best-effort + } + setDownloadProgress(null); + setPhase('select'); + } else { + onCancel(); + } + }; + + const handlePrimaryAction = async () => { + if (!selectedModelId) return; + const model = models.find((m) => m.id === selectedModelId); + if (!model) return; + if (model.status.state === 'Downloaded') { + await finishSetup(model.id); + } else { + await startDownload(model.id); + } + }; + + const recommended = models.find((m) => m.recommended); + const otherModels = models.filter((m) => m.id !== recommended?.id); + const selectedModel = models.find((m) => m.id === selectedModelId); + + if (phase === 'loading') { + return ( +
+
+

Checking available models...

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Run Locally

+

+ Download a model to run Goose entirely on your machine — no API keys, no accounts, completely free and private. +

+
+ + {/* Error state */} + {phase === 'error' && ( +
+
+

{errorMessage}

+
+ + +
+ )} + + {/* Model selection */} + {phase === 'select' && ( +
+ {/* Recommended model card */} + {recommended && ( +
setSelectedModelId(recommended.id)} + className={`relative w-full p-4 sm:p-6 border rounded-xl cursor-pointer transition-all duration-200 group ${ + selectedModelId === recommended.id + ? 'border-blue-500 bg-blue-500/5' + : 'border-border-subtle hover:border-border-default' + }`} + > +
+ + Best for your machine + +
+
+ setSelectedModelId(recommended.id)} + className="cursor-pointer flex-shrink-0 mt-1" + /> +
+
+ + {recommended.id} + + {recommended.status.state === 'Downloaded' && ( + + Ready + + )} +
+

+ {formatSize(recommended.size_bytes)} +

+
+
+
+ )} + + {/* Expandable other models */} + {otherModels.length > 0 && ( +
+ + + {showAllModels && ( +
+ {otherModels.map((model) => ( +
setSelectedModelId(model.id)} + className={`w-full p-4 border rounded-xl cursor-pointer transition-all duration-200 ${ + selectedModelId === model.id + ? 'border-blue-500 bg-blue-500/5' + : 'border-border-subtle hover:border-border-default' + }`} + > +
+ setSelectedModelId(model.id)} + className="cursor-pointer flex-shrink-0 mt-0.5" + /> +
+
+ {model.id} + {formatSize(model.size_bytes)} + {model.status.state === 'Downloaded' && ( + + Ready + + )} +
+
+
+
+ ))} +
+ )} +
+ )} + + {/* Primary action */} + + + +
+ )} + + {/* Downloading state */} + {phase === 'downloading' && selectedModel && ( +
+
+

+ Downloading {selectedModel.id} +

+ + {downloadProgress ? ( +
+ {/* Progress bar */} +
+
+
+ + {/* Stats row */} +
+ + {formatBytes(downloadProgress.bytes_downloaded)} of{' '} + {formatBytes(downloadProgress.total_bytes)} + + {downloadProgress.progress_percent.toFixed(0)}% +
+ +
+ {downloadProgress.speed_bps ? ( + {formatBytes(downloadProgress.speed_bps)}/s + ) : ( + + )} + {downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && ( + + ~{downloadProgress.eta_seconds < 60 + ? `${Math.round(downloadProgress.eta_seconds)}s` + : `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '} + remaining + + )} +
+
+ ) : ( +
+
+ Starting download... +
+ )} +
+ + +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index e1f6dd8421..21094510df 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -8,6 +8,7 @@ import { startChatGptCodexSetup } from '../utils/chatgptCodexSetup'; import WelcomeGooseLogo from './WelcomeGooseLogo'; import { toastService } from '../toasts'; import { OllamaSetup } from './OllamaSetup'; +import { LocalModelSetup } from './LocalModelSetup'; import ApiKeyTester from './ApiKeyTester'; import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal'; import { createNavigationHandler } from '../utils/navigationUtils'; @@ -34,6 +35,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const [hasProvider, setHasProvider] = useState(false); const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false); const [showOllamaSetup, setShowOllamaSetup] = useState(false); + const [showLocalModelSetup, setShowLocalModelSetup] = useState(false); const [userInActiveSetup, setUserInActiveSetup] = useState(false); const [showSwitchModelModal, setShowSwitchModelModal] = useState(false); const [switchModelProvider, setSwitchModelProvider] = useState(null); @@ -200,6 +202,19 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG setShowOllamaSetup(false); }; + const handleLocalModelComplete = () => { + trackOnboardingCompleted('local'); + setShowLocalModelSetup(false); + setShowFirstTimeSetup(false); + setHasProvider(true); + navigate('/', { replace: true }); + }; + + const handleLocalModelCancel = () => { + trackOnboardingAbandoned('local_model_setup'); + setShowLocalModelSetup(false); + }; + const handleRetrySetup = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => { if (setupType === 'openrouter') { setOpenRouterSetupState(null); @@ -285,6 +300,23 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG return ; } + if (showLocalModelSetup) { + return ( +
+
+
+
+ +
+
+
+
+ ); + } + if (!hasProvider && showFirstTimeSetup) { return (
@@ -316,6 +348,48 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }} /> + {/* Run Locally Card */} +
+
+ + Free & Private + +
+
{ + trackOnboardingProviderSelected('local'); + setShowLocalModelSetup(true); + }} + className="w-full p-4 sm:p-6 bg-transparent border rounded-xl transition-all duration-200 cursor-pointer group" + > +
+
+ + Run Locally + +
+
+ + + +
+
+

+ Download a model and run entirely on your machine. No API keys, no accounts. +

+
+
+ {/* ChatGPT Subscription Card - Full Width */}
diff --git a/ui/desktop/src/components/bottom_menu/CostTracker.tsx b/ui/desktop/src/components/bottom_menu/CostTracker.tsx index 5ebf895746..f8f17047a2 100644 --- a/ui/desktop/src/components/bottom_menu/CostTracker.tsx +++ b/ui/desktop/src/components/bottom_menu/CostTracker.tsx @@ -134,22 +134,15 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: !costInfo || (costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined) ) { - // If it's a known free/local provider, show $0.000000 without "not available" message const freeProviders = ['ollama', 'local', 'localhost']; if (freeProviders.includes(currentProvider.toLowerCase())) { return ( <> - - -
- - 0.0000 -
-
- - {`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`} - -
+
+ + {inputTokens.toLocaleString()}↑ {outputTokens.toLocaleString()}↓ + +
); diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 2e04ab651f..05a17d9ccb 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -9,10 +9,11 @@ import ConfigSettings from './config/ConfigSettings'; import PromptsSettingsSection from './PromptsSettingsSection'; import { ExtensionConfig } from '../../api'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; -import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard } from 'lucide-react'; +import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react'; import { useState, useEffect, useRef } from 'react'; import ChatSettingsSection from './chat/ChatSettingsSection'; import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection'; +import LocalInferenceSection from './localInference/LocalInferenceSection'; import { CONFIGURATION_ENABLED } from '../../updates'; import { trackSettingsTabViewed } from '../../utils/analytics'; @@ -54,6 +55,7 @@ export default function SettingsView({ chat: 'chat', prompts: 'prompts', keyboard: 'keyboard', + 'local-inference': 'local-inference', }; const targetTab = sectionToTab[viewOptions.section]; @@ -112,6 +114,14 @@ export default function SettingsView({ Models + + + Local Inference + Chat @@ -155,6 +165,13 @@ export default function SettingsView({ + + + + { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Determine if we should show all models by default (if non-recommended models are downloaded) - useEffect(() => { - if (models.length === 0) return; - - const hasDownloadedNonRecommended = models.some( - (model) => model.downloaded && !model.recommended - ); - - if (hasDownloadedNonRecommended && !showAllModels) { - setShowAllModels(true); - } - }, [models, showAllModels]); - const loadSelectedModel = async () => { try { const value = await read(LOCAL_WHISPER_MODEL_CONFIG_KEY, false); @@ -145,8 +132,14 @@ export const LocalModelManager = () => { } }; - const displayedModels = showAllModels ? models : models.filter((m) => m.recommended); + const hasDownloadedNonRecommended = models.some( + (model) => model.downloaded && !model.recommended + ); + const displayedModels = showAllModels || hasDownloadedNonRecommended + ? models + : models.filter((m) => m.recommended); const hasNonRecommendedModels = models.some((m) => !m.recommended); + const showToggleButton = hasNonRecommendedModels && !hasDownloadedNonRecommended; return (
@@ -267,7 +260,7 @@ export const LocalModelManager = () => { })}
- {hasNonRecommendedModels && ( + {showToggleButton && ( diff --git a/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx new file mode 100644 index 0000000000..241921555d --- /dev/null +++ b/ui/desktop/src/components/settings/localInference/HuggingFaceModelSearch.tsx @@ -0,0 +1,358 @@ +import { useState, useCallback, useRef } from 'react'; +import { Search, Download, ChevronDown, ChevronUp, Loader2, Star } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { + searchHfModels, + getRepoFiles, + downloadHfModel, + type HfModelInfo, + type HfQuantVariant, +} from '../../../api'; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return 'unknown'; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`; +}; + +const formatDownloads = (n: number): string => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return `${n}`; +}; + +interface RepoData { + variants: HfQuantVariant[]; + recommendedIndex: number | null; +} + +interface Props { + onDownloadStarted: (modelId: string) => void; +} + +export const HuggingFaceModelSearch = ({ onDownloadStarted }: Props) => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [expandedRepo, setExpandedRepo] = useState(null); + const [repoData, setRepoData] = useState>({}); + const [searching, setSearching] = useState(false); + const [downloading, setDownloading] = useState>(new Set()); + const [loadingFiles, setLoadingFiles] = useState>(new Set()); + const [directSpec, setDirectSpec] = useState(''); + const [error, setError] = useState(null); + const debounceRef = useRef | null>(null); + + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { + setResults([]); + setError(null); + return; + } + setSearching(true); + setError(null); + try { + const response = await searchHfModels({ + query: { q, limit: 20 }, + }); + if (response.data) { + // Pre-fetch variants for all results and filter out repos with no suitable quantizations + const modelsWithVariants = await Promise.all( + response.data.map(async (model) => { + try { + const [author, repo] = model.repo_id.split('/'); + const filesResponse = await getRepoFiles({ path: { author, repo } }); + if (filesResponse.data && filesResponse.data.variants.length > 0) { + return { model, data: filesResponse.data }; + } + } catch { + // Skip repos we can't fetch + } + return null; + }) + ); + + const validResults = modelsWithVariants.filter(Boolean) as { + model: HfModelInfo; + data: { variants: HfQuantVariant[]; recommended_index?: number | null }; + }[]; + + setResults(validResults.map((r) => r.model)); + setRepoData((prev) => { + const next = { ...prev }; + for (const r of validResults) { + next[r.model.repo_id] = { + variants: r.data.variants, + recommendedIndex: r.data.recommended_index ?? null, + }; + } + return next; + }); + + if (validResults.length === 0) { + setError('No GGUF models found for this query.'); + } + } else { + console.error('Search response:', response); + const errMsg = response.error + ? `Search error: ${JSON.stringify(response.error)}` + : 'Search returned no data.'; + setError(errMsg); + } + } catch (e) { + console.error('Search failed:', e); + setError('Search failed. Please try again.'); + } finally { + setSearching(false); + } + }, []); + + const handleQueryChange = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(value), 300); + }; + + const toggleRepo = async (repoId: string) => { + if (expandedRepo === repoId) { + setExpandedRepo(null); + return; + } + setExpandedRepo(repoId); + + if (!repoData[repoId]?.variants.length) { + setLoadingFiles((prev) => new Set(prev).add(repoId)); + try { + const [author, repo] = repoId.split('/'); + const response = await getRepoFiles({ + path: { author, repo }, + }); + if (response.data) { + const variants = response.data.variants; + setRepoData((prev) => ({ + ...prev, + [repoId]: { + variants, + recommendedIndex: response.data!.recommended_index ?? null, + }, + })); + } + } catch (e) { + console.error('Failed to fetch repo files:', e); + } finally { + setLoadingFiles((prev) => { + const next = new Set(prev); + next.delete(repoId); + return next; + }); + } + } + }; + + const startDownload = async (repoId: string, quantization: string) => { + const spec = `${repoId}:${quantization}`; + setDownloading((prev) => new Set(prev).add(spec)); + try { + const response = await downloadHfModel({ + body: { spec }, + }); + if (response.data) { + onDownloadStarted(response.data); + } + } catch (e) { + console.error('Download failed:', e); + } finally { + setDownloading((prev) => { + const next = new Set(prev); + next.delete(spec); + return next; + }); + } + }; + + const startDirectDownload = async () => { + const spec = directSpec.trim(); + if (!spec) return; + const key = `direct:${spec}`; + setDownloading((prev) => new Set(prev).add(key)); + try { + const response = await downloadHfModel({ + body: { spec }, + }); + if (response.data) { + onDownloadStarted(response.data); + setDirectSpec(''); + } + } catch (e) { + console.error('Direct download failed:', e); + } finally { + setDownloading((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }; + + return ( +
+
+

Search HuggingFace

+
+ + handleQueryChange(e.target.value)} + placeholder="Search for GGUF models..." + className="w-full pl-9 pr-4 py-2 text-sm border border-border-subtle rounded-lg bg-background-default text-text-default placeholder:text-text-muted focus:outline-none focus:border-accent-primary" + /> + {searching && ( + + )} +
+
+ + {error && !searching && ( +

{error}

+ )} + + {results.length > 0 && ( +
+ {results.map((model) => { + const isExpanded = expandedRepo === model.repo_id; + const data = repoData[model.repo_id]; + const variants = data?.variants || []; + const recommendedIndex = data?.recommendedIndex ?? null; + + return ( +
+ + + {isExpanded && ( +
+ {loadingFiles.has(model.repo_id) && ( +
+ + Loading variants... +
+ )} + {variants.map((variant, idx) => { + const dlKey = `${model.repo_id}:${variant.quantization}`; + const isStarting = downloading.has(dlKey); + const isRecommended = idx === recommendedIndex; + + return ( +
+
+
+ + {variant.quantization} + + + {formatBytes(variant.size_bytes)} + + {isRecommended && ( + + + Recommended + + )} +
+ {variant.description && ( + + {variant.description} + + )} +
+ +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} + +
+

Direct Download

+

+ Specify a model directly: user/repo:quantization +

+
+ setDirectSpec(e.target.value)} + placeholder="bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M" + className="flex-1 px-3 py-2 text-sm border border-border-subtle rounded-lg bg-background-default text-text-default placeholder:text-text-muted focus:outline-none focus:border-accent-primary" + onKeyDown={(e) => { + if (e.key === 'Enter') startDirectDownload(); + }} + /> + +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/localInference/LocalInferenceSection.tsx b/ui/desktop/src/components/settings/localInference/LocalInferenceSection.tsx new file mode 100644 index 0000000000..3a68c21171 --- /dev/null +++ b/ui/desktop/src/components/settings/localInference/LocalInferenceSection.tsx @@ -0,0 +1,9 @@ +import { LocalInferenceSettings } from './LocalInferenceSettings'; + +export default function LocalInferenceSection() { + return ( +
+ +
+ ); +} diff --git a/ui/desktop/src/components/settings/localInference/LocalInferenceSettings.tsx b/ui/desktop/src/components/settings/localInference/LocalInferenceSettings.tsx new file mode 100644 index 0000000000..cdb6fccfa9 --- /dev/null +++ b/ui/desktop/src/components/settings/localInference/LocalInferenceSettings.tsx @@ -0,0 +1,401 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Download, Trash2, X, ChevronDown, ChevronUp, Settings2 } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { useModelAndProvider } from '../../ModelAndProviderContext'; +import { + listLocalModels, + downloadHfModel, + getLocalModelDownloadProgress, + cancelLocalModelDownload, + deleteLocalModel, + setConfigProvider, + type DownloadProgress, + type LocalModelResponse, +} from '../../../api'; +import { HuggingFaceModelSearch } from './HuggingFaceModelSearch'; +import { ModelSettingsPanel } from './ModelSettingsPanel'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '../../ui/dialog'; + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`; +}; + +export const LocalInferenceSettings = () => { + const [models, setModels] = useState([]); + const [downloads, setDownloads] = useState>(new Map()); + const [showAllFeatured, setShowAllFeatured] = useState(false); + const [settingsOpenFor, setSettingsOpenFor] = useState(null); + const { currentModel, currentProvider, setProviderAndModel } = useModelAndProvider(); + const downloadSectionRef = useRef(null); + const selectedModelId = currentProvider === 'local' ? currentModel : null; + + const loadModels = useCallback(async () => { + try { + const response = await listLocalModels(); + if (response.data) { + setModels(response.data); + } + } catch (error) { + console.error('Failed to load models:', error); + } + }, []); + + // Check for any in-progress downloads when models list changes + const detectActiveDownloads = useCallback(async () => { + for (const model of models) { + if (downloads.has(model.id)) continue; + // Check models that the API reports as downloading + if (model.status.state === 'Downloading') { + pollDownloadProgress(model.id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [models, downloads]); + + useEffect(() => { + loadModels(); + }, [loadModels]); + + useEffect(() => { + if (models.length > 0) { + detectActiveDownloads(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [models]); + + const selectModel = async (modelId: string) => { + setProviderAndModel('local', modelId); + try { + await setConfigProvider({ + body: { provider: 'local', model: modelId }, + throwOnError: true, + }); + } catch (error) { + console.error('Failed to select model:', error); + } + }; + + const startFeaturedDownload = async (modelId: string) => { + const model = models.find((m) => m.id === modelId); + if (!model) return; + try { + await downloadHfModel({ body: { spec: model.id } }); + pollDownloadProgress(modelId); + scrollToDownloads(); + } catch (error) { + console.error('Failed to start download:', error); + } + }; + + const scrollToDownloads = useCallback(() => { + requestAnimationFrame(() => { + downloadSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + }, []); + + const pollDownloadProgress = (modelId: string) => { + const interval = setInterval(async () => { + try { + const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } }); + if (response.data) { + const progress = response.data; + setDownloads((prev) => new Map(prev).set(modelId, progress)); + + if (progress.status === 'completed') { + clearInterval(interval); + setDownloads((prev) => { + const next = new Map(prev); + next.delete(modelId); + return next; + }); + await loadModels(); + await selectModel(modelId); + } else if (progress.status === 'failed') { + clearInterval(interval); + await loadModels(); + } + } else { + clearInterval(interval); + } + } catch { + clearInterval(interval); + } + }, 1000); + }; + + const cancelDownload = async (modelId: string) => { + try { + await cancelLocalModelDownload({ path: { model_id: modelId } }); + setDownloads((prev) => { + const next = new Map(prev); + next.delete(modelId); + return next; + }); + } catch (error) { + console.error('Failed to cancel download:', error); + } + }; + + const handleDeleteModel = async (modelId: string) => { + if (!window.confirm('Delete this model? You can re-download it later.')) return; + try { + await deleteLocalModel({ path: { model_id: modelId } }); + await loadModels(); + } catch (error) { + console.error('Failed to delete model:', error); + } + }; + + const handleHfDownloadStarted = (modelId: string) => { + pollDownloadProgress(modelId); + loadModels(); + scrollToDownloads(); + }; + + const isDownloaded = (model: LocalModelResponse) => model.status.state === 'Downloaded'; + const isNotDownloaded = (model: LocalModelResponse) => + model.status.state === 'NotDownloaded' && !downloads.has(model.id); + + const downloadedModels = models.filter(isDownloaded); + const notDownloadedModels = models.filter(isNotDownloaded); + const recommendedModels = notDownloadedModels.filter((m) => m.recommended); + const displayedFeatured = showAllFeatured ? notDownloadedModels : recommendedModels; + const showFeaturedToggle = notDownloadedModels.length > recommendedModels.length; + + return ( +
+
+

Local Inference Models

+

+ Download and manage local LLM models for inference without API keys. Search HuggingFace + for any GGUF model or use the featured picks below. +

+
+ + {/* Active Downloads */} + {downloads.size > 0 && ( +
+

Downloading

+
+ {Array.from(downloads.entries()).map(([modelId, progress]) => { + if (progress.status === 'completed') return null; + return ( +
+
+ + {modelId} + + {progress.status === 'downloading' && ( + + )} +
+ {progress.status === 'downloading' && ( +
+
+
+
+
+ + {formatBytes(progress.bytes_downloaded)} /{' '} + {formatBytes(progress.total_bytes)} ( + {progress.progress_percent.toFixed(0)}%) + + + {progress.eta_seconds != null && progress.eta_seconds > 0 && ( + + {progress.eta_seconds < 60 + ? `${Math.round(progress.eta_seconds)}s` + : `${Math.round(progress.eta_seconds / 60)}m`}{' '} + remaining + + )} + {progress.speed_bps != null && progress.speed_bps > 0 && ( + {formatBytes(progress.speed_bps)}/s + )} + +
+
+ )} + {progress.status === 'failed' && ( +

+ {progress.error || 'Download failed'} +

+ )} +
+ ); + })} +
+
+ )} + + {/* Downloaded Models */} + {downloadedModels.length > 0 && ( +
+

Downloaded Models

+
+ {downloadedModels.map((model) => { + const isSelected = selectedModelId === model.id; + return ( +
+
+
+ selectModel(model.id)} + className="cursor-pointer" + /> + + {model.id} + + + {formatBytes(model.size_bytes)} + + {model.recommended && ( + + Recommended + + )} +
+
+ + +
+
+
+ ); + })} +
+
+ )} + + {/* Featured Models (not yet downloaded) */} + {displayedFeatured.length > 0 && ( +
+

Featured Models

+
+ {displayedFeatured.map((model) => ( +
+
+
+
+

+ {model.id} +

+ + {formatBytes(model.size_bytes)} + + {model.recommended && ( + + Recommended + + )} +
+
+ +
+
+ ))} +
+ + {showFeaturedToggle && ( + + )} +
+ )} + + {/* HuggingFace Search */} +
+ +
+ + {models.length === 0 && ( +
No models available
+ )} + + { + if (!open) setSettingsOpenFor(null); + }} + > + + + Model Settings +

{settingsOpenFor || ''}

+
+ {settingsOpenFor && } +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx b/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx new file mode 100644 index 0000000000..6ab726cc3e --- /dev/null +++ b/ui/desktop/src/components/settings/localInference/ModelSettingsPanel.tsx @@ -0,0 +1,415 @@ +import { useState, useEffect, useCallback } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { Switch } from '../../ui/switch'; +import { + getModelSettings, + updateModelSettings, + type ModelSettings, + type SamplingConfig, +} from '../../../api'; + +const DEFAULT_SETTINGS: ModelSettings = { + context_size: null, + max_output_tokens: null, + sampling: { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null }, + repeat_penalty: 1.0, + repeat_last_n: 64, + frequency_penalty: 0.0, + presence_penalty: 0.0, + n_batch: null, + n_gpu_layers: null, + use_mlock: false, + flash_attention: null, + n_threads: null, + native_tool_calling: false, +}; + +type SamplingType = SamplingConfig['type']; + +function NumberField({ + label, + description, + value, + onChange, + placeholder, + min, + max, + step, + allowNull, +}: { + label: string; + description?: string; + value: number | null | undefined; + onChange: (v: number | null) => void; + placeholder?: string; + min?: number; + max?: number; + step?: number; + allowNull?: boolean; +}) { + return ( +
+ + {description && {description}} + { + const raw = e.target.value; + if (raw === '' && allowNull) { + onChange(null); + } else { + const n = step && step < 1 ? parseFloat(raw) : parseInt(raw, 10); + if (!isNaN(n)) onChange(n); + } + }} + placeholder={placeholder ?? 'Auto'} + min={min} + max={max} + step={step} + /> +
+ ); +} + +function ToggleField({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+
{label}
+ {description && {description}} +
+ +
+ ); +} + +function SelectField({ + label, + description, + value, + options, + onChange, +}: { + label: string; + description?: string; + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; +}) { + return ( +
+
+
{label}
+ {description && {description}} +
+ +
+ ); +} + +export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const res = await getModelSettings({ path: { model_id: modelId } }); + if (res.data) setSettings(res.data); + } catch { + // use defaults + } finally { + setLoading(false); + } + }, [modelId]); + + useEffect(() => { + load(); + }, [load]); + + const save = async (updated: ModelSettings) => { + setSettings(updated); + setSaving(true); + try { + await updateModelSettings({ path: { model_id: modelId }, body: updated }); + } catch (e) { + console.error('Failed to save settings:', e); + } finally { + setSaving(false); + } + }; + + const resetDefaults = () => save(DEFAULT_SETTINGS); + + const updateField = (key: K, value: ModelSettings[K]) => { + save({ ...settings, [key]: value }); + }; + + const samplingType: SamplingType = settings.sampling?.type ?? 'Temperature'; + + const setSamplingType = (type: SamplingType) => { + let sampling: SamplingConfig; + if (type === 'Greedy') { + sampling = { type: 'Greedy' }; + } else if (type === 'MirostatV2') { + sampling = { type: 'MirostatV2', tau: 5.0, eta: 0.1, seed: null }; + } else { + sampling = { type: 'Temperature', temperature: 0.8, top_k: 40, top_p: 0.95, min_p: 0.05, seed: null }; + } + save({ ...settings, sampling }); + }; + + const updateSampling = (partial: Partial) => { + save({ ...settings, sampling: { ...settings.sampling!, ...partial } as SamplingConfig }); + }; + + if (loading) { + return
Loading settings...
; + } + + return ( +
+
+ {saving && Saving...} + +
+ + {/* Context & Generation */} +
+
Context & Generation
+
+ updateField('context_size', v)} + placeholder="Auto" + min={0} + allowNull + /> + updateField('max_output_tokens', v)} + placeholder="No limit" + min={1} + allowNull + /> +
+
+ + {/* Sampling */} +
+ setSamplingType(v)} + /> + + {samplingType === 'Temperature' && settings.sampling?.type === 'Temperature' && ( +
+ updateSampling({ temperature: v ?? 0.8 })} + min={0} + max={2} + step={0.05} + /> + updateSampling({ top_k: v ?? 40 })} + min={0} + /> + updateSampling({ top_p: v ?? 0.95 })} + min={0} + max={1} + step={0.01} + /> + updateSampling({ min_p: v ?? 0.05 })} + min={0} + max={1} + step={0.01} + /> + updateSampling({ seed: v })} + placeholder="Random" + min={0} + allowNull + /> +
+ )} + + {samplingType === 'MirostatV2' && settings.sampling?.type === 'MirostatV2' && ( +
+ updateSampling({ tau: v ?? 5.0 })} + min={0} + step={0.1} + /> + updateSampling({ eta: v ?? 0.1 })} + min={0} + max={1} + step={0.01} + /> + updateSampling({ seed: v })} + placeholder="Random" + min={0} + allowNull + /> +
+ )} +
+ + {/* Repetition Penalty */} +
+
Repetition Penalty
+
+ updateField('repeat_penalty', v ?? 1.0)} + min={0} + step={0.05} + /> + updateField('repeat_last_n', v ?? 64)} + min={0} + /> + updateField('frequency_penalty', v ?? 0.0)} + min={0} + max={2} + step={0.05} + /> + updateField('presence_penalty', v ?? 0.0)} + min={0} + max={2} + step={0.05} + /> +
+
+ + {/* Performance */} +
+
Performance
+
+ updateField('n_batch', v)} + placeholder="Auto" + min={1} + allowNull + /> + updateField('n_gpu_layers', v)} + placeholder="All" + min={0} + allowNull + /> + updateField('n_threads', v)} + placeholder="Auto" + min={1} + allowNull + /> +
+ updateField('use_mlock', v)} + /> + updateField('flash_attention', v === 'auto' ? null : v === 'on')} + /> +
+ {/* Tool Calling */} +
+
Tool Calling
+ updateField('native_tool_calling', v)} + /> +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/models/modelInterface.ts b/ui/desktop/src/components/settings/models/modelInterface.ts index f5fb73758a..0be12e2244 100644 --- a/ui/desktop/src/components/settings/models/modelInterface.ts +++ b/ui/desktop/src/components/settings/models/modelInterface.ts @@ -1,4 +1,4 @@ -import { ProviderDetails, getProviderModels } from '../../../api'; +import { ProviderDetails, getProviderModels, listLocalModels } from '../../../api'; import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils'; export default interface Model { @@ -54,6 +54,16 @@ export async function fetchModelsForProviders( ): Promise { const modelPromises = activeProviders.map(async (p) => { try { + // For local provider, use listLocalModels and filter to only downloaded models + if (p.name === 'local') { + const response = await listLocalModels(); + const allModels = response.data || []; + const downloadedModels = allModels + .filter((m) => m.status.state === 'Downloaded') + .map((m) => m.id); + return { provider: p, models: downloadedModels, error: null }; + } + const response = await getProviderModels({ path: { name: p.name }, throwOnError: true, diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 6d294a18b9..4783e77c75 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -486,7 +486,36 @@ export const SwitchModelModal = ({ {provider && ( <> - {providerErrors[provider] ? ( + {provider === 'local' && + !loadingModels && + filteredModelOptions.flatMap((g) => g.options).filter((o) => o.value !== 'custom') + .length === 0 ? ( + /* Show special UI for local provider when no models are downloaded */ +
+
+
+

+ Local models need to be downloaded first +

+
+ To use local inference, you need to download a model to your computer + first. Go to Settings → Models to manage local models. +
+
+ +
+
+ ) : providerErrors[provider] ? ( /* Show error message when provider failed to connect */
diff --git a/ui/desktop/src/utils/analytics.ts b/ui/desktop/src/utils/analytics.ts index 73ee208f4c..6bf157e499 100644 --- a/ui/desktop/src/utils/analytics.ts +++ b/ui/desktop/src/utils/analytics.ts @@ -70,7 +70,7 @@ export type AnalyticsEvent = | { name: 'onboarding_provider_selected'; properties: { - method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other'; + method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other'; }; } | { @@ -80,7 +80,7 @@ export type AnalyticsEvent = | { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } } | { name: 'onboarding_setup_failed'; - properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex'; error_message?: string }; + properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local'; error_message?: string }; } | { name: 'error_occurred'; @@ -284,7 +284,7 @@ export function trackOnboardingStarted(): void { } export function trackOnboardingProviderSelected( - method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other' + method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'local' | 'other' ): void { trackEvent({ name: 'onboarding_provider_selected', @@ -317,7 +317,7 @@ export function trackOnboardingAbandoned(step: string): void { } export function trackOnboardingSetupFailed( - provider: 'openrouter' | 'tetrate' | 'chatgpt_codex', + provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local', errorMessage?: string ): void { trackEvent({ diff --git a/ui/text/.gitignore b/ui/text/.gitignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/ui/text/.gitignore @@ -0,0 +1 @@ +dist diff --git a/ui/text/.npmrc b/ui/text/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/ui/text/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/ui/text/README.md b/ui/text/README.md new file mode 100644 index 0000000000..09555d5ac2 --- /dev/null +++ b/ui/text/README.md @@ -0,0 +1,16 @@ +# goose ACP TUI + +Early stage and part of goose's broader move to ACP + +https://github.com/block/goose/issues/6642 +https://github.com/block/goose/discussions/7309 + +## Running + +1. Run the server `cargo run -p goose-acp --bin goose-acp-server` +2. Run the tui + ``` + cd ui/text + npm i + npm run start + ``` diff --git a/ui/text/package-lock.json b/ui/text/package-lock.json new file mode 100644 index 0000000000..bf6499854a --- /dev/null +++ b/ui/text/package-lock.json @@ -0,0 +1,1244 @@ +{ + "name": "goose-text", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "goose-text", + "version": "0.1.0", + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", + "@goose/acp": "file:../acp", + "ink": "^5.1.0", + "ink-text-input": "^6.0.0", + "meow": "^13.2.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "@types/react": "^18.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "../acp": { + "name": "goose-acp-types", + "version": "0.1.0", + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@agentclientprotocol/sdk": "^0.14.1", + "@hey-api/openapi-ts": "^0.92.3", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "~5.9.3" + }, + "peerDependencies": { + "@agentclientprotocol/sdk": "*" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", + "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@goose/acp": { + "resolved": "../acp", + "link": true + }, + "node_modules/@types/node": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/ui/text/package.json b/ui/text/package.json new file mode 100644 index 0000000000..ded27fb2af --- /dev/null +++ b/ui/text/package.json @@ -0,0 +1,25 @@ +{ + "name": "goose-text", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "tsx src/cli.tsx", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", + "@goose/acp": "file:../acp", + "ink": "^5.1.0", + "ink-text-input": "^6.0.0", + "meow": "^13.2.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "@types/react": "^18.3.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/ui/text/src/app.tsx b/ui/text/src/app.tsx new file mode 100644 index 0000000000..135ccae9c5 --- /dev/null +++ b/ui/text/src/app.tsx @@ -0,0 +1,764 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import TextInput from "ink-text-input"; +import type { + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, +} from "@agentclientprotocol/sdk"; +import { GooseClient } from "@goose/acp"; +import { createHttpStream } from "./transport.js"; + +interface PendingPermission { + toolTitle: string; + options: Array<{ optionId: string; name: string; kind: string }>; + resolve: (response: RequestPermissionResponse) => void; +} + +const CRANBERRY_BRIGHT = "#C0354A"; +const HARBOR_NAVY = "#1B2A4A"; +const DEEP_SLATE = "#3A4F6F"; +const SLATE = "#6B7F99"; +const LIGHT_SLATE = "#8FA4BD"; +const AUTUMN_GOLD = "#C4883A"; +const OCEAN_TEAL = "#3A7D7B"; +const CEDAR_BROWN = "#6B5344"; +const FOG_WHITE = "#E8E4DF"; +const PARCHMENT = "#D4CFC8"; + +const GOOSE_FRAMES = [ + [ + " ,_", + " (o >", + " //\\", + " \\\\ \\", + " \\\\_/", + " | |", + " ^ ^", + ], + [ + " ,_", + " (o >", + " //\\", + " \\\\ \\", + " \\\\_/", + " / |", + " ^ ^", + ], + [ + " ,_", + " (o >", + " //\\", + " \\\\ \\", + " \\\\_/", + " | |", + " ^ ^", + ], + [ + " ,_", + " (o >", + " //\\", + " \\\\ \\", + " \\\\_/", + " | \\", + " ^ ^", + ], +]; + +const TITLE_TEXT = "goose"; + +const GREETING_MESSAGES = [ + "What would you like to work on?", + "Ready to build something amazing?", + "What would you like to explore?", + "What's on your mind?", + "What shall we create today?", + "What project needs attention?", + "What would you like to tackle?", + "What needs to be done?", + "What's the plan for today?", + "Ready to create something great?", + "What can be built today?", + "What's the next challenge?", + "What progress can be made?", + "What would you like to accomplish?", + "What task awaits?", + "What's the mission today?", + "What can be achieved?", + "What project is ready to begin?", +]; + +const INITIAL_GREETING = + GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!; + +const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"]; + +const PERMISSION_LABELS: Record = { + allow_once: "Allow once", + allow_always: "Always allow", + reject_once: "Reject once", + reject_always: "Always reject", +}; + +const PERMISSION_KEYS: Record = { + allow_once: "y", + allow_always: "a", + reject_once: "n", + reject_always: "N", +}; + +interface TextMessage { + kind: "text"; + role: "user" | "agent"; + text: string; +} + +interface ToolCallMessage { + kind: "tool_call"; + title: string; +} + +type Message = TextMessage | ToolCallMessage; + +function HRule({ width, color }: { width: number; color?: string }) { + return ( + + + {"─".repeat(Math.max(width, 1))} + + + ); +} + +function HeaderBar({ + width, + status, + loading, + spinIdx, + hasPendingPermission, +}: { + width: number; + status: string; + loading: boolean; + spinIdx: number; + hasPendingPermission: boolean; +}) { + const statusColor = + status === "ready" + ? OCEAN_TEAL + : status.startsWith("error") || status.startsWith("failed") + ? CRANBERRY_BRIGHT + : SLATE; + + const leftContent = ` ${TITLE_TEXT} `; + const spinner = + loading && !hasPendingPermission + ? ` ${SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]} ` + : ""; + + return ( + + + + + {leftContent} + + │ + {status} + {spinner && {spinner}} + + + + ctrl+c to exit{" "} + + + + + + ); +} + +function ToolCallBlock({ title, width }: { title: string; width: number }) { + return ( + + ⚙ + + {title} + + + ); +} + +function UserMessage({ text, width }: { text: string; width: number }) { + return ( + + + + {"❯ "} + + + {text} + + + + ); +} + +function AgentMessage({ text, width }: { text: string; width: number }) { + return ( + + {text} + + ); +} + +function PermissionPrompt({ + toolTitle, + options, + selectedIdx, + width, +}: { + toolTitle: string; + options: Array<{ optionId: string; name: string; kind: string }>; + selectedIdx: number; + width: number; +}) { + return ( + + + 🔒 Permission required + + {toolTitle} + + {options.map((opt, i) => { + const key = PERMISSION_KEYS[opt.kind] ?? String(i + 1); + const label = PERMISSION_LABELS[opt.kind] ?? opt.name; + const selected = i === selectedIdx; + return ( + + + {selected ? " ▸ " : " "} + + + [{key}] {label} + + + ); + })} + + + + ↑↓ select · enter confirm · esc cancel + + + + ); +} + +function SplashScreen({ + animFrame, + width, + height, + status, + loading, + spinIdx, + showInput, + input, + onInputChange, + onInputSubmit, +}: { + animFrame: number; + width: number; + height: number; + status: string; + loading: boolean; + spinIdx: number; + showInput: boolean; + input: string; + onInputChange: (v: string) => void; + onInputSubmit: (v: string) => void; +}) { + const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!; + const statusColor = + status === "ready" + ? OCEAN_TEAL + : status.startsWith("error") || status.startsWith("failed") + ? CRANBERRY_BRIGHT + : SLATE; + + const inputWidth = Math.min(60, width - 8); + + return ( + + + {frame.map((line, i) => ( + + {line} + + ))} + + + + {TITLE_TEXT} + + + + your on-machine AI agent + + + {showInput ? ( + <> + + + + + + {"❯ "} + + + + + + + + ) : ( + <> + + + + + {loading && ( + + {SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "} + + )} + {status} + + + )} + + ); +} + +function InputBar({ + width, + input, + onChange, + onSubmit, +}: { + width: number; + input: string; + onChange: (v: string) => void; + onSubmit: (v: string) => void; +}) { + return ( + + + + + {"❯ "} + + + + + ); +} + +function LoadingIndicator({ + status, + spinIdx, +}: { + status: string; + spinIdx: number; +}) { + return ( + + + {SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "} + + + {status} + + + ); +} + +export default function App({ + serverUrl, + initialPrompt, +}: { + serverUrl: string; + initialPrompt?: string; +}) { + const { exit } = useApp(); + const { stdout } = useStdout(); + const termWidth = stdout?.columns ?? 80; + const termHeight = stdout?.rows ?? 24; + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState("connecting..."); + const [spinIdx, setSpinIdx] = useState(0); + const [gooseFrame, setGooseFrame] = useState(0); + const [bannerVisible, setBannerVisible] = useState(true); + const [pendingPermission, setPendingPermission] = + useState(null); + const [permissionIdx, setPermissionIdx] = useState(0); + const clientRef = useRef(null); + const sessionIdRef = useRef(null); + const streamBuf = useRef(""); + const sentInitialPrompt = useRef(false); + + useEffect(() => { + const t = setInterval(() => { + setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length); + setGooseFrame((f) => f + 1); + }, 300); + return () => clearInterval(t); + }, []); + + useEffect(() => { + if (messages.length > 0) { + setBannerVisible(false); + } + }, [messages]); + + const appendAgent = useCallback((text: string) => { + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.kind === "text" && last.role === "agent") { + return [ + ...prev.slice(0, -1), + { + kind: "text" as const, + role: "agent" as const, + text: last.text + text, + }, + ]; + } + return [...prev, { kind: "text" as const, role: "agent" as const, text }]; + }); + }, []); + + const appendToolCall = useCallback((title: string) => { + setMessages((prev) => [...prev, { kind: "tool_call" as const, title }]); + }, []); + + const resolvePermission = useCallback( + (option: { optionId: string } | "cancelled") => { + if (!pendingPermission) return; + const { resolve } = pendingPermission; + if (option === "cancelled") { + resolve({ outcome: { outcome: "cancelled" } }); + } else { + resolve({ + outcome: { outcome: "selected", optionId: option.optionId }, + }); + } + setPendingPermission(null); + setPermissionIdx(0); + }, + [pendingPermission], + ); + + const sendPrompt = useCallback( + async (text: string) => { + const client = clientRef.current; + const sid = sessionIdRef.current; + if (!client || !sid) return; + + setMessages((prev) => [ + ...prev, + { kind: "text" as const, role: "user" as const, text }, + ]); + setLoading(true); + setStatus("thinking..."); + streamBuf.current = ""; + + try { + const result = await client.prompt({ + sessionId: sid, + prompt: [{ type: "text", text }], + }); + + if (streamBuf.current) { + appendAgent(""); + } + + setStatus( + result.stopReason === "end_turn" + ? "ready" + : `stopped: ${result.stopReason}`, + ); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + setStatus(`error: ${errMsg}`); + } finally { + setLoading(false); + } + }, + [appendAgent], + ); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + setStatus("initializing..."); + const stream = createHttpStream(serverUrl); + + const client = new GooseClient( + () => ({ + sessionUpdate: async (params: SessionNotification) => { + const update = params.update; + + if (update.sessionUpdate === "agent_message_chunk") { + if (update.content.type === "text") { + streamBuf.current += update.content.text; + appendAgent(update.content.text); + } + } else if (update.sessionUpdate === "tool_call") { + appendToolCall(update.title || "tool"); + } + }, + requestPermission: async ( + params: RequestPermissionRequest, + ): Promise => { + return new Promise((resolve) => { + const toolTitle = params.toolCall.title ?? "unknown tool"; + const options = params.options.map((opt) => ({ + optionId: opt.optionId, + name: opt.name, + kind: opt.kind, + })); + setPendingPermission({ toolTitle, options, resolve }); + setPermissionIdx(0); + }); + }, + }), + stream, + ); + + if (cancelled) return; + clientRef.current = client; + + setStatus("handshaking..."); + await client.initialize({ + protocolVersion: 0, + clientInfo: { name: "goose-text", version: "0.1.0" }, + clientCapabilities: {}, + }); + + if (cancelled) return; + + setStatus("creating session..."); + const session = await client.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + if (cancelled) return; + sessionIdRef.current = session.sessionId; + setLoading(false); + setStatus("ready"); + + if (initialPrompt && !sentInitialPrompt.current) { + sentInitialPrompt.current = true; + await sendPrompt(initialPrompt); + if (initialPrompt) { + setTimeout(() => exit(), 100); + } + } + } catch (e: unknown) { + if (cancelled) return; + const errMsg = e instanceof Error ? e.message : String(e); + setStatus(`failed: ${errMsg}`); + setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [serverUrl, initialPrompt, sendPrompt, appendAgent, appendToolCall, exit]); + + const handleSubmit = useCallback( + (value: string) => { + const trimmed = value.trim(); + if (!trimmed || loading) return; + setInput(""); + sendPrompt(trimmed); + }, + [loading, sendPrompt], + ); + + useInput((ch, key) => { + if (key.escape || (ch === "c" && key.ctrl)) { + if (pendingPermission) { + resolvePermission("cancelled"); + return; + } + exit(); + } + + if (pendingPermission) { + const opts = pendingPermission.options; + + if (key.upArrow) { + setPermissionIdx((i) => (i - 1 + opts.length) % opts.length); + return; + } + if (key.downArrow) { + setPermissionIdx((i) => (i + 1) % opts.length); + return; + } + if (key.return) { + const selected = opts[permissionIdx]; + if (selected) { + resolvePermission({ optionId: selected.optionId }); + } + return; + } + + const keyMap: Record = { + y: "allow_once", + a: "allow_always", + n: "reject_once", + N: "reject_always", + }; + const targetKind = keyMap[ch]; + if (targetKind) { + const match = opts.find((o) => o.kind === targetKind); + if (match) { + resolvePermission({ optionId: match.optionId }); + return; + } + } + } + }); + + const PAD_X = 2; + const PAD_BOTTOM = 1; + const innerWidth = Math.max(termWidth - PAD_X * 2, 20); + const headerHeight = 2; + const inputBarHeight = initialPrompt ? 0 : 2; + const bodyHeight = Math.max(termHeight - headerHeight - inputBarHeight - PAD_BOTTOM, 3); + + if (bannerVisible) { + return ( + + + + ); + } + + return ( + + + + + {messages.map((msg, i) => { + if (msg.kind === "tool_call") { + return ; + } + if (msg.role === "user") { + return ( + + {i > 0 && } + + + + ); + } + return ; + })} + + {pendingPermission && ( + + )} + + {loading && !pendingPermission && messages.length > 0 && ( + + )} + + + {!loading && !pendingPermission && !initialPrompt && ( + + )} + + ); +} diff --git a/ui/text/src/cli.tsx b/ui/text/src/cli.tsx new file mode 100644 index 0000000000..568890a1b9 --- /dev/null +++ b/ui/text/src/cli.tsx @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import React from "react"; +import { render } from "ink"; +import meow from "meow"; +import App from "./app.js"; + +const cli = meow( + ` + Usage + $ goose-text + + Options + --server, -s Server URL (default: http://127.0.0.1:3284) + --text, -t Send a single prompt and exit +`, + { + importMeta: import.meta, + flags: { + server: { type: "string", shortFlag: "s", default: "http://127.0.0.1:3284" }, + text: { type: "string", shortFlag: "t" }, + }, + } +); + +render(); diff --git a/ui/text/src/transport.ts b/ui/text/src/transport.ts new file mode 100644 index 0000000000..193b5a1375 --- /dev/null +++ b/ui/text/src/transport.ts @@ -0,0 +1,120 @@ +import type { AnyMessage, Stream } from "@agentclientprotocol/sdk"; + +const ACP_SESSION_HEADER = "Acp-Session-Id"; + +export function createHttpStream(serverUrl: string): Stream { + let sessionId: string | null = null; + const incoming: AnyMessage[] = []; + const waiters: Array<() => void> = []; + const sseAbort = new AbortController(); + + function pushMessage(msg: AnyMessage) { + incoming.push(msg); + const w = waiters.shift(); + if (w) w(); + } + + function waitForMessage(): Promise { + if (incoming.length > 0) return Promise.resolve(); + return new Promise((r) => waiters.push(r)); + } + + async function consumeSSE(response: Response) { + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split("\n\n"); + buffer = parts.pop() || ""; + + for (const part of parts) { + for (const line of part.split("\n")) { + if (line.startsWith("data: ")) { + try { + const msg = JSON.parse(line.slice(6)) as AnyMessage; + pushMessage(msg); + } catch { + } + } + } + } + } + } catch (e: unknown) { + if (e instanceof DOMException && e.name === "AbortError") return; + } + } + + // POST initialize (no session header) opens a long-lived SSE stream that receives + // ALL subsequent responses and notifications. Later POSTs with the session header + // are fire-and-forget for requests (responses arrive on the first stream) or + // return 202 immediately for notifications/responses. + let isFirstRequest = true; + + const readable = new ReadableStream({ + async pull(controller) { + await waitForMessage(); + while (incoming.length > 0) { + controller.enqueue(incoming.shift()!); + } + }, + }); + + const writable = new WritableStream({ + async write(msg) { + const isRequest = + "method" in msg && "id" in msg && msg.id !== undefined && msg.id !== null; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) { + headers[ACP_SESSION_HEADER] = sessionId; + } + + if (isFirstRequest && isRequest) { + isFirstRequest = false; + + const response = await fetch(`${serverUrl}/acp`, { + method: "POST", + headers, + body: JSON.stringify(msg), + signal: sseAbort.signal, + }); + + const sid = response.headers.get(ACP_SESSION_HEADER); + if (sid) sessionId = sid; + + consumeSSE(response); + } else if (isRequest) { + const abort = new AbortController(); + fetch(`${serverUrl}/acp`, { + method: "POST", + headers, + body: JSON.stringify(msg), + signal: abort.signal, + }).catch(() => {}); + setTimeout(() => abort.abort(), 200); + } else { + await fetch(`${serverUrl}/acp`, { + method: "POST", + headers, + body: JSON.stringify(msg), + }); + } + }, + + close() { + sseAbort.abort(); + }, + }); + + return { readable, writable }; +} diff --git a/ui/text/tsconfig.json b/ui/text/tsconfig.json new file mode 100644 index 0000000000..c3ee214ac3 --- /dev/null +++ b/ui/text/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src"] +}