diff --git a/Cargo.lock b/Cargo.lock index eeab7b562ab5..2a08acdecba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1277,9 +1277,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.6.1" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675f87afced0413c9bb02843499dbbd3882a237645883f71a2b59644a6d2f753" +checksum = "e9ec96fe9a81b5e365f9db71fe00edc4fe4ca2cc7dcb7861f0603012a7caa210" dependencies = [ "arrayref", "arrayvec", @@ -1409,9 +1409,9 @@ dependencies = [ [[package]] name = "bytesize" -version = "1.3.2" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2c12f985c78475a6b8d629afd0c360260ef34cfef52efccdcfd31972f81c2e" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" [[package]] name = "camino" @@ -1461,9 +1461,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.16" +version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ "jobserver", "libc", @@ -1632,9 +1632,9 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "cliclack" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a80570d35684e725e9d2d4aaaf32bc0cbfcfb8539898f9afea3da0d2e5189e4" +checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8" dependencies = [ "console", "indicatif", @@ -2701,9 +2701,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "etcetera" @@ -2809,13 +2809,13 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fd-lock" -version = "4.0.2" +version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 0.38.44", - "windows-sys 0.52.0", + "rustix 1.0.7", + "windows-sys 0.59.0", ] [[package]] @@ -2975,6 +2975,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fsst" version = "0.19.2" @@ -3305,6 +3314,7 @@ dependencies = [ "etcetera", "fs2", "futures", + "ignore", "include_dir", "indoc", "jsonschema", @@ -3345,6 +3355,16 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "tree-sitter", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", "unicode-normalization", "url", "urlencoding", @@ -3399,6 +3419,7 @@ dependencies = [ "goose-bench", "goose-mcp", "http 1.2.0", + "ignore", "indicatif", "is-terminal", "jsonschema", @@ -3406,9 +3427,11 @@ dependencies = [ "mcp-core", "mcp-server", "nix 0.30.1", + "notify", "once_cell", "rand 0.8.5", "regex", + "reqwest 0.12.12", "rmcp", "rustyline", "serde", @@ -3425,6 +3448,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "walkdir", "webbrowser 1.0.4", "winapi", ] @@ -4208,6 +4232,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -4426,6 +4470,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lance" version = "0.19.2" @@ -4999,9 +5063,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.21" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9b68e50e6e0b26f672573834882eb57759f6db9b3be2ea3c35c91188bb4eaa" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" dependencies = [ "cc", "libc", @@ -5314,6 +5378,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -5502,6 +5578,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.9.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "serde", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -6175,13 +6270,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.0" +version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64 0.22.1", "indexmap 2.7.1", - "quick-xml 0.32.0", + "quick-xml 0.38.0", "serde", "time", ] @@ -6245,9 +6340,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "powerfmt" @@ -6511,18 +6606,19 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" dependencies = [ "memchr", + "serde", ] [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" dependencies = [ "memchr", "serde", @@ -6530,12 +6626,11 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" dependencies = [ "memchr", - "serde", ] [[package]] @@ -6986,9 +7081,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.14" +version = "0.17.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" dependencies = [ "cc", "cfg-if", @@ -8192,11 +8287,11 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ - "rustix 0.38.44", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -8425,7 +8520,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -8769,6 +8864,114 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax 0.8.5", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67f06accca7b45351758663b8215089e643d53bd9a660ce0349314263737fcb0" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65aeb41726119416567d0333ec17580ac4abfb96db1f67c4bd638c65f9992fe" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.14" @@ -9852,9 +10055,9 @@ dependencies = [ [[package]] name = "xattr" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" dependencies = [ "libc", "rustix 1.0.7", diff --git a/Justfile b/Justfile index d5f32e85d962..f8c119d82d7e 100644 --- a/Justfile +++ b/Justfile @@ -117,6 +117,12 @@ copy-binary-windows: @powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goosed.exe) { \ Write-Host 'Copying Windows binary and DLLs to ui/desktop/src/bin...'; \ Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goosed.exe' -Destination './ui/desktop/src/bin/' -Force; \ + if (Test-Path ./target/x86_64-pc-windows-gnu/release/goose.exe) { \ + Write-Host 'Copying Windows goose CLI binary...'; \ + Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goose.exe' -Destination './ui/desktop/src/bin/' -Force; \ + } else { \ + Write-Host 'Windows goose CLI binary not found.' -ForegroundColor Yellow; \ + } \ Copy-Item -Path './target/x86_64-pc-windows-gnu/release/*.dll' -Destination './ui/desktop/src/bin/' -Force; \ } else { \ Write-Host 'Windows binary not found.' -ForegroundColor Red; \ @@ -234,6 +240,12 @@ make-ui-windows: mkdir -p ./ui/desktop/src/bin && \ echo "Copying Windows binary and DLLs..." && \ cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/ && \ + if [ -f "./target/x86_64-pc-windows-gnu/release/goose.exe" ]; then \ + echo "Copying Windows goose CLI binary..." && \ + cp -f ./target/x86_64-pc-windows-gnu/release/goose.exe ./ui/desktop/src/bin/ ; \ + else \ + echo "Windows goose CLI binary not found." ; \ + fi && \ cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/ && \ echo "Starting Windows package build..." && \ (cd ui/desktop && npm run bundle:windows) && \ diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 89b4b0e54650..0e2c8c7ff1d1 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -10,12 +10,10 @@ description.workspace = true [lints] workspace = true -[[bin]] -name = "goose" -path = "src/main.rs" + [dependencies] -goose = { path = "../goose" } +goose = { path = "../goose", features = ["repo-index"] } goose-bench = { path = "../goose-bench" } goose-mcp = { path = "../goose-mcp" } mcp-client = { path = "../mcp-client" } @@ -36,6 +34,20 @@ serde = { version = "1.0", features = ["derive"] } # For serialization serde_yaml = "0.9" tempfile = "3" etcetera = "0.8.0" +reqwest = { version = "0.12.9", features = [ + "rustls-tls-native-roots", + "json", + "cookies", + "gzip", + "brotli", + "deflate", + "zstd", + "charset", + "http2", + "stream" + ], default-features = false } +walkdir = "2.5" +ignore = "0.4" rand = "0.8.5" rustyline = "15.0.0" tracing = "0.1" @@ -49,22 +61,33 @@ base64 = "0.22.1" regex = "1.11.1" nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" +# File watching for optional background auto re-index (only activates when env enables) +notify = { version = "6.1", default-features = false, features = ["macos_fsevent", "serde"] } # Web server dependencies axum = { version = "0.8.1", features = ["ws", "macros"] } tower-http = { version = "0.5", features = ["cors", "fs"] } http = "1.0" webbrowser = "1.0" + + indicatif = "0.17.11" tokio-util = "0.7.15" is-terminal = "0.4.16" anstream = "0.6.18" -[target.'cfg(target_os = "windows")'.dependencies] -winapi = { version = "0.3", features = ["wincred"] } - - [dev-dependencies] tempfile = "3" temp-env = { version = "0.3.6", features = ["async_closure"] } test-case = "3.3" tokio = { version = "1.43", features = ["rt", "macros"] } + +[features] +# Forward the repo-index feature flag so conditional compilation in CLI works cleanly +repo-index = [] + +[[bin]] +name = "goose" +path = "src/main.rs" + +[target.'cfg(target_os = "windows")'.dependencies] +winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/background_index.rs b/crates/goose-cli/src/background_index.rs new file mode 100644 index 000000000000..d5d9feafa557 --- /dev/null +++ b/crates/goose-cli/src/background_index.rs @@ -0,0 +1,127 @@ +use std::{path::{Path, PathBuf}, sync::{Arc, atomic::{AtomicBool, Ordering}}, time::{Duration, Instant}}; +use tokio::task::JoinHandle; +use tokio::sync::OnceCell; +use tracing::{info, warn, error, debug, trace, span, Level}; +use anyhow::Result; + +static STARTED: OnceCell<()> = OnceCell::const_new(); + +#[derive(Clone, Debug)] +pub struct AutoIndexConfig { + pub root: PathBuf, + pub output: PathBuf, + pub enable_watch: bool, + pub debounce: Duration, + pub initial_delay: Duration, + pub quiet: bool, +} + +impl AutoIndexConfig { + pub fn from_env(root: &Path) -> Option { + if std::env::var("ALPHA_FEATURES").ok().as_deref() != Some("true") { return None; } + // Opt-out variable + if std::env::var("GOOSE_AUTO_INDEX").map(|v| v=="0" || v.to_lowercase()=="false").unwrap_or(false) { return None; } + let enable_watch = std::env::var("GOOSE_AUTO_INDEX_WATCH").map(|v| v=="1" || v.to_lowercase()=="true").unwrap_or(false); + let debounce_ms = std::env::var("GOOSE_AUTO_INDEX_DEBOUNCE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500u64); + let initial_delay_ms = std::env::var("GOOSE_AUTO_INDEX_INITIAL_DELAY_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500u64); + Some(Self { root: root.to_path_buf(), output: root.join(".goose-repo-index.jsonl"), enable_watch, debounce: Duration::from_millis(debounce_ms), initial_delay: Duration::from_millis(initial_delay_ms), quiet: false }) + } +} + +fn should_skip_repo(root: &Path) -> bool { + // Heuristic: skip if no "src" or "lib" or very small (# source files < 5) to save cycles + let mut source_like = 0usize; + if let Ok(read) = std::fs::read_dir(root) { + for entry in read.flatten().take(200) { // cheap scan + let p = entry.path(); + if p.is_dir() { continue; } + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + match ext { "rs"|"py"|"js"|"ts"|"go"|"java"|"cs"|"cpp"|"cxx"|"cc"|"swift" => { source_like += 1; if source_like >= 5 { break; } }, _=>{} } + } + } + } + source_like < 5 +} + +pub async fn spawn_background_index() -> Option> { + // Only run once per process + if STARTED.set(()).is_err() { return None; } + let root = match std::env::current_dir() { Ok(r) => r, Err(_) => return None }; + let cfg = match AutoIndexConfig::from_env(&root) { Some(c) => c, None => return None }; + if should_skip_repo(&cfg.root) { debug!(?cfg.root, "auto-index skipped: too few source files"); return None; } + let lock_path = cfg.root.join(".goose-repo-index.lock"); + if lock_path.exists() { debug!(?lock_path, "auto-index lock exists, skipping"); return None; } + if std::fs::write(&lock_path, b"indexing") .is_err() { return None; } + info!(root=%cfg.root.display(), watch=%cfg.enable_watch, "Starting background repo index (initial delay {:?})", cfg.initial_delay); + + Some(tokio::spawn(async move { + // Initial delay to avoid impacting startup latency + tokio::time::sleep(cfg.initial_delay).await; + if let Err(e) = run_index_once(&cfg).await { error!(error=?e, "background index failed"); } + else { info!(root=%cfg.root.display(), "Background index complete"); } + if cfg.enable_watch { if let Err(e) = watch_loop(&cfg).await { warn!(error=?e, "auto-index watch loop terminated"); } } + let _ = std::fs::remove_file(&lock_path); + })) +} + +async fn run_index_once(cfg: &AutoIndexConfig) -> Result<()> { + use goose::repo_index::RepoIndexOptions; + use std::fs::File; + use std::time::{SystemTime, UNIX_EPOCH}; + let mut builder = RepoIndexOptions::builder().root(&cfg.root); + let write_file = std::env::var("GOOSE_AUTO_INDEX_WRITE_FILE").map(|v| v=="1" || v.to_lowercase()=="true").unwrap_or(false); + let mut maybe_file; + if write_file { maybe_file = Some(File::create(&cfg.output)?); builder = builder.output_writer(maybe_file.as_mut().unwrap()); } + else { builder = builder.output_null(); maybe_file = None; } + let opts = builder.build(); + let build_start = std::time::Instant::now(); + let stats = goose::repo_index::index_repository(opts)?; + let elapsed = build_start.elapsed(); + let duration_ms = stats.duration.as_millis(); + if !cfg.quiet { if write_file { eprintln!("(background) indexed {} files / {} entities (wrote {})", stats.files_indexed, stats.entities_indexed, cfg.output.display()); } else { eprintln!("(background) indexed {} files / {} entities (no file output)", stats.files_indexed, stats.entities_indexed); } } + info!(counter.goose.repo.builds = 1, event="repo.index.build", root=%cfg.root.display(), background=true, files=stats.files_indexed, entities=stats.entities_indexed, duration_ms=elapsed.as_millis() as u64, wrote_file=write_file, trigger="background", "Background repository index build complete"); + // Write meta file for status command consumers + let meta_path = cfg.root.join(".goose-repo-index.meta.json"); + let meta = serde_json::json!({ + "files_indexed": stats.files_indexed, + "entities_indexed": stats.entities_indexed, + "duration_ms": duration_ms, + "wrote_file": write_file, + "output_file": if write_file { Some(cfg.output.file_name().unwrap().to_string_lossy().to_string()) } else { None }, + "timestamp": SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + }); + if let Err(e) = std::fs::write(&meta_path, serde_json::to_vec_pretty(&meta)?) { warn!(error=?e, "failed to write meta file"); } + Ok(()) +} + +async fn watch_loop(cfg: &AutoIndexConfig) -> Result<()> { + use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind}; + use tokio::sync::mpsc; + let (tx, mut rx) = mpsc::channel(64); + let root = cfg.root.clone(); + let debounce = cfg.debounce; + // Wrap notify watcher in blocking task -> channel + let _blocking = std::thread::spawn(move || { + let tx2 = tx.clone(); + let mut watcher: RecommendedWatcher = RecommendedWatcher::new(|res| { + if let Ok(event) = res { let _ = tx2.blocking_send(event); } + }, notify::Config::default()).expect("watcher"); + let _ = watcher.watch(&root, RecursiveMode::Recursive); + // park thread until process exit + loop { std::thread::park(); } + }); + + let mut last_change: Option = None; + let mut pending = false; + let mut ticker = tokio::time::interval(Duration::from_millis(500)); + loop { + tokio::select! { + maybe_evt = rx.recv() => { + if let Some(evt) = maybe_evt { match evt.kind { EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => { last_change = Some(Instant::now()); pending = true; }, _=>{} } } + } + _ = ticker.tick() => { + if pending { if let Some(ts) = last_change { if ts.elapsed() >= debounce { pending = false; if let Err(e) = run_index_once(cfg).await { warn!(error=?e, "background re-index failed"); } else { info!(counter.goose.repo.builds = 1, event="repo.index.build", root=%cfg.root.display(), background=true, trigger="watch", reason="watch", "Re-index complete (watch)"); } } } } + } + } + } +} diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ad0bd828ef69..4e5a975dcdbc 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -30,7 +30,7 @@ use std::path::PathBuf; #[derive(Parser)] #[command(author, version, display_name = "", about, long_about = None)] struct Cli { - #[command(subcommand)] + #[clap(subcommand)] command: Option, } @@ -272,6 +272,85 @@ enum RecipeCommand { }, } +#[derive(Subcommand)] +enum RepoCommand { + #[command(about = "Show latest background or manual repository index status")] + Status { + /// Path to the root (default: current directory) + #[arg(long, value_name = "PATH", default_value = ".", help = "Path to repo root")] + path: String, + /// Output as JSON + #[arg(long, help = "Emit raw JSON meta if available")] + json: bool, + }, + #[command(about = "Query repository symbols (exact name match) using in-memory index")] + Query { + /// Path to the root of the repository to query (default: current directory) + #[arg( + long, + value_name = "PATH", + help = "Path to the root of the repository to index/query", + default_value = "." + )] + path: String, + + /// Symbol name to search for (exact, case-insensitive) + #[arg( + value_name = "SYMBOL", + help = "Symbol name to search for (exact match, case-insensitive)" + )] + symbol: String, + + /// Limit number of results + #[arg( + long = "limit", + value_name = "N", + help = "Limit number of results", + default_value = "20" + )] + limit: usize, + + /// Include doc excerpt in output + #[arg( + long = "docs", + help = "Include first line of doc comment in output" + )] + docs: bool, + + /// Depth to also print callers/callees (0 = none) + #[arg( + long = "graph-depth", + value_name = "DEPTH", + help = "Include callers/callees up to this depth (0 disables)", + default_value = "0" + )] + graph_depth: u32, + + /// Force exact (case-insensitive) match only (disables fuzzy + rank blend) + #[arg( + long = "exact-only", + help = "Force exact match only (disable fuzzy search)" + )] + exact_only: bool, + + /// Show blended fuzzy+rank score alongside rank + #[arg( + long = "show-score", + help = "Show blended fuzzy+rank score in output" + )] + show_score: bool, + + /// Minimum blended score (0-1) required to include a result (applies only to fuzzy mode) + #[arg( + long = "min-score", + value_name = "FLOAT", + help = "Minimum blended score (0-1) to include a fuzzy result", + default_value = "0.0" + )] + min_score: f32, + }, +} + #[derive(Subcommand)] enum Command { /// Configure Goose settings @@ -386,6 +465,21 @@ enum Command { builtins: Vec, }, + /// Open the last project directory + #[command(about = "Open the last project directory", visible_alias = "p")] + Project {}, + + /// List recent project directories + #[command(about = "List recent project directories", visible_alias = "ps")] + Projects, + + /// Repository indexing and analysis + #[command(about = "Repository indexing and analysis tools")] + Repo { + #[command(subcommand)] + command: Option, + }, + /// Execute commands from an instruction file #[command(about = "Execute commands from an instruction file or stdin")] Run { @@ -691,11 +785,25 @@ pub struct RecipeInfo { pub async fn cli() -> Result<()> { let cli = Cli::parse(); + // Fire-and-forget background repo indexing (auto) for qualifying commands. + // Only runs when ALPHA_FEATURES + not explicitly disabled via GOOSE_AUTO_INDEX=0. + // Safe to call early; function internally ensures single spawn. + #[cfg(feature = "repo-index")] + let _bg_index_handle = crate::background_index::spawn_background_index(); + + // Experimental gating: hide repo indexing commands unless ALPHA_FEATURES=true. + // We still compile the code (feature flag controls code inclusion) but we avoid + // exposing the subcommand at runtime for general users unless explicitly opted in. + let alpha_enabled = std::env::var("ALPHA_FEATURES").map(|v| v == "true").unwrap_or(false); + let command_name = match &cli.command { Some(Command::Configure {}) => "configure", Some(Command::Info { .. }) => "info", Some(Command::Mcp { .. }) => "mcp", Some(Command::Session { .. }) => "session", + Some(Command::Repo { .. }) => if alpha_enabled { "repo" } else { "(repo-hidden)" }, + Some(Command::Project {}) => "project", + Some(Command::Projects) => "projects", Some(Command::Run { .. }) => "run", Some(Command::Schedule { .. }) => "schedule", Some(Command::Update { .. }) => "update", @@ -712,6 +820,93 @@ pub async fn cli() -> Result<()> { ); match cli.command { + Some(Command::Repo { command }) => { + if !alpha_enabled { + eprintln!("Repository commands are experimental. Set ALPHA_FEATURES=true to enable."); + return Ok(()); + } + match command { + Some(RepoCommand::Status { path, json }) => { + crate::commands::repo::status_repository(&path, json)?; + return Ok(()); + } + Some(RepoCommand::Query { path, symbol: _symbol, limit: _limit, docs: _docs, graph_depth: _graph_depth, exact_only: _exact_only, show_score: _show_score, min_score: _min_score }) => { + // Build the in-memory index service and perform a query + #[cfg(feature = "repo-index")] + { + use std::path::Path; + use goose::repo_index::RepoIndexOptions; + use goose::repo_index::service::RepoIndexService; + // Build options using builder (no output file needed for in-memory service) + let fake_output_path = Path::new("/dev/null"); // required by builder; service does its own walk + let opts = RepoIndexOptions::builder() + .root(Path::new(&path)) + .output_file(fake_output_path) + .build(); + match RepoIndexService::build(opts) { + Ok((svc, stats)) => { + // Summaries to ensure variables are used (avoid unused warnings) and give context + // (Only printed at verbose depth conditions to stay concise) + // Determine search mode (fuzzy+rank vs exact) + let mut scored: Vec<(f32, &goose::repo_index::service::StoredEntity)> = Vec::new(); + if !_exact_only { + let q = _symbol.to_lowercase(); + let mut min_rank = f32::MAX; let mut max_rank = f32::MIN; + for e in &svc.entities { if e.rank < min_rank { min_rank = e.rank; } if e.rank > max_rank { max_rank = e.rank; } } + let rank_range = if (max_rank - min_rank).abs() < 1e-9 { 1.0 } else { max_rank - min_rank }; + fn levenshtein(a:&str,b:&str)->usize{ let mut dp:Vec=(0..=b.len()).collect(); for (i,ca) in a.chars().enumerate(){ let mut prev=dp[0]; dp[0]=i+1; for (j,cb) in b.chars().enumerate(){ let temp=dp[j+1]; dp[j+1]= if ca==cb { prev } else { 1+prev.min(dp[j]).min(dp[j+1]) }; prev=temp; } } *dp.last().unwrap() } + for e in &svc.entities { if e.kind.as_str()=="file" { continue; } let name_lower=e.name.to_lowercase(); let mut lex=0.0f32; if name_lower==q { lex=1.0; } else if name_lower.starts_with(&q){ lex=0.8; } else if name_lower.contains(&q){ lex=0.5; } else { let d=levenshtein(&name_lower,&q); if d<=2 { lex=(0.3-0.1*d as f32).max(0.0);} } if lex>0.0 { let norm_rank=(e.rank-min_rank)/rank_range; let blended=lex*0.6+norm_rank*0.4; if blended >= _min_score { scored.push((blended,e)); } } } + scored.sort_by(|a,b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + if scored.len() > _limit { scored.truncate(_limit); } + } + if _exact_only || scored.is_empty() { + let mut exact = svc.search_symbol_exact(&_symbol); + if exact.len() > _limit { exact.truncate(_limit); } + if exact.is_empty() { + println!("No matches for '{}' (indexed {} files, {} entities, path={})", _symbol, stats.files_indexed, stats.entities_indexed, path); + return Ok(()); + } + println!("Top {} exact matches for '{}' (searched {} files / {} entities, path={})", exact.len(), _symbol, stats.files_indexed, stats.entities_indexed, path); + for e in exact.iter() { let file=&svc.files[e.file_id as usize]; if _docs { let doc_first=e.doc.as_deref().and_then(|d| d.lines().next()).unwrap_or(""); if doc_first.is_empty(){ println!("{:.4} {}:{}-{} | {} {} | {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} else { println!("{:.4} {}:{}-{} | {} {} | {} // {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature, doc_first);} } else { println!("{:.4} {}:{}-{} | {} {} | {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} } + if _graph_depth > 0 { for e in exact.iter(){ let callees=svc.callees_up_to(e.id,_graph_depth); if !callees.is_empty(){ let names:Vec<&str>=callees.iter().filter_map(|id|svc.entities.get(*id as usize).map(|se|se.name.as_str())).take(15).collect(); println!(" {} callees(depth<= {}): {}", e.name,_graph_depth,names.join(", ")); } let callers=svc.callers_up_to(e.id,_graph_depth); if !callers.is_empty(){ let names:Vec<&str>=callers.iter().filter_map(|id|svc.entities.get(*id as usize).map(|se|se.name.as_str())).take(15).collect(); println!(" {} callers(depth<= {}): {}", e.name,_graph_depth,names.join(", ")); } } } + } else { + println!("Top {} fuzzy+rank matches for '{}' (min_score={} searched {} files / {} entities, path={})", scored.len(), _symbol, _min_score, stats.files_indexed, stats.entities_indexed, path); + for (score,e) in scored.iter() { let file=&svc.files[e.file_id as usize]; if _docs { let doc_first=e.doc.as_deref().and_then(|d| d.lines().next()).unwrap_or(""); if doc_first.is_empty(){ if _show_score { println!("{:.4} {:.4} {}:{}-{} | {} {} | {}", e.rank, score, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} else { println!("{:.4} {}:{}-{} | {} {} | {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} } else { if _show_score { println!("{:.4} {:.4} {}:{}-{} | {} {} | {} // {}", e.rank, score, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature, doc_first);} else { println!("{:.4} {}:{}-{} | {} {} | {} // {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature, doc_first);} } } else { if _show_score { println!("{:.4} {:.4} {}:{}-{} | {} {} | {}", e.rank, score, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} else { println!("{:.4} {}:{}-{} | {} {} | {}", e.rank, file.path, e.start_line, e.end_line, e.kind.as_str(), e.name, e.signature);} } } + if _graph_depth > 0 { for (_,e) in scored.iter(){ let callees=svc.callees_up_to(e.id,_graph_depth); if !callees.is_empty(){ let names:Vec<&str>=callees.iter().filter_map(|id|svc.entities.get(*id as usize).map(|se|se.name.as_str())).take(15).collect(); println!(" {} callees(depth<= {}): {}", e.name,_graph_depth,names.join(", ")); } let callers=svc.callers_up_to(e.id,_graph_depth); if !callers.is_empty(){ let names:Vec<&str>=callers.iter().filter_map(|id|svc.entities.get(*id as usize).map(|se|se.name.as_str())).take(15).collect(); println!(" {} callers(depth<= {}): {}", e.name,_graph_depth,names.join(", ")); } } } + } + // Summarize unresolved imports count as additional context using path just for display + let unresolved_total: usize = svc.unresolved_imports.iter().map(|v| v.len()).sum(); + if unresolved_total > 0 { + println!("Unresolved imports across repo ({}): {}", path, unresolved_total); + } + } + Err(err) => { + eprintln!("Query failed: {err}"); + } + } + return Ok(()); + } + #[cfg(not(feature = "repo-index"))] + { + // Mark variable as used to avoid unused warning when feature is disabled + let _ = &path; + eprintln!("Repo query requires building with --features repo-index"); + return Ok(()); + } + } + _ => { println!("Usage: goose repo status --path [--json]"); return Ok(()); } + } + } + Some(Command::Project {}) => { + // TODO: implement project open behavior (placeholder) + eprintln!("Project command not yet implemented"); + return Ok(()); + } + Some(Command::Projects) => { + // TODO: implement projects listing behavior (placeholder) + eprintln!("Projects command not yet implemented"); + return Ok(()); + } Some(Command::Configure {}) => { let _ = handle_configure().await; return Ok(()); @@ -1108,6 +1303,9 @@ pub async fn cli() -> Result<()> { return Ok(()); } Some(Command::Web { port, host, open }) => { + // Ensure background index also active for web server lifecycle. + #[cfg(feature = "repo-index")] + { let _ = crate::background_index::spawn_background_index(); } crate::commands::web::handle_web(port, host, open).await?; return Ok(()); } diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index a52216d01b5b..98a2b8b91552 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod configure; pub mod info; pub mod mcp; pub mod recipe; +pub mod repo; pub mod schedule; pub mod session; pub mod update; diff --git a/crates/goose-cli/src/commands/repo.rs b/crates/goose-cli/src/commands/repo.rs new file mode 100644 index 000000000000..ecfacdcf7f8b --- /dev/null +++ b/crates/goose-cli/src/commands/repo.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use std::path::Path; + +pub fn status_repository(root_path: &str, json: bool) -> Result<()> { + let root = Path::new(root_path); + let meta_path = root.join(".goose-repo-index.meta.json"); + if !meta_path.exists() { + println!("No index meta file found (path {}). Enable ALPHA_FEATURES and allow an auto-build by using repo tools or start background indexing.", meta_path.display()); + return Ok(()); + } + let data = std::fs::read_to_string(&meta_path)?; + if json { + println!("{}", data); + return Ok(()); + } + let v: serde_json::Value = serde_json::from_str(&data)?; + let files = v.get("files_indexed").and_then(|x| x.as_u64()).unwrap_or(0); + let ents = v.get("entities_indexed").and_then(|x| x.as_u64()).unwrap_or(0); + let dur = v.get("duration_ms").and_then(|x| x.as_u64()).unwrap_or(0); + let wrote = v.get("wrote_file").and_then(|x| x.as_bool()).unwrap_or(false); + let out_file = v.get("output_file").and_then(|x| x.as_str()).unwrap_or("-"); + println!("Indexed {} files / {} entities in {}ms{}", files, ents, dur, if wrote { format!(" (file: {})", out_file) } else { " (in-memory)".to_string() }); + Ok(()) +} diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index f2e6a3591b63..34955f32c845 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -3,6 +3,8 @@ use once_cell::sync::Lazy; pub mod cli; pub mod commands; pub mod logging; +#[cfg(feature = "repo-index")] +pub mod background_index; pub mod recipes; pub mod scenario_tests; pub mod session; diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 3459ca8069a8..11cb0578af25 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -21,6 +21,7 @@ pub enum InputResult { Clear, Recipe(Option), Summarize, + IndexRepo(Option), // optional path argument } #[derive(Debug)] @@ -121,6 +122,7 @@ fn handle_slash_command(input: &str) -> Option { const CMD_CLEAR: &str = "/clear"; const CMD_RECIPE: &str = "/recipe"; const CMD_SUMMARIZE: &str = "/summarize"; + const CMD_INDEX: &str = "/index"; match input { "/exit" | "/quit" => Some(InputResult::Exit), @@ -181,10 +183,19 @@ fn handle_slash_command(input: &str) -> Option { s if s == CMD_CLEAR => Some(InputResult::Clear), s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s), s if s == CMD_SUMMARIZE => Some(InputResult::Summarize), + s if s.starts_with(CMD_INDEX) => parse_index_command(s), _ => None, } } +fn parse_index_command(s: &str) -> Option { + const CMD_INDEX: &str = "/index"; + if s == CMD_INDEX { return Some(InputResult::IndexRepo(None)); } + let rest = s[CMD_INDEX.len()..].trim(); + if rest.is_empty() { return Some(InputResult::IndexRepo(None)); } + Some(InputResult::IndexRepo(Some(rest.to_string()))) +} + fn parse_recipe_command(s: &str) -> Option { const CMD_RECIPE: &str = "/recipe"; @@ -291,6 +302,7 @@ fn print_help() { /recipe [filepath] - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml). If no filepath is provided, it will be saved to ./recipe.yaml. /summarize - Summarize the current conversation to reduce context length while preserving key information. +/index [path] - Build a repository index (Tree-sitter). Optional path (defaults to current working directory). /? or /help - Display this help message /clear - Clears the current chat history diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 5a5fe1f80e6b..1b86c9ab8d8e 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -764,6 +764,48 @@ impl Session { continue; } + InputResult::IndexRepo(path_opt) => { + save_history(&mut editor); + let target = path_opt.unwrap_or_else(|| ".".to_string()); + println!("{}", console::style(format!("Indexing repository at {}...", target)).yellow()); + output::show_thinking(); + #[cfg(feature = "repo-index")] + { + let opts = goose::repo_index::RepoIndexOptions::builder() + .root(std::path::Path::new(&target)) + .output_file(std::path::Path::new("/dev/null")) + .build(); + match goose::repo_index::index_repository(opts) { + Ok(stats) => { + output::hide_thinking(); + println!( + "{}", + console::style(format!( + "Indexed {} files / {} entities in {:?}", + stats.files_indexed, stats.entities_indexed, stats.duration + )) + .green() + ); + } + Err(e) => { + output::hide_thinking(); + println!( + "{}", + console::style(format!("Index failed: {}", e)).red() + ); + } + } + } + #[cfg(not(feature = "repo-index"))] + { + output::hide_thinking(); + println!( + "{}", + console::style("Goose was compiled without repo-index feature").red() + ); + } + continue; + } } } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 473b6320dcdc..f04923d48457 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -77,6 +77,19 @@ utoipa = { version = "4.1", features = ["chrono"] } tokio-cron-scheduler = "0.14.0" urlencoding = "2.1" +# Optional repository indexing feature (Tree-sitter based) +ignore = { version = "0.4", optional = true } +tree-sitter = { version = "0.23", optional = true } +tree-sitter-rust = { version = "0.23", optional = true } +tree-sitter-python = { version = "0.23", package = "tree-sitter-python", optional = true } +tree-sitter-javascript = { version = "0.23", package = "tree-sitter-javascript", optional = true } +tree-sitter-typescript = { version = "0.23", package = "tree-sitter-typescript", optional = true } +tree-sitter-cpp = { version = "0.23", optional = true } +tree-sitter-java = { version = "0.23", package = "tree-sitter-java", optional = true } +tree-sitter-c-sharp = { version = "0.23", package = "tree-sitter-c-sharp", optional = true } +tree-sitter-swift = { version = "0.6", package = "tree-sitter-swift", optional = true } +tree-sitter-go = { version = "0.23", package = "tree-sitter-go", optional = true } + # For Bedrock provider aws-config = { version = "1.5.16", features = ["behavior-version-latest"] } aws-smithy-types = "1.2.13" @@ -116,6 +129,21 @@ dotenvy = "0.15.7" ctor = "0.2.9" test-case = "3.3" +[features] +repo-index = [ + "ignore", + "tree-sitter", + "tree-sitter-rust", + "tree-sitter-python", + "tree-sitter-javascript", + "tree-sitter-typescript", + "tree-sitter-cpp", + "tree-sitter-java", + "tree-sitter-c-sharp", + "tree-sitter-swift", + "tree-sitter-go", +] + [[example]] name = "agent" path = "examples/agent.rs" diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index a093cf314eb6..7ee40a37790c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -154,6 +154,8 @@ where } impl Agent { + // Repo tool helpers removed: inlined handling in dispatch_tool_call to reduce async stream complexity + const DEFAULT_TODO_MAX_CHARS: usize = 50_000; fn get_todo_max_chars() -> usize { @@ -373,6 +375,7 @@ impl Agent { /// Dispatch a single tool call to the appropriate client #[instrument(skip(self, tool_call, request_id), fields(input, output))] + #[inline(never)] pub async fn dispatch_tool_call( &self, tool_call: mcp_core::tool::ToolCall, @@ -530,6 +533,26 @@ impl Agent { "Updated ({} chars)", char_count ))])) + } else if tool_call.name == crate::agents::repo_tools::REPO_QUERY_TOOL_NAME { + #[cfg(feature = "repo-index")] + { + match crate::agents::repo_tools::handle_repo_query(tool_call.arguments.clone()).await { + Ok(v) => ToolCallResult::from(Ok(vec![Content::text(v.to_string())])), + Err(e) => ToolCallResult::from(Err(ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))) + } + } + #[cfg(not(feature = "repo-index"))] + { ToolCallResult::from(Ok(vec![Content::text("repo-index feature disabled".to_string())])) } + } else if tool_call.name == crate::agents::repo_tools::REPO_STATS_TOOL_NAME { + #[cfg(feature = "repo-index")] + { + match crate::agents::repo_tools::handle_repo_stats(tool_call.arguments.clone()).await { + Ok(v) => ToolCallResult::from(Ok(vec![Content::text(v.to_string())])), + Err(e) => ToolCallResult::from(Err(ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))) + } + } + #[cfg(not(feature = "repo-index"))] + { ToolCallResult::from(Ok(vec![Content::text("repo-index feature disabled".to_string())])) } } else if tool_call.name == ROUTER_VECTOR_SEARCH_TOOL_NAME || tool_call.name == ROUTER_LLM_SEARCH_TOOL_NAME { @@ -752,11 +775,13 @@ impl Agent { .unwrap_or_default(); if extension_name.is_none() || extension_name.as_deref() == Some("platform") { - // Add platform tools + // Add platform + repo index tools prefixed_tools.extend([ platform_tools::search_available_extensions_tool(), platform_tools::manage_extensions_tool(), platform_tools::manage_schedule_tool(), + crate::agents::repo_tools::repo_query_tool(), + crate::agents::repo_tools::repo_stats_tool(), ]); // Add task planner tools @@ -939,12 +964,12 @@ impl Agent { session: Option, cancel_token: Option, ) -> Result>> { - let context = self.prepare_reply_context(messages, &session).await?; + let context = self.prepare_reply_context(messages, &session).await?; let ReplyContext { - mut messages, - mut tools, - mut toolshim_tools, - mut system_prompt, + messages, + tools, + toolshim_tools, + system_prompt, goose_mode, initial_messages, config, @@ -960,7 +985,35 @@ impl Agent { debug!("user_message" = &content); } - Ok(Box::pin(async_stream::try_stream! { + Ok(Box::pin(self.reply_internal_stream( + reply_span, + messages, + session, + cancel_token, + tools, + toolshim_tools, + system_prompt, + goose_mode, + initial_messages, + config, + ))) + } + + #[inline(never)] + fn reply_internal_stream<'a>( + &'a self, + reply_span: tracing::Span, + mut messages: Conversation, + session: Option, + cancel_token: Option, + mut tools: Vec, + mut toolshim_tools: Vec, + mut system_prompt: String, + goose_mode: String, + initial_messages: Vec, + config: &'static Config, + ) -> impl Stream> + 'a { + async_stream::try_stream! { let _ = reply_span.enter(); let mut turns_taken = 0u32; let max_turns = session @@ -971,25 +1024,16 @@ impl Agent { }); loop { - if is_token_cancelled(&cancel_token) { - break; - } - + if is_token_cancelled(&cancel_token) { break; } if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { if final_output_tool.final_output.is_some() { - let final_event = AgentEvent::Message( - Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()), - ); - yield final_event; + yield AgentEvent::Message(Message::assistant().with_text(final_output_tool.final_output.clone().unwrap())); break; } } - turns_taken += 1; if turns_taken > max_turns { - yield AgentEvent::Message(Message::assistant().with_text( - "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - )); + yield AgentEvent::Message(Message::assistant().with_text("I've reached the maximum number of actions I can do without user input. Would you like me to continue?")); break; } @@ -1006,224 +1050,66 @@ impl Agent { let mut tools_updated = false; while let Some(next) = stream.next().await { - if is_token_cancelled(&cancel_token) { - break; - } - + if is_token_cancelled(&cancel_token) { break; } match next { Ok((response, usage)) => { - // Emit model change event if provider is lead-worker let provider = self.provider().await?; if let Some(lead_worker) = provider.as_lead_worker() { if let Some(ref usage) = usage { let active_model = usage.model.clone(); let (lead_model, worker_model) = lead_worker.get_model_info(); - let mode = if active_model == lead_model { - "lead" - } else if active_model == worker_model { - "worker" - } else { - "unknown" - }; - - yield AgentEvent::ModelChange { - model: active_model, - mode: mode.to_string(), - }; + let mode = if active_model == lead_model {"lead"} else if active_model == worker_model {"worker"} else {"unknown"}; + yield AgentEvent::ModelChange { model: active_model, mode: mode.to_string() }; } } - - // Record usage for the session - if let Some(ref session_config) = &session { - if let Some(ref usage) = usage { - Self::update_session_metrics(session_config, usage, messages.len()) - .await?; - } - } - + if let Some(ref session_config) = &session { if let Some(ref usage) = usage { Self::update_session_metrics(session_config, usage, messages.len()).await?; } } if let Some(response) = response { - let ToolCategorizeResult { - frontend_requests, - remaining_requests, - filtered_response, - readonly_tools, - regular_tools, - } = self.categorize_tools(&response, &tools).await; + let ToolCategorizeResult { frontend_requests, remaining_requests, filtered_response, readonly_tools, regular_tools } = self.categorize_tools(&response, &tools).await; let requests_to_record: Vec = frontend_requests.iter().chain(remaining_requests.iter()).cloned().collect(); - self.tool_route_manager - .record_tool_requests(&requests_to_record) - .await; - + self.tool_route_manager.record_tool_requests(&requests_to_record).await; yield AgentEvent::Message(filtered_response.clone()); tokio::task::yield_now().await; - - let num_tool_requests = frontend_requests.len() + remaining_requests.len(); - if num_tool_requests == 0 { - continue; - } - - let message_tool_response = Arc::new(Mutex::new(Message::user().with_id( - format!("msg_{}", Uuid::new_v4()) - ))); - - let mut frontend_tool_stream = self.handle_frontend_tool_requests( - &frontend_requests, - message_tool_response.clone(), - ); - - while let Some(msg) = frontend_tool_stream.try_next().await? { - yield AgentEvent::Message(msg); - } - - let mode = goose_mode.clone(); - if mode.as_str() == "chat" { - // Skip all tool calls in chat mode - for request in remaining_requests { - let mut response = message_tool_response.lock().await; - *response = response.clone().with_tool_response( - request.id.clone(), - Ok(vec![Content::text(CHAT_MODE_TOOL_SKIPPED_RESPONSE)]), - ); - } + if frontend_requests.len() + remaining_requests.len() == 0 { continue; } + let message_tool_response = Arc::new(Mutex::new(Message::user().with_id(format!("msg_{}", Uuid::new_v4())))); + let mut frontend_tool_stream = self.handle_frontend_tool_requests(&frontend_requests, message_tool_response.clone()); + while let Some(msg) = frontend_tool_stream.try_next().await? { yield AgentEvent::Message(msg); } + if goose_mode.as_str() == "chat" { + for request in remaining_requests { let mut response = message_tool_response.lock().await; *response = response.clone().with_tool_response(request.id.clone(), Ok(vec![Content::text(CHAT_MODE_TOOL_SKIPPED_RESPONSE)])); } } else { let mut permission_manager = PermissionManager::default(); - let (permission_check_result, enable_extension_request_ids) = - check_tool_permissions( - &remaining_requests, - &mode, - readonly_tools.clone(), - regular_tools.clone(), - &mut permission_manager, - self.provider().await?, - ).await; - - let mut tool_futures = self.handle_approved_and_denied_tools( - &permission_check_result, - message_tool_response.clone(), - cancel_token.clone() - ).await?; - + let (permission_check_result, enable_extension_request_ids) = check_tool_permissions(&remaining_requests, &goose_mode, readonly_tools.clone(), regular_tools.clone(), &mut permission_manager, self.provider().await?).await; + let mut tool_futures = self.handle_approved_and_denied_tools(&permission_check_result, message_tool_response.clone(), cancel_token.clone()).await?; let tool_futures_arc = Arc::new(Mutex::new(tool_futures)); - - // Process tools requiring approval - let mut tool_approval_stream = self.handle_approval_tool_requests( - &permission_check_result.needs_approval, - tool_futures_arc.clone(), - &mut permission_manager, - message_tool_response.clone(), - cancel_token.clone(), - ); - - while let Some(msg) = tool_approval_stream.try_next().await? { - yield AgentEvent::Message(msg); - } - - tool_futures = { - let mut futures_lock = tool_futures_arc.lock().await; - futures_lock.drain(..).collect::>() - }; - - let with_id = tool_futures - .into_iter() - .map(|(request_id, stream)| { - stream.map(move |item| (request_id.clone(), item)) - }) - .collect::>(); - + let mut tool_approval_stream = self.handle_approval_tool_requests(&permission_check_result.needs_approval, tool_futures_arc.clone(), &mut permission_manager, message_tool_response.clone(), cancel_token.clone()); + while let Some(msg) = tool_approval_stream.try_next().await? { yield AgentEvent::Message(msg); } + tool_futures = { let mut futures_lock = tool_futures_arc.lock().await; futures_lock.drain(..).collect::>() }; + let with_id = tool_futures.into_iter().map(|(request_id, stream)| { stream.map(move |item| (request_id.clone(), item)) }).collect::>(); let mut combined = stream::select_all(with_id); let mut all_install_successful = true; - - while let Some((request_id, item)) = combined.next().await { - if is_token_cancelled(&cancel_token) { - break; - } - match item { - ToolStreamItem::Result(output) => { - if enable_extension_request_ids.contains(&request_id) - && output.is_err() - { - all_install_successful = false; - } - let mut response = message_tool_response.lock().await; - *response = - response.clone().with_tool_response(request_id, output); - } - ToolStreamItem::Message(msg) => { - yield AgentEvent::McpNotification(( - request_id, msg, - )); - } - } - } - - if all_install_successful { - tools_updated = true; - } + while let Some((request_id, item)) = combined.next().await { if is_token_cancelled(&cancel_token) { break; } match item { ToolStreamItem::Result(output) => { if enable_extension_request_ids.contains(&request_id) && output.is_err() { all_install_successful = false; } let mut response = message_tool_response.lock().await; *response = response.clone().with_tool_response(request_id, output); } ToolStreamItem::Message(msg) => { yield AgentEvent::McpNotification((request_id, msg)); } } } + if all_install_successful { tools_updated = true; } } - let final_message_tool_resp = message_tool_response.lock().await.clone(); yield AgentEvent::Message(final_message_tool_resp.clone()); - - added_message = true; - messages_to_add.push(response); - messages_to_add.push(final_message_tool_resp); + added_message = true; messages_to_add.push(response); messages_to_add.push(final_message_tool_resp); } } - Err(ProviderError::ContextLengthExceeded(_)) => { - yield AgentEvent::Message(Message::assistant().with_context_length_exceeded( - "The context length of the model has been exceeded. Please start a new session and try again.", - )); - break; - } - Err(e) => { - error!("Error: {}", e); - yield AgentEvent::Message(Message::assistant().with_text( - format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") - )); - break; - } + Err(ProviderError::ContextLengthExceeded(_)) => { yield AgentEvent::Message(Message::assistant().with_context_length_exceeded("The context length of the model has been exceeded. Please start a new session and try again.")); break; } + Err(e) => { error!("Error: {}", e); yield AgentEvent::Message(Message::assistant().with_text(format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error."))); break; } } } - if tools_updated { - (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?; - } + if tools_updated { (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?; } if !added_message { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { - if final_output_tool.final_output.is_none() { - tracing::warn!("Final output tool has not been called yet. Continuing agent loop."); - let message = Message::user().with_text(FINAL_OUTPUT_CONTINUATION_MESSAGE); - messages_to_add.push(message.clone()); - yield AgentEvent::Message(message); - continue - } else { - let message = Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()); - messages_to_add.push(message.clone()); - yield AgentEvent::Message(message); - } - } - - match self.handle_retry_logic(&mut messages, &session, &initial_messages).await { - Ok(should_retry) => { - if should_retry { - info!("Retry logic triggered, restarting agent loop"); - continue; - } - } - Err(e) => { - error!("Retry logic failed: {}", e); - yield AgentEvent::Message(Message::assistant().with_text( - format!("Retry logic encountered an error: {}", e) - )); - } + if final_output_tool.final_output.is_none() { tracing::warn!("Final output tool has not been called yet. Continuing agent loop."); let message = Message::user().with_text(FINAL_OUTPUT_CONTINUATION_MESSAGE); messages_to_add.push(message.clone()); yield AgentEvent::Message(message); continue } else { let message = Message::assistant().with_text(final_output_tool.final_output.clone().unwrap()); messages_to_add.push(message.clone()); yield AgentEvent::Message(message); } } + match self.handle_retry_logic(&mut messages, &session, &initial_messages).await { Ok(should_retry) => { if should_retry { info!("Retry logic triggered, restarting agent loop"); continue; } } Err(e) => { error!("Retry logic failed: {}", e); yield AgentEvent::Message(Message::assistant().with_text(format!("Retry logic encountered an error: {}", e))); } } break; } - messages.extend(messages_to_add); - tokio::task::yield_now().await; } - })) + } } fn determine_goose_mode(session: Option<&SessionConfig>, config: &Config) -> String { diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 8fb20fbc15ff..9b6d80c873b6 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -22,6 +22,7 @@ mod tool_execution; mod tool_route_manager; mod tool_router_index_manager; pub(crate) mod tool_vectordb; +pub mod repo_tools; pub mod types; pub use agent::{Agent, AgentEvent}; diff --git a/crates/goose/src/agents/repo_tools.rs b/crates/goose/src/agents/repo_tools.rs new file mode 100644 index 000000000000..682052d1a325 --- /dev/null +++ b/crates/goose/src/agents/repo_tools.rs @@ -0,0 +1,272 @@ +use indoc::indoc; +use once_cell::sync::Lazy; +use rmcp::model::{Tool, ToolAnnotations}; +use rmcp::object; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, instrument}; + +#[cfg(feature = "repo-index")] +use crate::repo_index::{RepoIndexOptions}; +#[cfg(feature = "repo-index")] +use crate::repo_index::service::RepoIndexService; +#[cfg(feature = "repo-index")] +use anyhow::{anyhow, Result}; + +// Tool name constants +pub const REPO_QUERY_TOOL_NAME: &str = "repo__search"; +pub const REPO_STATS_TOOL_NAME: &str = "repo__stats"; + +#[derive(Clone)] +struct CachedIndex { + service: Arc, + built_at: std::time::Instant, +} + +#[cfg(feature = "repo-index")] +static REPO_INDEX_CACHE: Lazy>> = Lazy::new(|| RwLock::new(HashMap::new())); +// Per-root build locks to avoid duplicate concurrent builds +static REPO_BUILD_LOCKS: Lazy>>>> = Lazy::new(|| RwLock::new(HashMap::new())); + +// repo_build_tool removed: index will auto-build on first search + +pub fn repo_query_tool() -> Tool { + Tool::new( + REPO_QUERY_TOOL_NAME.to_string(), + indoc! {r#" + Search repository symbols (lazy auto-build). On first query or after TTL expiry the index + is (re)built automatically in-memory (no on-disk artifact) unless background indexing already + populated it. You can optionally restrict languages and request callers/callees traversal. + TTL (seconds) can be overridden via GOOSE_REPO_INDEX_TTL_SECS (default 600). Set to 0 to disable TTL refresh. + "#}.to_string(), + object!({ + "type": "object", + "required": ["root", "query"], + "properties": { + "root": {"type": "string", "description": "Previously indexed root"}, + "query": {"type": "string", "description": "Symbol name to search"}, + "limit": {"type": "integer", "default": 15}, + "exact_only": {"type": "boolean", "description": "Only return exact matches"}, + "min_score": {"type": "number", "description": "Minimum blended score filter (0-1)"}, + "show_score": {"type": "boolean", "description": "Include score details in result"}, + "callers_depth": {"type": "integer", "description": "Depth of reverse call traversal per match"}, + "callees_depth": {"type": "integer", "description": "Depth of forward call traversal per match"}, + "langs": {"type": "array", "items": {"type": "string"}, "description": "Optional whitelist of language IDs (e.g. rust, python)."} + } + }) + ).annotate(ToolAnnotations { + title: Some("Search repository symbols".to_string()), + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + }) +} + +pub fn repo_stats_tool() -> Tool { + Tool::new( + REPO_STATS_TOOL_NAME.to_string(), + "Get high-level statistics about a repository index (auto-builds if missing).".to_string(), + object!({ + "type": "object", + "required": ["root"], + "properties": { + "root": {"type": "string", "description": "Repository root path"}, + "langs": {"type": "array", "items": {"type": "string"}, "description": "Optional whitelist of language IDs if an auto-build occurs."} + } + }) + ).annotate(ToolAnnotations { + title: Some("Repository index stats".to_string()), + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + }) +} + + +#[cfg(feature = "repo-index")] +#[instrument(level = "info", skip(args), fields(root = %args.get("root").and_then(|v| v.as_str()).unwrap_or("?"), query = %args.get("query").and_then(|v| v.as_str()).unwrap_or("?")))] +pub async fn handle_repo_query(args: serde_json::Value) -> Result { + use crate::repo_index::service::StoredEntity; + let root_s = args["root"].as_str().ok_or_else(|| anyhow!("missing root"))?; + let query = args["query"].as_str().ok_or_else(|| anyhow!("missing query"))?; + let limit = args["limit"].as_u64().unwrap_or(15) as usize; + let exact_only = args["exact_only"].as_bool().unwrap_or(false); + let min_score = args["min_score"].as_f64().unwrap_or(0.0) as f32; + let show_score = args["show_score"].as_bool().unwrap_or(false); + let callers_depth = args["callers_depth"].as_u64().unwrap_or(0) as u32; + let callees_depth = args["callees_depth"].as_u64().unwrap_or(0) as u32; + let root = Path::new(root_s).canonicalize().unwrap_or_else(|_| PathBuf::from(root_s)); + let langs: Option> = args["langs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); + + // TTL-based auto build + let ttl_secs: u64 = std::env::var("GOOSE_REPO_INDEX_TTL_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(600); + // Double-checked TTL + build lock + let mut need_build = false; + { + let cache = REPO_INDEX_CACHE.read().await; + if let Some(cached) = cache.get(&root) { + if ttl_secs > 0 && cached.built_at.elapsed().as_secs() >= ttl_secs { need_build = true; } + } else { need_build = true; } + } + if need_build { + // Acquire per-root build mutex + let lock_arc = { + let mut locks = REPO_BUILD_LOCKS.write().await; + locks.entry(root.clone()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone() + }; + let _g = lock_arc.lock().await; // wait for any ongoing build + // Re-check after acquiring lock + let mut do_build = false; + { + let cache = REPO_INDEX_CACHE.read().await; + if let Some(cached) = cache.get(&root) { + if ttl_secs > 0 && cached.built_at.elapsed().as_secs() >= ttl_secs { do_build = true; } + } else { do_build = true; } + } + if do_build { + let build_start = std::time::Instant::now(); + let (svc, _stats) = { + let mut builder_local = RepoIndexOptions::builder(); + builder_local = builder_local.root(&root); + if let Some(lang_list) = langs.as_ref() { builder_local = builder_local.include_langs(lang_list.iter().map(|s| s.as_str()).collect()); } + builder_local = builder_local.output_null(); + RepoIndexService::build(builder_local.build())? + }; + let mut cache = REPO_INDEX_CACHE.write().await; + cache.insert(root.clone(), CachedIndex { service: Arc::new(svc), built_at: std::time::Instant::now() }); + let elapsed = build_start.elapsed(); + info!(counter.goose.repo.builds = 1, event = "repo.index.build", root = %root.display(), duration_ms = elapsed.as_millis() as u64, ttl_secs, trigger = "query", "Repository index built (query path)"); + } + } + + let svc = { + let cache = REPO_INDEX_CACHE.read().await; + cache.get(&root).map(|c| c.service.clone()) + }.ok_or_else(|| anyhow!("no cached index for root"))?; + + // gather candidates + let entities: Vec<&StoredEntity> = if exact_only { + svc.search_symbol_exact(query) + } else { + svc.search_symbol_fuzzy_ranked(query, limit * 2) // over-fetch a bit before min_score filter + }; + + // Recompute blended score for reporting and filtering (mirror logic in service) + let mut min_rank = f32::MAX; let mut max_rank = f32::MIN; + for e in &svc.entities { if e.rank < min_rank { min_rank = e.rank; } if e.rank > max_rank { max_rank = e.rank; } } + let rank_range = if (max_rank - min_rank).abs() < 1e-9 { 1.0 } else { max_rank - min_rank }; + + let mut results = Vec::new(); + for e in entities.into_iter() { + if e.kind.as_str() == "file" { continue; } + let name_lower = e.name.to_lowercase(); + let q_lower = query.to_lowercase(); + let mut lex = 0.0f32; + if name_lower == q_lower { lex = 1.0; } + else if name_lower.starts_with(&q_lower) { lex = 0.8; } + else if name_lower.contains(&q_lower) { lex = 0.5; } + else { + // small inline levenshtein (duplicate ok for now) + let dist = { + let a = &name_lower; let b = &q_lower; + let mut dp: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut prev = dp[0]; dp[0] = i + 1; + for (j, cb) in b.chars().enumerate() { + let temp = dp[j + 1]; + dp[j + 1] = if ca == cb { prev } else { 1 + prev.min(dp[j]).min(dp[j + 1]) }; + prev = temp; + } + } + *dp.last().unwrap() + }; + if dist <= 2 { lex = (0.3 - 0.1 * dist as f32).max(0.0); } + } + if lex == 0.0 && !exact_only { continue; } + let norm_rank = (e.rank - min_rank) / rank_range; + let blended = if exact_only { lex.max(1.0) } else { lex * 0.6 + norm_rank * 0.4 }; + if blended < min_score { continue; } + + // Optionally add graph expansion + let mut callers = Vec::new(); + if callers_depth > 0 { callers = svc.callers_up_to(e.id, callers_depth); } + let mut callees = Vec::new(); + if callees_depth > 0 { callees = svc.callees_up_to(e.id, callees_depth); } + + results.push(serde_json::json!({ + "id": e.id, + "name": e.name, + "kind": e.kind.as_str(), + "file": svc.files[e.file_id as usize].path, + "rank": e.rank, + "score": if show_score { Some(blended) } else { None }, + "callers": if callers_depth>0 { Some(callers) } else { None }, + "callees": if callees_depth>0 { Some(callees) } else { None }, + })); + if results.len() >= limit { break; } + } + + info!(counter.goose.repo.search_calls = 1, event = "repo.index.search", root = %root.display(), query, results = results.len(), exact_only, callers_depth, callees_depth, limit, "Repository search executed"); + Ok(serde_json::json!({"results": results})) +} + +#[cfg(feature = "repo-index")] +#[instrument(level = "info", skip(args), fields(root = %args.get("root").and_then(|v| v.as_str()).unwrap_or("?")))] +pub async fn handle_repo_stats(args: serde_json::Value) -> Result { + let root_s = args["root"].as_str().ok_or_else(|| anyhow!("missing root"))?; + let root = Path::new(root_s).canonicalize().unwrap_or_else(|_| PathBuf::from(root_s)); + let langs: Option> = args["langs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); + + // If not cached, build (no TTL for stats path; on-demand only) + let exists = { REPO_INDEX_CACHE.read().await.contains_key(&root) }; + if !exists { + let lock_arc = { + let mut locks = REPO_BUILD_LOCKS.write().await; + locks.entry(root.clone()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone() + }; + let _g = lock_arc.lock().await; + let exists2 = { REPO_INDEX_CACHE.read().await.contains_key(&root) }; + if !exists2 { + let build_start = std::time::Instant::now(); + let (svc, _stats) = { + let mut builder_local = RepoIndexOptions::builder(); + builder_local = builder_local.root(&root).output_null(); + if let Some(lang_list) = langs.as_ref() { builder_local = builder_local.include_langs(lang_list.iter().map(|s| s.as_str()).collect()); } + RepoIndexService::build(builder_local.build())? + }; + let mut cache = REPO_INDEX_CACHE.write().await; + cache.insert(root.clone(), CachedIndex { service: Arc::new(svc), built_at: std::time::Instant::now() }); + let elapsed = build_start.elapsed(); + info!(counter.goose.repo.builds = 1, event = "repo.index.build", root = %root.display(), duration_ms = elapsed.as_millis() as u64, trigger = "stats", reason = "stats", "Repository index built (stats path)"); + } + } + let svc = { + let cache = REPO_INDEX_CACHE.read().await; + cache.get(&root).map(|c| c.service.clone()) + }.ok_or_else(|| anyhow!("index build failed"))?; + info!(counter.goose.repo.stats_calls = 1, event = "repo.index.stats", root = %root.display(), files = svc.files.len(), entities = svc.entities.len(), "Repository stats collected"); + Ok(serde_json::json!({ + "root": root, + "files": svc.files.len(), + "entities": svc.entities.len(), + "unresolved_imports_files": svc.unresolved_imports.iter().filter(|v| !v.is_empty()).count(), + "rank_weights": { + "call": svc.rank_weights.call, + "import": svc.rank_weights.import, + "containment": svc.rank_weights.containment, + "damping": svc.rank_weights.damping, + "iterations": svc.rank_weights.iterations + } + })) +} + +#[cfg(not(feature = "repo-index"))] +pub async fn handle_repo_build(_args: serde_json::Value) -> Result { Err(anyhow::anyhow!("repo-index feature disabled")) } +#[cfg(not(feature = "repo-index"))] +pub async fn handle_repo_query(_args: serde_json::Value) -> Result { Err(anyhow::anyhow!("repo-index feature disabled")) } +#[cfg(not(feature = "repo-index"))] +pub async fn handle_repo_stats(_args: serde_json::Value) -> Result { Err(anyhow::anyhow!("repo-index feature disabled")) } diff --git a/crates/goose/src/agents/tests/repo_tools_tests.rs b/crates/goose/src/agents/tests/repo_tools_tests.rs new file mode 100644 index 000000000000..6956c919ec39 --- /dev/null +++ b/crates/goose/src/agents/tests/repo_tools_tests.rs @@ -0,0 +1,47 @@ +#![cfg(feature = "repo-index")] + +use crate::agents::repo_tools; +use serde_json::json; + +#[tokio::test] +async fn test_repo_build_cached_flow() { + let root = std::env::current_dir().unwrap(); + let first = repo_tools::handle_repo_build(json!({ + "root": root, + "langs": ["rust"], + "force": true + })).await.expect("build ok"); + assert_eq!(first["status"], "built"); + + let second = repo_tools::handle_repo_build(json!({ + "root": root, + "langs": ["rust"], + "force": false + })).await.expect("cached ok"); + assert_eq!(second["status"], "cached"); +} + +#[tokio::test] +async fn test_repo_search_and_stats() { + let root = std::env::current_dir().unwrap(); + // Build (force) to ensure fresh index + repo_tools::handle_repo_build(json!({ + "root": root, + "langs": ["rust"], + "force": true + })).await.unwrap(); + + let res = repo_tools::handle_repo_query(json!({ + "root": root, + "query": "Agent", + "limit": 5, + "show_score": true + })).await.unwrap(); + assert!(res["results"].as_array().unwrap().len() > 0, "Expected some results for Agent"); + + let stats = repo_tools::handle_repo_stats(json!({ + "root": root + })).await.unwrap(); + assert!(stats["files"].as_u64().unwrap() > 0); + assert!(stats["entities"].as_u64().unwrap() > 0); +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index d0046941f57a..d3435de9516a 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -9,6 +9,7 @@ pub mod prompt_template; pub mod providers; pub mod recipe; pub mod recipe_deeplink; +pub mod repo_index; // feature-gated implementation inside module pub mod scheduler; pub mod scheduler_factory; pub mod scheduler_trait; diff --git a/crates/goose/src/repo_index/internal.rs b/crates/goose/src/repo_index/internal.rs new file mode 100644 index 000000000000..fbbc3370fd05 --- /dev/null +++ b/crates/goose/src/repo_index/internal.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use ignore::WalkBuilder; +use serde::{Serialize, Deserialize}; +use std::collections::HashSet; +use std::fs::File; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; +use tree_sitter::{Language, Parser, Node}; + +// Language crates (feature-level currently all under single feature) +use tree_sitter_c_sharp as ts_c_sharp; +use tree_sitter_cpp as ts_cpp; +use tree_sitter_go as ts_go; +use tree_sitter_java as ts_java; +use tree_sitter_javascript as ts_javascript; +use tree_sitter_python as ts_python; +use tree_sitter_rust as ts_rust; +use tree_sitter_swift as ts_swift; +use tree_sitter_typescript as ts_typescript; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct RepoIndexStats { + pub files_indexed: usize, + pub entities_indexed: usize, + pub duration: Duration, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entity<'a> { + pub file: String, + pub language: &'a str, + pub kind: &'static str, // class | function | method | other + pub name: String, + pub parent: Option, + pub signature: String, + pub start_line: usize, + pub end_line: usize, + pub calls: Option>, // simple callee names + pub doc: Option, +} + +#[derive(Clone, Copy, Debug)] +pub struct Progress<'a> { + pub current_file: Option<&'a Path>, + pub files_indexed: usize, + pub entities_indexed: usize, +} + +pub type ProgressCallback<'a> = dyn Fn(Progress<'_>) + Send + Sync + 'a; + +pub struct RepoIndexOptions<'a> { + pub root: &'a Path, + pub output: RepoIndexOutput<'a>, + pub include_langs: Option>, // if None index all supported + pub progress: Option<&'a ProgressCallback<'a>>, +} + +pub enum RepoIndexOutput<'a> { + FilePath(&'a Path), + Writer(&'a mut dyn Write), + Null, // discard (still counts entities via sink) +} + +pub struct RepoIndexOptionsBuilder<'a> { + root: Option<&'a Path>, + output: Option>, + include_langs: Option>, + progress: Option<&'a ProgressCallback<'a>>, +} + +impl<'a> Default for RepoIndexOptionsBuilder<'a> { fn default() -> Self { Self { root: None, output: None, include_langs: None, progress: None } } } + +impl<'a> RepoIndexOptions<'a> { pub fn builder() -> RepoIndexOptionsBuilder<'a> { RepoIndexOptionsBuilder::default() } } + +impl<'a> RepoIndexOptionsBuilder<'a> { + pub fn root(mut self, root: &'a Path) -> Self { self.root = Some(root); self } + pub fn output_file(mut self, path: &'a Path) -> Self { self.output = Some(RepoIndexOutput::FilePath(path)); self } + pub fn output_writer(mut self, w: &'a mut dyn Write) -> Self { self.output = Some(RepoIndexOutput::Writer(w)); self } + pub fn output_null(mut self) -> Self { self.output = Some(RepoIndexOutput::Null); self } + pub fn include_langs(mut self, langs: HashSet<&'a str>) -> Self { self.include_langs = Some(langs); self } + pub fn progress(mut self, cb: &'a ProgressCallback<'a>) -> Self { self.progress = Some(cb); self } + pub fn build(self) -> RepoIndexOptions<'a> { RepoIndexOptions { root: self.root.expect("root required"), output: self.output.expect("output required"), include_langs: self.include_langs, progress: self.progress } } +} + +struct CountingWriter { inner: W, counter: usize } +impl CountingWriter { fn new(inner: W) -> Self { Self { inner, counter: 0 } } fn count(&self) -> usize { self.counter } } +impl Write for CountingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { let written = self.inner.write(buf)?; self.counter += buf[..written].iter().filter(|b| **b == b'\n').count(); Ok(written) } + fn flush(&mut self) -> io::Result<()> { self.inner.flush() } +} + +pub(crate) fn detect_language(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|e| e.to_str()) { + Some("rs") => Some("rust"), + Some("py") => Some("python"), + Some("js") => Some("javascript"), + Some("ts") | Some("tsx") => Some("typescript"), + Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("h") => Some("cpp"), + Some("java") => Some("java"), + Some("cs") => Some("c_sharp"), + Some("swift") => Some("swift"), + Some("go") => Some("go"), + _ => None, + } +} +pub(crate) fn lang_to_ts(lang: &str) -> Option { + Some(match lang { + "rust" => ts_rust::LANGUAGE.into(), + "python" => ts_python::LANGUAGE.into(), + "javascript" => ts_javascript::LANGUAGE.into(), + "typescript" => ts_typescript::LANGUAGE_TYPESCRIPT.into(), + "cpp" => ts_cpp::LANGUAGE.into(), + "java" => ts_java::LANGUAGE.into(), + "c_sharp" => ts_c_sharp::LANGUAGE.into(), + "swift" => ts_swift::LANGUAGE.into(), + "go" => ts_go::LANGUAGE.into(), + _ => return None, + }) +} + +pub fn index_repository(opts: RepoIndexOptions<'_>) -> Result { + let start = Instant::now(); + let mut entities = 0usize; // we will snapshot from writer.counter + let mut files = 0usize; + + let mut file_handle; // scoped for writer + let writer: Box = match opts.output { + RepoIndexOutput::FilePath(p) => { + file_handle = Box::new(File::create(p)?); + Box::new(&mut *file_handle) + } + RepoIndexOutput::Writer(w) => Box::new(w), + RepoIndexOutput::Null => Box::new(std::io::sink()), + }; + // Counting writer increments entities when we newline an entity JSON + let mut cw = CountingWriter::new(writer); + + let walker = WalkBuilder::new(opts.root).standard_filters(true).add_custom_ignore_filename(".gitignore").build(); + for dent in walker { + let dent = match dent { Ok(d) => d, Err(_) => continue }; + let path = dent.path(); + if !path.is_file() { continue; } + let lang = match detect_language(path) { Some(l) => l, None => continue }; + if let Some(include) = &opts.include_langs { if !include.contains(lang) { continue; } } + let language = match lang_to_ts(lang) { Some(l) => l, None => continue }; + let src = match std::fs::read_to_string(path) { Ok(s) => s, Err(_) => continue }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { continue; } + let tree = match parser.parse(&src, None) { Some(t) => t, None => continue }; + let mut entities_local: Vec = Vec::new(); + extract_entities(lang, &tree, &src, path, &mut entities_local); + for e in entities_local.into_iter() { + writeln!(cw, "{}", serde_json::to_string(&e)?)?; + } + files += 1; + cw.flush()?; + entities = cw.count(); + if let Some(cb) = opts.progress { + cb(Progress { current_file: Some(path), files_indexed: files, entities_indexed: entities }); + } + } + Ok(RepoIndexStats { files_indexed: files, entities_indexed: entities, duration: start.elapsed() }) +} + +// ---------------- Entity Extraction ---------------- + +pub(crate) fn extract_entities<'a>(lang: &'a str, tree: &tree_sitter::Tree, src: &'a str, path: &Path, out: &mut Vec>) { + match lang { + "rust" => generic_class_function_walk(lang, tree, src, path, out, &RustLangSpec), + "python" => generic_class_function_walk(lang, tree, src, path, out, &PythonLangSpec), + "javascript" => generic_class_function_walk(lang, tree, src, path, out, &JsLangSpec), + "typescript" => generic_class_function_walk(lang, tree, src, path, out, &TsLangSpec), + "java" => generic_class_function_walk(lang, tree, src, path, out, &JavaLangSpec), + "go" => generic_class_function_walk(lang, tree, src, path, out, &GoLangSpec), + "cpp" => generic_class_function_walk(lang, tree, src, path, out, &CppLangSpec), + "c_sharp" => generic_class_function_walk(lang, tree, src, path, out, &CSharpLangSpec), + "swift" => generic_class_function_walk(lang, tree, src, path, out, &SwiftLangSpec), + _ => { + // fallback: whole file summary as one entity + out.push(Entity { + file: path.display().to_string(), + language: lang, + kind: "file", + name: path.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(), + parent: None, + signature: src.lines().next().unwrap_or("").to_string(), + start_line: 1, + end_line: src.lines().count(), + calls: None, + doc: None, + }); + } + } +} + +// Spec describing language-specific node kinds & field names +trait LangSpec { fn class_kind(&self) -> &'static str; fn class_name_field(&self) -> &'static str; fn function_kind(&self) -> &'static str; fn function_name_field(&self) -> &'static str; } + +struct RustLangSpec; impl LangSpec for RustLangSpec { fn class_kind(&self) -> &'static str { "struct_item" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_item" } fn function_name_field(&self) -> &'static str { "name" } } +struct PythonLangSpec; impl LangSpec for PythonLangSpec { fn class_kind(&self) -> &'static str { "class_definition" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_definition" } fn function_name_field(&self) -> &'static str { "name" } } +struct JsLangSpec; impl LangSpec for JsLangSpec { fn class_kind(&self) -> &'static str { "class_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_declaration" } fn function_name_field(&self) -> &'static str { "name" } } +struct TsLangSpec; impl LangSpec for TsLangSpec { fn class_kind(&self) -> &'static str { "class_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_declaration" } fn function_name_field(&self) -> &'static str { "name" } } +struct JavaLangSpec; impl LangSpec for JavaLangSpec { fn class_kind(&self) -> &'static str { "class_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "method_declaration" } fn function_name_field(&self) -> &'static str { "name" } } +struct GoLangSpec; impl LangSpec for GoLangSpec { fn class_kind(&self) -> &'static str { "type_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_declaration" } fn function_name_field(&self) -> &'static str { "name" } } +struct CppLangSpec; impl LangSpec for CppLangSpec { fn class_kind(&self) -> &'static str { "class_specifier" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_definition" } fn function_name_field(&self) -> &'static str { "declarator" } } +struct CSharpLangSpec; impl LangSpec for CSharpLangSpec { fn class_kind(&self) -> &'static str { "class_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "method_declaration" } fn function_name_field(&self) -> &'static str { "name" } } +struct SwiftLangSpec; impl LangSpec for SwiftLangSpec { fn class_kind(&self) -> &'static str { "class_declaration" } fn class_name_field(&self) -> &'static str { "name" } fn function_kind(&self) -> &'static str { "function_declaration" } fn function_name_field(&self) -> &'static str { "name" } } + +fn node_text<'a>(node: Node<'a>, src: &'a str) -> &'a str { node.utf8_text(src.as_bytes()).unwrap_or("") } + +fn generic_class_function_walk<'a>(lang: &'a str, tree: &tree_sitter::Tree, src: &'a str, path: &Path, out: &mut Vec>, spec: &dyn LangSpec) { + let mut stack = vec![(tree.root_node(), None::)]; + while let Some((node, parent)) = stack.pop() { + let kind = node.kind(); + if kind == spec.class_kind() { + let name = node.child_by_field_name(spec.class_name_field()).map(|n| node_text(n, src)).unwrap_or(""); + let entity = Entity { + file: path.display().to_string(), + language: lang, + kind: "class", + name: name.to_string(), + parent: parent.clone(), + signature: extract_signature(&node, src).to_string(), + start_line: node.start_position().row + 1, + end_line: node.end_position().row + 1, + calls: None, + doc: extract_doc_comments(&node, src), + }; + out.push(entity); + for child in node.children(&mut node.walk()) { stack.push((child, Some(name.to_string()))); } + } else if kind == spec.function_kind() { + let name = node.child_by_field_name(spec.function_name_field()).map(|n| node_text(n, src)).unwrap_or(""); + let calls = collect_call_idents(node, src); + out.push(Entity { + file: path.display().to_string(), + language: lang, + kind: "function", + name: name.to_string(), + parent: parent.clone(), + signature: extract_signature(&node, src).to_string(), + start_line: node.start_position().row + 1, + end_line: node.end_position().row + 1, + calls: if calls.is_empty() { None } else { Some(calls) }, + doc: extract_doc_comments(&node, src), + }); + for child in node.children(&mut node.walk()) { stack.push((child, parent.clone())); } + } else { + for child in node.children(&mut node.walk()) { stack.push((child, parent.clone())); } + } + } +} + +fn extract_signature<'a>(node: &Node<'a>, src: &'a str) -> &'a str { + let text = src.get(node.byte_range()).unwrap_or(""); + // Up to first '{' or newline (whichever shorter) for brace languages; else first line + if let Some(idx) = text.find('{') { &text[..idx].trim_end() } else { text.lines().next().unwrap_or("") } +} + +fn extract_doc_comments<'a>(node: &Node<'a>, src: &'a str) -> Option { + let start_byte = node.start_byte(); + let prefix = &src[..start_byte.min(src.len())]; + let mut lines: Vec<&str> = prefix.lines().collect(); + let mut doc_rev: Vec = Vec::new(); + while let Some(&line) = lines.last() { + let trimmed = line.trim(); + if trimmed.is_empty() { break; } + if trimmed.starts_with("///") || trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with("/*") || trimmed.starts_with('*') { + doc_rev.push(trimmed.trim_start_matches("///").trim_start_matches("//").trim_start_matches('#').trim_start_matches('*').trim().to_string()); + lines.pop(); + } else { break; } + } + if doc_rev.is_empty() { None } else { doc_rev.reverse(); Some(doc_rev.join("\n")) } +} + +fn collect_call_idents(func_node: Node, src: &str) -> Vec { + let mut calls = Vec::new(); + let mut stack = vec![func_node]; + while let Some(node) = stack.pop() { + let kind = node.kind(); + // Heuristic: identifier nodes inside call_expression or function_call + if kind == "call_expression" || kind == "function_call" { // language dependent + if let Some(child) = node.child_by_field_name("function") { calls.push(node_text(child, src).to_string()); } + else if let Some(first) = node.child(0) { if first.is_named() { calls.push(node_text(first, src).to_string()); } } + } + for child in node.children(&mut node.walk()) { stack.push(child); } + } + calls.sort(); calls.dedup(); calls +} diff --git a/crates/goose/src/repo_index/mod.rs b/crates/goose/src/repo_index/mod.rs new file mode 100644 index 000000000000..7d296efaeaf9 --- /dev/null +++ b/crates/goose/src/repo_index/mod.rs @@ -0,0 +1,43 @@ +//! Repository indexing (Tree-sitter) - optional feature `repo-index`. +//! Provides API to index a source tree and emit JSONL entity records. +//! +//! High-level usage: +//! ```ignore +//! use goose::repo_index::{index_repository, RepoIndexOptions}; +//! # use std::path::Path; +//! let opts = RepoIndexOptions::builder().root(Path::new(".")).build(); +//! let stats = index_repository(opts).expect("index ok"); +//! println!("indexed {} files", stats.files_indexed); +//! ``` +#[cfg(feature = "repo-index")] +pub mod internal; +#[cfg(feature = "repo-index")] +pub mod service; +#[cfg(feature = "repo-index")] +pub mod tool; +#[cfg(feature = "repo-index")] +pub use internal::*; + +#[cfg(not(feature = "repo-index"))] +pub mod disabled { + use anyhow::Result; + use std::path::Path; + use std::time::Duration; + #[derive(Debug, Clone, Default)] + pub struct RepoIndexStats { + pub files_indexed: usize, + pub entities_indexed: usize, + pub duration: Duration, + } + #[derive(Default)] + pub struct RepoIndexOptions<'a> { pub _phantom: std::marker::PhantomData<&'a ()> } + pub fn index_repository(_opts: RepoIndexOptions<'_>) -> Result { + Err(anyhow::anyhow!("goose compiled without repo-index feature")) + } + impl<'a> RepoIndexOptions<'a> { pub fn builder() -> RepoIndexOptionsBuilder<'a>{ RepoIndexOptionsBuilder::default() } } + #[derive(Default)] + pub struct RepoIndexOptionsBuilder<'a>{ pub _phantom: std::marker::PhantomData<&'a ()> } + impl<'a> RepoIndexOptionsBuilder<'a>{ pub fn root(self, _p: &'a Path)->Self{self} pub fn build(self)->RepoIndexOptions<'a>{ RepoIndexOptions{ _phantom: std::marker::PhantomData } } } +} +#[cfg(not(feature = "repo-index"))] +pub use disabled::*; diff --git a/crates/goose/src/repo_index/service.rs b/crates/goose/src/repo_index/service.rs new file mode 100644 index 000000000000..887a1e08c2f0 --- /dev/null +++ b/crates/goose/src/repo_index/service.rs @@ -0,0 +1,590 @@ +use crate::repo_index::{RepoIndexOptions, RepoIndexStats}; +use anyhow::Result; +use std::collections::{HashMap, HashSet, VecDeque}; + +// --- Enhanced import extraction helper (multi-language heuristics) --- +fn extract_import_modules(lang: &str, source: &str) -> HashSet { + let mut set = HashSet::new(); + match lang { + // Python: handle aliases, relative, multi-import + "python" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("import ") { + let rest = &t[7..]; + for part in rest.split(',') { + let mut token = part.trim(); + if let Some(idx) = token.find(" as ") { token = &token[..idx]; } + token = token.split_whitespace().next().unwrap_or(""); + if !token.is_empty() { set.insert(token.to_string()); } + } + } else if t.starts_with("from ") { + if let Some(after_from) = t.strip_prefix("from ") { + if let Some((module, _imports)) = after_from.split_once(" import ") { + let mut mod_token = module.trim(); + mod_token = mod_token.trim_start_matches('.'); // collapse relative dots + mod_token = mod_token.split('.').next().unwrap_or(""); + if !mod_token.is_empty() { set.insert(mod_token.to_string()); } + } + } + } + } + } + // Local quoted includes only + "cpp" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("#include \"") { + if let Some(start) = t.find('"') { if let Some(end_rel) = t[start+1..].find('"') { let name = &t[start+1..start+1+end_rel]; if !name.is_empty() { set.insert(normalize_module_basename(name)); } } } + } + } + } + "java" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("import ") { + let mut rest = &t[7..]; + if rest.starts_with("static ") { rest = &rest[7..]; } + if let Some(semi) = rest.find(';') { rest = &rest[..semi]; } + let last = rest.rsplit('.').next().unwrap_or(rest).trim(); + if !last.is_empty() && last != "*" { set.insert(last.to_string()); } + } + } + } + "javascript" | "typescript" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("import ") { + if let Some(idx) = t.find(" from ") { + let rest = &t[idx + 6..]; + if let Some(m) = extract_quoted(rest) { set.insert(normalize_module_basename(&m)); } + } else if t.starts_with("import ") && (t.contains('"') || t.contains('\'')) { + if let Some(m) = extract_quoted(t) { set.insert(normalize_module_basename(&m)); } + } + } else if t.contains("require(") { + if let Some(m) = between(t, "require(", ")") { let m2 = m.trim_matches(&['"', '\''] as &[_]); set.insert(normalize_module_basename(m2)); } + } + } + } + "go" => { + let mut in_block = false; + for line in source.lines() { + let t = line.trim(); + if t.starts_with("import (") { in_block = true; continue; } + if in_block { + if t.starts_with(')') { in_block = false; continue; } + if let Some(m) = extract_quoted(t) { set.insert(normalize_module_basename(&m)); } + } else if t.starts_with("import ") { + if let Some(m) = extract_quoted(t) { set.insert(normalize_module_basename(&m)); } + } + } + } + "rust" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("mod ") { + let token = t[4..].split(|c: char| c == ';' || c == '{' || c.is_whitespace()).next().unwrap_or(""); + if !token.is_empty() { set.insert(token.to_string()); } + } else if t.starts_with("use ") { + let after = &t[4..]; + let mut first = after.split(|c: char| c == ':' || c == ';' || c == '{' || c.is_whitespace()).next().unwrap_or(""); + if ["crate","super","self"].contains(&first) { + let remainder = after.trim_start_matches(first).trim_start_matches(':').trim_start_matches(':'); + first = remainder.split(|c: char| c == ':' || c == ';' || c == '{' || c.is_whitespace()).next().unwrap_or(""); + } + if !first.is_empty() && first != "*" { set.insert(first.to_string()); } + } + } + } + "c_sharp" => { + for line in source.lines() { + let t = line.trim(); + if t.starts_with("using ") { + let after = &t[6..]; + let head = after.split('=').next().unwrap_or(after); + let first = head.split(|c: char| c == '.' || c == ';' || c.is_whitespace()).next().unwrap_or(""); + if !first.is_empty() { set.insert(first.to_string()); } + } + } + } + "swift" => { + for line in source.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("@testable import ") { let tok = rest.split_whitespace().next().unwrap_or(""); if !tok.is_empty() { set.insert(tok.to_string()); } } + else if let Some(rest) = t.strip_prefix("import ") { let tok = rest.split_whitespace().next().unwrap_or(""); if !tok.is_empty() { set.insert(tok.to_string()); } } + } + } + _ => {} + } + set +} + +fn extract_quoted(s: &str) -> Option { + let mut current = None; + for (i, ch) in s.chars().enumerate() { + if ch == '"' || ch == '\'' { + if current.is_none() { current = Some((ch, i)); } + else if let Some((qc, start)) = current { if qc == ch { return Some(s[start+1..i].to_string()); } } + } + } + None +} + +fn between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> { + let a = s.find(start)? + start.len(); + let rest = &s[a..]; + let b = rest.find(end)?; + Some(&rest[..b]) +} + +fn normalize_module_basename(module: &str) -> String { + let last = module.rsplit('/').next().unwrap_or(module); + let stem = last.split('.').next().unwrap_or(last); + stem.to_string() +} +use std::io::Write; + +use ignore::WalkBuilder; +use tree_sitter::{Parser, Language}; +use crate::repo_index::internal::{detect_language, lang_to_ts, extract_entities}; + +#[derive(Debug)] +pub struct FileRecord { + pub id: u32, + pub path: String, + pub language: String, + pub entities: Vec, +} + +#[derive(Debug)] +pub struct StoredEntity { + pub id: u32, + pub file_id: u32, + pub kind: EntityKind, + pub name: String, + pub parent: Option, + pub signature: String, + pub start_line: u32, + pub end_line: u32, + pub calls: Vec, + pub doc: Option, + pub rank: f32, // placeholder for future PageRank +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EntityKind { File, Class, Function, Method, Other } + +impl EntityKind { + fn from_str(s: &str) -> Self { + match s { + "class" => EntityKind::Class, + "function" => EntityKind::Function, + "method" => EntityKind::Method, + _ => EntityKind::Other, + } + } + pub fn as_str(&self) -> &'static str { + match self { EntityKind::File => "file", EntityKind::Class => "class", EntityKind::Function => "function", EntityKind::Method => "method", EntityKind::Other => "other" } + } +} + +impl std::fmt::Display for EntityKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } +} + +pub struct RepoIndexService { + pub files: Vec, + pub entities: Vec, + pub(crate) name_index: HashMap>, // lowercase name -> entity ids (crate visible) + // Graph adjacency lists (indices reference entities vector) + pub containment_children: Vec>, // entity id -> child entity ids + pub containment_parent: Vec>, // entity id -> optional parent entity id + pub call_edges: Vec>, // entity id -> outgoing calls (resolved entity ids) + pub reverse_call_edges: Vec>, // entity id -> incoming calls (callers) + pub import_edges: Vec>, // placeholder until Step 4 (file-level imports -> file entity ids) + pub file_entities: Vec, // mapping file index -> file entity id + pub unresolved_imports: Vec>, // per file index unresolved module basenames + pub rank_weights: RankWeights, // configured (possibly env overridden) weights +} + +#[derive(Clone, Copy, Debug)] +pub struct RankWeights { + pub call: f32, + pub import: f32, + pub containment: f32, + pub damping: f32, + pub iterations: usize, +} + +impl RankWeights { + pub fn defaults() -> Self { Self { call: 1.0, import: 0.5, containment: 0.2, damping: 0.85, iterations: 20 } } + pub fn from_env() -> Self { + let mut w = Self::defaults(); + // Helper closure + fn parse_f(name: &str) -> Option { std::env::var(name).ok().and_then(|v| v.parse::().ok()) } + fn parse_usize(name: &str) -> Option { std::env::var(name).ok().and_then(|v| v.parse::().ok()) } + if let Some(v) = parse_f("GOOSE_REPO_RANK_CALL_WEIGHT") { if v >= 0.0 { w.call = v; } } + if let Some(v) = parse_f("GOOSE_REPO_RANK_IMPORT_WEIGHT") { if v >= 0.0 { w.import = v; } } + if let Some(v) = parse_f("GOOSE_REPO_RANK_CONTAINMENT_WEIGHT") { if v >= 0.0 { w.containment = v; } } + if let Some(v) = parse_f("GOOSE_REPO_RANK_DAMPING") { if (0.0..=1.0).contains(&v) { w.damping = v; } } + if let Some(v) = parse_usize("GOOSE_REPO_RANK_ITERATIONS") { if v > 0 && v <= 200 { w.iterations = v; } } + // If all edge weights zero, fall back to defaults to avoid division by zero + if w.call == 0.0 && w.import == 0.0 && w.containment == 0.0 { return Self::defaults(); } + w + } +} + +impl RepoIndexService { + pub fn build(opts: RepoIndexOptions<'_>) -> Result<(Self, RepoIndexStats)> { + let start = std::time::Instant::now(); + let root = opts.root; + let walker = WalkBuilder::new(root).standard_filters(true).add_custom_ignore_filename(".gitignore").build(); + let mut files_map: HashMap = HashMap::new(); + let mut files: Vec = Vec::new(); + let mut entities_store: Vec = Vec::new(); + let mut name_index: HashMap> = HashMap::new(); + let mut file_count = 0usize; + let mut parser = Parser::new(); + // Temporary store of raw import module names per file index (not entity id yet) + let mut file_import_modules: HashMap> = HashMap::new(); + for dent in walker { + let dent = match dent { Ok(d) => d, Err(_) => continue }; + let path = dent.path(); + if !path.is_file() { continue; } + let lang: &str = match detect_language(path) { Some(l) => l, None => continue }; + if let Some(include) = &opts.include_langs { if !include.contains(lang) { continue; } } + let language: Language = match lang_to_ts(lang) { Some(l) => l, None => continue }; + let src = match std::fs::read_to_string(path) { Ok(s) => s, Err(_) => continue }; + if parser.set_language(&language).is_err() { + // Fallback: if language is one of our import-heuristic supported languages, still record file & imports + if matches!(lang, "c_sharp" | "swift") { + let imports = extract_import_modules(lang, &src); + let file_path_str = path.display().to_string(); + let file_id = *files_map.entry(file_path_str.clone()).or_insert_with(|| { + let id = files.len() as u32; + files.push(FileRecord { id, path: file_path_str.clone(), language: lang.to_string(), entities: Vec::new() }); + id + }); + if !imports.is_empty() { file_import_modules.entry(file_id).or_default().extend(imports); } + } + continue; + } + let tree = match parser.parse(&src, None) { Some(t) => t, None => continue }; + let mut entities_local = Vec::new(); + extract_entities(lang, &tree, &src, path, &mut entities_local); + // Extract basic import/module references heuristically (language-specific) + let imports = extract_import_modules(lang, &src); + let file_path_str = path.display().to_string(); + let file_id = *files_map.entry(file_path_str.clone()).or_insert_with(|| { + let id = files.len() as u32; + files.push(FileRecord { id, path: file_path_str.clone(), language: lang.to_string(), entities: Vec::new() }); + id + }); + if !imports.is_empty() { file_import_modules.entry(file_id).or_default().extend(imports); } + for ent in entities_local { + let id = entities_store.len() as u32; + if let Some(fr) = files.get_mut(file_id as usize) { fr.entities.push(id); } + name_index.entry(ent.name.to_lowercase()).or_default().push(id); + entities_store.push(StoredEntity { + id, + file_id, + kind: EntityKind::from_str(ent.kind), + name: ent.name, + parent: ent.parent, + signature: ent.signature, + start_line: ent.start_line as u32, + end_line: ent.end_line as u32, + calls: ent.calls.unwrap_or_default(), + doc: ent.doc, + rank: 0.0, + }); + } + file_count += 1; + } + // Add file pseudo-entities (not added to name index to avoid symbol noise) + let mut file_entities: Vec = Vec::with_capacity(files.len()); + for f in &files { + let id = entities_store.len() as u32; + file_entities.push(id); + entities_store.push(StoredEntity { + id, + file_id: f.id, + kind: EntityKind::File, + name: std::path::Path::new(&f.path).file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(), + parent: None, + signature: String::new(), + start_line: 0, + end_line: 0, + calls: Vec::new(), + doc: None, + rank: 0.0, + }); + } + + // Initialize empty adjacency lists sized to entities length (after file entities appended) + let entity_len = entities_store.len(); + let mut containment_children: Vec> = vec![Vec::new(); entity_len]; + let mut containment_parent: Vec> = vec![None; entity_len]; + let mut call_edges: Vec> = vec![Vec::new(); entity_len]; + let mut reverse_call_edges: Vec> = vec![Vec::new(); entity_len]; + let mut import_edges: Vec> = vec![Vec::new(); entity_len]; + let mut unresolved_imports_per_file: Vec> = vec![Vec::new(); files.len()]; + + // Build a temporary map: (file_id, parent_name_lowercase) -> entity id for containment + let mut scope_map: HashMap<(u32, String), u32> = HashMap::new(); + for e in &entities_store { + scope_map.insert((e.file_id, e.name.to_lowercase()), e.id); + } + + // Resolve containment relationships + for e in &entities_store { + if let Some(parent_name) = &e.parent { + let key = (e.file_id, parent_name.to_lowercase()); + if let Some(parent_id) = scope_map.get(&key) { + containment_parent[e.id as usize] = Some(*parent_id); + containment_children[*parent_id as usize].push(e.id); + } + } + } + + // Resolve call edges by name within same file first + for e in &entities_store { + if matches!(e.kind, EntityKind::File) { continue; } + let mut resolved_local: HashSet = HashSet::new(); + for call_name in &e.calls { + if let Some(callee_id) = scope_map.get(&(e.file_id, call_name.to_lowercase())) { + call_edges[e.id as usize].push(*callee_id); + reverse_call_edges[*callee_id as usize].push(e.id); + resolved_local.insert(*callee_id); + } + } + } + // Cross-file resolution: if a call name is globally unique, link it + for e in &entities_store { + if matches!(e.kind, EntityKind::File) { continue; } + // gather already resolved callees to avoid duplicates + let already: HashSet = call_edges[e.id as usize].iter().cloned().collect(); + for call_name in &e.calls { + let key = call_name.to_lowercase(); + if let Some(ids) = name_index.get(&key) { + if ids.len() == 1 { // unambiguous + let target = ids[0]; + if !already.contains(&target) && target != e.id { // avoid self-loop + call_edges[e.id as usize].push(target); + reverse_call_edges[target as usize].push(e.id); + } + } + } + } + } + + // Resolve import edges: from file entity to target file entity based on basename/module name + // Build map from lowercase basename (no extension) to file entity id (if unique) + let mut basename_map: HashMap = HashMap::new(); + let mut basename_counts: HashMap = HashMap::new(); + for (idx, f) in files.iter().enumerate() { + if let Some(stem) = std::path::Path::new(&f.path).file_stem().and_then(|s| s.to_str()) { + let key = stem.to_lowercase(); + *basename_counts.entry(key.clone()).or_insert(0) += 1; + basename_map.entry(key).or_insert(file_entities[idx]); + } + } + for (fid, mods) in file_import_modules.into_iter() { + let file_entity_id = file_entities[fid as usize]; + let mut added: HashSet = HashSet::new(); + for m in mods { + let key = m.to_lowercase(); + if let Some(count) = basename_counts.get(&key) { + if *count == 1 { + if let Some(target_file_entity) = basename_map.get(&key) { + if added.insert(*target_file_entity) { + import_edges[file_entity_id as usize].push(*target_file_entity); + } + continue; + } + } + // ambiguous or missing mapping -> unresolved + unresolved_imports_per_file[fid as usize].push(m.clone()); + } else { + // no local match + unresolved_imports_per_file[fid as usize].push(m.clone()); + } + } + } + + let stats = RepoIndexStats { files_indexed: file_count, entities_indexed: entities_store.len(), duration: start.elapsed() }; + let rank_weights = RankWeights::from_env(); + let mut svc = Self { files, entities: entities_store, name_index, containment_children, containment_parent, call_edges, reverse_call_edges, import_edges, file_entities, unresolved_imports: unresolved_imports_per_file, rank_weights }; + // Compute initial PageRank (Step 5) with configured weights + svc.compute_pagerank(); + Ok((svc, stats)) + } + + pub fn search_symbol_exact(&self, name: &str) -> Vec<&StoredEntity> { + let key = name.to_lowercase(); + self.name_index.get(&key) + .map(|ids| ids.iter().filter_map(|id| self.entities.get(*id as usize)).collect()) + .unwrap_or_default() + } + + // Simple fuzzy search: compute lexical score (higher is better) then blend with PageRank. + // Strategy: exact match score=1.0; prefix=0.8; substring=0.5; levenshtein within threshold (<=2) score=0.3 minus 0.1 per distance. + // Final score = lexical_score * 0.6 + normalized_rank * 0.4 (normalization over all entities ranks). + pub fn search_symbol_fuzzy_ranked(&self, query: &str, limit: usize) -> Vec<&StoredEntity> { + let q_lower = query.to_lowercase(); + let mut min_rank = f32::MAX; let mut max_rank = f32::MIN; + for e in &self.entities { if e.rank < min_rank { min_rank = e.rank; } if e.rank > max_rank { max_rank = e.rank; } } + let rank_range = if (max_rank - min_rank).abs() < 1e-9 { 1.0 } else { max_rank - min_rank }; + fn levenshtein(a: &str, b: &str) -> usize { // small helper (O(len^2)) acceptable for modest entity counts + let mut dp: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut prev = dp[0]; + dp[0] = i + 1; + for (j, cb) in b.chars().enumerate() { + let temp = dp[j + 1]; + dp[j + 1] = if ca == cb { prev } else { 1 + prev.min(dp[j]).min(dp[j + 1]) }; + prev = temp; + } + } + *dp.last().unwrap() + } + let mut scored: Vec<(f32, &StoredEntity)> = Vec::new(); + for e in &self.entities { + // Skip file pseudo-entities for symbol searches + if e.kind.as_str() == "file" { continue; } + let name_lower = e.name.to_lowercase(); + let mut lex = 0.0f32; + if name_lower == q_lower { lex = 1.0; } + else if name_lower.starts_with(&q_lower) { lex = 0.8; } + else if name_lower.contains(&q_lower) { lex = 0.5; } + else { + let dist = levenshtein(&name_lower, &q_lower); + if dist <= 2 { lex = (0.3 - 0.1 * dist as f32).max(0.0); } + } + if lex > 0.0 { // candidate + let norm_rank = (e.rank - min_rank) / rank_range; + let final_score = lex * 0.6 + norm_rank * 0.4; + scored.push((final_score, e)); + } + } + scored.sort_by(|a,b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(limit); + scored.into_iter().map(|(_,e)| e).collect() + } + + pub fn symbol_ids_exact(&self, name: &str) -> &[u32] { + static EMPTY: [u32;0] = []; + self.name_index.get(&name.to_lowercase()).map(|v| v.as_slice()).unwrap_or(&EMPTY) + } + + pub fn children_of(&self, entity_id: u32) -> &[u32] { + self.containment_children.get(entity_id as usize).map(|v| v.as_slice()).unwrap_or(&[]) + } + + pub fn parent_of(&self, entity_id: u32) -> Option { + self.containment_parent.get(entity_id as usize).and_then(|p| *p) + } + + pub fn outgoing_calls(&self, entity_id: u32) -> &[u32] { + self.call_edges.get(entity_id as usize).map(|v| v.as_slice()).unwrap_or(&[]) + } + + pub fn incoming_calls(&self, entity_id: u32) -> &[u32] { + self.reverse_call_edges.get(entity_id as usize).map(|v| v.as_slice()).unwrap_or(&[]) + } + + pub fn imported_files(&self, file_entity_id: u32) -> &[u32] { + self.import_edges.get(file_entity_id as usize).map(|v| v.as_slice()).unwrap_or(&[]) + } + pub fn unresolved_imports_for_file_index(&self, file_index: usize) -> &[String] { + self.unresolved_imports.get(file_index).map(|v| v.as_slice()).unwrap_or(&[]) + } + + +// (Removed duplicate legacy import heuristic block; enhanced version defined above) + pub fn export_jsonl(&self, mut w: W) -> Result<()> { + for e in &self.entities { + let json = serde_json::json!({ + "file": self.files[e.file_id as usize].path, + "language": self.files[e.file_id as usize].language, + "kind": e.kind.as_str(), + "name": e.name, + "parent": e.parent, + "signature": e.signature, + "start_line": e.start_line, + "end_line": e.end_line, + "calls": e.calls, + "doc": e.doc, + "rank": e.rank, + }); + writeln!(w, "{}", json.to_string())?; + } + Ok(()) + } + + // Graph traversal helpers + pub fn callees_up_to(&self, entity_id: u32, depth: u32) -> Vec { + self.bfs_depth(entity_id, depth, true) + } + pub fn callers_up_to(&self, entity_id: u32, depth: u32) -> Vec { + self.bfs_depth(entity_id, depth, false) + } + fn bfs_depth(&self, start: u32, depth: u32, forward: bool) -> Vec { + if depth == 0 { return Vec::new(); } + let mut visited: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + let mut q: VecDeque<(u32, u32)> = VecDeque::new(); + q.push_back((start, 0)); + visited.insert(start); + while let Some((node, d)) = q.pop_front() { + if d == depth { continue; } + let neigh = if forward { &self.call_edges } else { &self.reverse_call_edges }; + for &n in neigh.get(node as usize).unwrap_or(&Vec::new()) { + if visited.insert(n) { + out.push(n); + q.push_back((n, d + 1)); + } + } + } + out + } + + // Weighted PageRank over multi-edge graph using configured RankWeights. + pub fn compute_pagerank(&mut self) { + let n = self.entities.len(); + if n == 0 { return; } + let RankWeights { call: w_call, import: w_import, containment: w_contain, damping, iterations } = self.rank_weights; + let init = 1.0f32 / n as f32; + let mut rank = vec![init; n]; + let mut new_rank = vec![0.0f32; n]; + // Pre-build adjacency with normalized probabilities per source entity + let mut outgoing: Vec> = Vec::with_capacity(n); + for i in 0..n { + let mut edges: Vec<(u32, f32)> = Vec::new(); + for &t in self.call_edges.get(i).unwrap_or(&Vec::new()) { edges.push((t, w_call)); } + for &t in self.import_edges.get(i).unwrap_or(&Vec::new()) { edges.push((t, w_import)); } + for &t in self.containment_children.get(i).unwrap_or(&Vec::new()) { edges.push((t, w_contain)); } + if let Some(Some(p)) = self.containment_parent.get(i) { edges.push((*p, w_contain)); } + let sum: f32 = edges.iter().map(|(_, w)| *w).sum(); + if sum > 0.0 { for e in edges.iter_mut() { e.1 /= sum; } } + outgoing.push(edges); + } + let teleport = (1.0 - damping) / n as f32; + for _ in 0..iterations { + // reset + new_rank.fill(0.0); + let mut dangling_sum = 0.0f32; + for i in 0..n { + let r = rank[i]; + if outgoing[i].is_empty() { dangling_sum += r; continue; } + for &(t, w) in &outgoing[i] { new_rank[t as usize] += r * w * damping; } + } + let dangling_contrib = if n > 0 { (dangling_sum * damping) / n as f32 } else { 0.0 }; + for i in 0..n { new_rank[i] += teleport + dangling_contrib; } + std::mem::swap(&mut rank, &mut new_rank); + } + // Store back into entities + for (i, r) in rank.into_iter().enumerate() { if let Some(ent) = self.entities.get_mut(i) { ent.rank = r; } } + } +} diff --git a/crates/goose/src/repo_index/tests.rs b/crates/goose/src/repo_index/tests.rs new file mode 100644 index 000000000000..4d32379d31b3 --- /dev/null +++ b/crates/goose/src/repo_index/tests.rs @@ -0,0 +1,26 @@ +#[cfg(test)] +#[cfg(feature = "repo-index")] +mod repo_index_tests { + use super::super::*; + use std::io::Cursor; + use std::path::Path; + use tempfile::tempdir; + + #[test] + fn extracts_rust_function_and_doc() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("lib.rs"); + std::fs::write(&file_path, "/// Adds two numbers\nfn add(a: i32, b: i32) -> i32 { a + b }\n").unwrap(); + let mut buf: Vec = Vec::new(); + let opts = crate::repo_index::RepoIndexOptions::builder() + .root(dir.path()) + .output_writer(&mut buf) + .build(); + let stats = crate::repo_index::index_repository(opts).unwrap(); + assert_eq!(stats.files_indexed, 1); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("add"), "output: {out}"); + assert!(out.contains("Adds two numbers"), "output: {out}"); + assert!(stats.entities_indexed >= 1); + } +} diff --git a/crates/goose/src/repo_index/tool.rs b/crates/goose/src/repo_index/tool.rs new file mode 100644 index 000000000000..3964f45b0b1f --- /dev/null +++ b/crates/goose/src/repo_index/tool.rs @@ -0,0 +1,37 @@ +use super::service::{RepoIndexService, StoredEntity}; + +/// Summary information about the indexed repository. +#[derive(Debug, Clone)] +pub struct RepoSummary { + pub files: usize, + pub entities: usize, +} + +/// Trait abstraction for repository search & graph queries (Step 3). +pub trait RepoSearchTool { + /// Exact symbol lookup (case-insensitive) returning entity ids. + fn search_symbol_exact_ids(&self, name: &str) -> Vec; + /// Exact symbol lookup returning entity references. + fn search_symbol_exact(&self, name: &str) -> Vec<&StoredEntity>; + /// Depth-limited forward (callee) traversal. + fn callees_up_to(&self, entity_id: u32, depth: u32) -> Vec; + /// Depth-limited reverse (caller) traversal. + fn callers_up_to(&self, entity_id: u32, depth: u32) -> Vec; + /// Access an entity by id. + fn entity(&self, id: u32) -> Option<&StoredEntity>; + /// Return overall summary. + fn summary(&self) -> RepoSummary; +} + +impl RepoSearchTool for RepoIndexService { + fn search_symbol_exact_ids(&self, name: &str) -> Vec { + self.symbol_ids_exact(name).to_vec() + } + fn search_symbol_exact(&self, name: &str) -> Vec<&StoredEntity> { + RepoIndexService::search_symbol_exact(self, name) + } + fn callees_up_to(&self, entity_id: u32, depth: u32) -> Vec { self.callees_up_to(entity_id, depth) } + fn callers_up_to(&self, entity_id: u32, depth: u32) -> Vec { self.callers_up_to(entity_id, depth) } + fn entity(&self, id: u32) -> Option<&StoredEntity> { self.entities.get(id as usize) } + fn summary(&self) -> RepoSummary { RepoSummary { files: self.files.len(), entities: self.entities.len() } } +} diff --git a/crates/goose/tests/repo_index_tests.rs b/crates/goose/tests/repo_index_tests.rs new file mode 100644 index 000000000000..e59954ef0f1a --- /dev/null +++ b/crates/goose/tests/repo_index_tests.rs @@ -0,0 +1,206 @@ +#![cfg(feature = "repo-index")] + +use goose::repo_index::service::RepoIndexService; use goose::repo_index::{RepoIndexOptions, RepoIndexOutput}; +use std::path::Path; + +// Consolidated tests operating over the shared example repository fixtures. + +fn build_example_service() -> RepoIndexService { + // examples/example-treesitter-repo/src relative to crate + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/example-treesitter-repo/src"); + let root = root.canonicalize().expect("canonical example path"); + let mut sink = std::io::sink(); + let opts = goose::repo_index::RepoIndexOptions { root: root.as_path(), include_langs: None, output: goose::repo_index::RepoIndexOutput::Writer(&mut sink), progress: None }; + let (svc, _stats) = RepoIndexService::build(opts).expect("build index"); + svc +} + +#[test] +fn test_languages_present() { + let svc = build_example_service(); + let expected = ["rust","python","javascript","typescript","swift","java","cpp","c_sharp","go"]; + for l in expected.iter() { + assert!(svc.files.iter().any(|f| f.language==*l), "language missing: {l}"); + } +} + +#[test] +fn test_python_unresolved_imports() { + let svc = build_example_service(); + let idx = svc.files.iter().position(|f| f.path.ends_with("python/test.py")).expect("python test file"); + let unresolved = svc.unresolved_imports_for_file_index(idx); + assert!(unresolved.iter().any(|u| u=="math"), "expected math unresolved (got {:?})", unresolved); +} + +#[test] +fn test_basic_symbol_search() { + let svc = build_example_service(); + let hits = svc.search_symbol_exact("greet"); + assert!(!hits.is_empty(), "expected greet function"); +} + +#[test] +fn test_file_entities_exist() { + let svc = build_example_service(); + // ensure each file has a pseudo-entity entry + for f in &svc.files { + let file_entity = svc.file_entities[f.id as usize]; + let ent = &svc.entities[file_entity as usize]; + assert_eq!(ent.kind.as_str(), "file"); + } +} + +#[test] +fn test_rust_call_graph_traversal_present() { + let svc = build_example_service(); + // Just ensure traversal functions run without panic; pick first non-file entity + if let Some(ent) = svc.entities.iter().find(|e| e.kind.as_str()!="file") { + let _callees = svc.callees_up_to(ent.id, 1); + let _callers = svc.callers_up_to(ent.id, 1); + } +} + +#[test] +fn test_import_edges_any_present() { + let svc = build_example_service(); + // At least one file should have import edges (python or others) + let any = svc.entities.iter().filter(|e| e.kind.as_str()=="file").any(|e| !svc.imported_files(e.id).is_empty()); + assert!(any, "expected at least one file with import edges"); +} + +use std::fs::File as FsFile; use std::io::Write; use tempfile::tempdir; + +// Helper for ad-hoc temp repos +fn build_temp_repo(root: &std::path::Path) -> RepoIndexService { + let mut sink = std::io::sink(); + let opts = RepoIndexOptions { root, include_langs: None, output: RepoIndexOutput::Writer(&mut sink), progress: None }; + let (svc, _stats) = RepoIndexService::build(opts).expect("build temp repo"); + svc +} + +#[test] +fn test_cross_file_unique_call_and_traversal() { + let dir = tempdir().unwrap(); + // file a defines unique function alpha & beta + let file_a = dir.path().join("a.rs"); + FsFile::create(&file_a).unwrap().write_all(b"pub fn alpha() {}\npub fn beta() {}\n").unwrap(); + // file b calls alpha + let file_b = dir.path().join("b.rs"); + FsFile::create(&file_b).unwrap().write_all(b"fn gamma() { alpha(); }\n").unwrap(); + let svc = build_temp_repo(dir.path()); + let alpha = svc.search_symbol_exact("alpha"); + let gamma = svc.search_symbol_exact("gamma"); + assert_eq!(alpha.len(), 1, "alpha should be unique"); + assert_eq!(gamma.len(), 1, "gamma should be indexed once"); + let alpha_id = alpha[0].id; let gamma_id = gamma[0].id; + assert_ne!(alpha[0].file_id, gamma[0].file_id, "Ensure cross-file scenario"); + assert!(svc.outgoing_calls(gamma_id).contains(&alpha_id), "gamma should call alpha"); + assert!(svc.incoming_calls(alpha_id).contains(&gamma_id), "alpha should have reverse edge"); + assert!(svc.callees_up_to(gamma_id,1).contains(&alpha_id)); + assert!(svc.callers_up_to(alpha_id,1).contains(&gamma_id)); +} + +#[test] +fn test_python_import_edges_and_unresolved() { + let dir = tempdir().unwrap(); + FsFile::create(dir.path().join("a.py")).unwrap().write_all(b"import b, json as jsn\nfrom . import b as bmod\nfrom a import something\n").unwrap(); + FsFile::create(dir.path().join("b.py")).unwrap().write_all(b"def foo():\n return 1\n").unwrap(); + let svc = build_temp_repo(dir.path()); + // locate file entities by filename + let mut a_file_entity=None; let mut b_file_entity=None; + for e in &svc.entities { if e.kind.as_str()=="file" { let stem=std::path::Path::new(&svc.files[e.file_id as usize].path).file_stem().unwrap().to_str().unwrap(); if stem=="a" { a_file_entity=Some(e.id);} if stem=="b" { b_file_entity=Some(e.id);} }} + let a_id = a_file_entity.expect("a file entity"); + let b_id = b_file_entity.expect("b file entity"); + assert!(svc.imported_files(a_id).contains(&b_id), "a should import b"); + let unresolved = svc.unresolved_imports_for_file_index(0); + assert!(unresolved.iter().any(|u| u=="json"), "json expected unresolved, got {:?}", unresolved); +} + +#[test] +fn test_rust_mod_and_use_resolution() { + let dir = tempdir().unwrap(); + FsFile::create(dir.path().join("lib.rs")).unwrap().write_all(b"mod foo;\nuse crate::foo;\n").unwrap(); + FsFile::create(dir.path().join("foo.rs")).unwrap().write_all(b"pub fn bar(){}\n").unwrap(); + let svc = build_temp_repo(dir.path()); + let mut lib_entity=None; let mut foo_entity=None; + for e in &svc.entities { if e.kind.as_str()=="file" { let stem=std::path::Path::new(&svc.files[e.file_id as usize].path).file_stem().unwrap().to_str().unwrap(); if stem=="lib" { lib_entity=Some(e.id);} if stem=="foo" { foo_entity=Some(e.id);} }} + let lib_id=lib_entity.unwrap(); let foo_id=foo_entity.unwrap(); + assert!(svc.imported_files(lib_id).contains(&foo_id), "lib should import foo module"); +} + +#[test] +fn test_cpp_local_include() { + let dir = tempdir().unwrap(); + FsFile::create(dir.path().join("add.h")).unwrap().write_all(b"#pragma once\nint add(int a,int b);").unwrap(); + FsFile::create(dir.path().join("main.cpp")).unwrap().write_all(b"#include \"add.h\"\nint add(int a,int b){return a+b;}").unwrap(); + let svc = build_temp_repo(dir.path()); + let mut main_entity=None; let mut add_entity=None; + for e in &svc.entities { if e.kind.as_str()=="file" { let stem=std::path::Path::new(&svc.files[e.file_id as usize].path).file_stem().unwrap().to_str().unwrap(); if stem=="main" { main_entity=Some(e.id);} if stem=="add" { add_entity=Some(e.id);} }} + let main_id=main_entity.unwrap(); let add_id=add_entity.unwrap(); + assert!(svc.imported_files(main_id).contains(&add_id), "main should import add via include"); +} + +#[test] +fn test_java_csharp_swift_basic() { + let dir = tempdir().unwrap(); + FsFile::create(dir.path().join("Util.java")).unwrap().write_all(b"package p; public class Util {} ").unwrap(); + FsFile::create(dir.path().join("Util.cs")).unwrap().write_all(b"public class Util {} ").unwrap(); + FsFile::create(dir.path().join("Program.cs")).unwrap().write_all(b"using System;\nusing Util;\nclass Program {}\n").unwrap(); + FsFile::create(dir.path().join("main.swift")).unwrap().write_all(b"import Util\n@testable import XCTest").unwrap(); + let svc = build_temp_repo(dir.path()); + // Identify file entity ids + let mut program_entity=None; let mut util_java=None; let mut swift_main=None; + for e in &svc.entities { if e.kind.as_str()=="file" { let stem=std::path::Path::new(&svc.files[e.file_id as usize].path).file_stem().unwrap().to_str().unwrap(); match stem { "Program" => program_entity=Some(e.id), "Util" => { if util_java.is_none() { util_java=Some(e.id);} }, "main" => swift_main=Some(e.id), _=>{} } }} + let program_id=program_entity.unwrap(); let swift_id=swift_main.unwrap(); + // Swift should import Util (heuristic expected) BUT allow fallback if not resolved (then should appear unresolved) + let swift_idx = svc.files.iter().position(|f| f.path.ends_with("main.swift")).unwrap(); + let swift_unresolved = svc.unresolved_imports_for_file_index(swift_idx); + let swift_imports = svc.imported_files(swift_id); + assert!(!swift_imports.is_empty() || !swift_unresolved.is_empty(), "expected swift main to have some import signal"); + // C# heuristic may not resolve local Util; ensure we at least parsed some using statements (either edges or unresolved) + let csharp_unresolved = svc.unresolved_imports_for_file_index(svc.files.iter().position(|f| f.path.ends_with("Program.cs")).unwrap()); + let program_imports = svc.imported_files(program_id); + assert!(!program_imports.is_empty() || !csharp_unresolved.is_empty(), "expected either resolved or unresolved imports for Program.cs"); +} + +#[test] +fn test_pagerank_variance() { + let svc = build_example_service(); + let mut ranks: Vec = svc.entities.iter().filter(|e| e.kind.as_str()!="file").map(|e| e.rank).collect(); + assert!(!ranks.is_empty(), "expected non-file entities to compute ranks"); + ranks.sort_by(|a,b| a.partial_cmp(b).unwrap()); + let min = ranks.first().copied().unwrap(); + let max = ranks.last().copied().unwrap(); + let delta = max - min; + assert!(delta > 1e-6, "expected rank variance > 1e-6 (min={min}, max={max})"); +} + +#[test] +fn test_env_override_rank_weights() { + use std::env; + // Save originals + let orig = env::var("GOOSE_REPO_RANK_CALL_WEIGHT").ok(); + env::set_var("GOOSE_REPO_RANK_CALL_WEIGHT", "2.0"); + env::set_var("GOOSE_REPO_RANK_IMPORT_WEIGHT", "0.1"); + env::set_var("GOOSE_REPO_RANK_CONTAINMENT_WEIGHT", "0.05"); + env::set_var("GOOSE_REPO_RANK_DAMPING", "0.9"); + env::set_var("GOOSE_REPO_RANK_ITERATIONS", "5"); + // Build small temp repo to minimize interference + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.rs"), b"pub fn a(){}\n").unwrap(); + std::fs::write(dir.path().join("b.rs"), b"fn b(){ a(); }\n").unwrap(); + let mut sink = std::io::sink(); + let opts = goose::repo_index::RepoIndexOptions { root: dir.path(), include_langs: None, output: goose::repo_index::RepoIndexOutput::Writer(&mut sink), progress: None }; + let (svc, _stats) = RepoIndexService::build(opts).expect("build with env overrides"); + assert!((svc.rank_weights.call - 2.0).abs() < 1e-6, "call weight override not applied"); + assert!((svc.rank_weights.import - 0.1).abs() < 1e-6, "import weight override not applied"); + assert!((svc.rank_weights.containment - 0.05).abs() < 1e-6, "containment weight override not applied"); + assert!((svc.rank_weights.damping - 0.9).abs() < 1e-6, "damping override not applied"); + assert_eq!(svc.rank_weights.iterations, 5, "iterations override not applied"); + // Restore / clear + if let Some(val) = orig { env::set_var("GOOSE_REPO_RANK_CALL_WEIGHT", val); } else { env::remove_var("GOOSE_REPO_RANK_CALL_WEIGHT"); } + env::remove_var("GOOSE_REPO_RANK_IMPORT_WEIGHT"); + env::remove_var("GOOSE_REPO_RANK_CONTAINMENT_WEIGHT"); + env::remove_var("GOOSE_REPO_RANK_DAMPING"); + env::remove_var("GOOSE_REPO_RANK_ITERATIONS"); +} diff --git a/crates/goose/tests/repo_tools_tests.rs b/crates/goose/tests/repo_tools_tests.rs new file mode 100644 index 000000000000..33adf3f0d359 --- /dev/null +++ b/crates/goose/tests/repo_tools_tests.rs @@ -0,0 +1,31 @@ +//! Integration tests for repo__search and repo__stats tools (auto-build path). +//! These exercise the handlers directly (not full agent loop) to keep scope small. + +#[cfg(feature = "repo-index")] +mod repo_tool_tests { + use serde_json::json; + use goose::agents::repo_tools; + + #[tokio::test] + async fn stats_then_search_autobuild() { + // Use repository root (current crate workspace) as test root. + // In CI this should be fine; build will skip if already cached. + let root = std::env::current_dir().unwrap(); + let root_str = root.to_string_lossy().to_string(); + + // Stats (auto-build if missing) + let stats = repo_tools::handle_repo_stats(json!({"root": root_str, "langs": ["rust"]})).await.expect("stats"); + assert!(stats["files"].as_u64().unwrap() > 0, "files count"); + assert!(stats["entities"].as_u64().unwrap() > 0, "entities count"); + + // Simple search for a known symbol (RepoIndexService itself) + let search = repo_tools::handle_repo_query(json!({ + "root": stats["root"].clone(), + "query": "RepoIndexService", + "limit": 5, + "show_score": true + })).await.expect("search result"); + let results = search["results"].as_array().unwrap(); + assert!(!results.is_empty(), "should find at least one symbol"); + } +} diff --git a/documentation/docs/experimental/repo-index-technical-reference.md b/documentation/docs/experimental/repo-index-technical-reference.md new file mode 100644 index 000000000000..d832ab3d43c9 --- /dev/null +++ b/documentation/docs/experimental/repo-index-technical-reference.md @@ -0,0 +1,175 @@ +--- +title: Repository Indexing Technical Reference +sidebar_label: Repo Index Technical Reference +sidebar_position: 6 +--- + +> Deep dive: architecture, configuration variables, data model, ranking, and upgrade guidance for the experimental repository indexing feature. + +## Overview +High‑level pipeline: Tree-sitter parses → entities (functions/types) → relationship graph (calls, imports, containment) → weighted PageRank → blended search (lexical + rank) → agent / CLI tools. + +## Feature Flags & Stability +The system is experimental; APIs and output schemas may evolve. Runtime exposure is controlled by `ALPHA_FEATURES=true`. The code is compiled in current workspace builds (CLI depends on the feature). Making it fully optional build‑time is a future consideration. + +## Architecture +1. Extraction: Tree-sitter per language (Rust, Python, JS/TS, Go, C#, Java, C++, Swift) produces raw entity records (legacy JSONL path retained for compatibility). +2. Normalization: Language‑specific kinds collapsed into a small internal enum (Function/Method, Type/Class/Struct/Enum/Trait, File pseudo entity, Other). +3. Graph Construction: Call edges, containment edges (parent ↔ child), import edges (file → file; unresolved tracked separately). +4. Ranking: PageRank across combined weighted edges. +5. Search: Lexical match tiers (exact > prefix > substring > Levenshtein<=2) blended with normalized rank; optional callers/callees expansion. +6. Tools: Agent tool layer caches built indexes; CLI and (future) UI invoke those tools. + +Diagram: +``` ++-------------+ +------------------+ +------------------+ +---------------------+ +| Source Tree | --> | Tree-sitter Pass | --> | RepoIndexService | --> | Agent Tools / CLI | ++-------------+ +------------------+ | Graph + Rank | | Build / Search | + +------------------+ +---------------------+ +``` + +## Environment Variable Overrides (Ranking) +| Variable | Default | Description | +|----------|---------|-------------| +| `GOOSE_REPO_RANK_CALL_WEIGHT` | 1.0 | Weight of call edges | +| `GOOSE_REPO_RANK_IMPORT_WEIGHT` | 0.5 | Weight of import edges | +| `GOOSE_REPO_RANK_CONTAINMENT_WEIGHT` | 0.2 | Weight for containment (both directions) | +| `GOOSE_REPO_RANK_DAMPING` | 0.85 | PageRank damping factor | +| `GOOSE_REPO_RANK_ITERATIONS` | 20 | Iteration count | + +Rules: +- All edge weights must be ≥ 0. If all three weights are 0, defaults are restored to avoid degenerate matrix. +- Damping in [0.0,1.0]. Iterations in [1,200]. + +## Search Ranking Formula +`final_score = 0.6 * lexical_score + 0.4 * normalized_rank` (subject to future tuning). Exact‑only mode bypasses blending and returns lexical exact matches. Lexical tiers assign a tier score then normalize 0–1. + +## Minimal Debug CLI Example (Optional) +``` +ALPHA_FEATURES=true GOOSE_REPO_RANK_CALL_WEIGHT=1.2 goose repo query --path . --symbol RepoIndexService --show-score --callers-depth 1 +``` +Use only for inspecting extraction or ranking; the agent normally triggers and consumes the index automatically. + +## Agent Tools +| Tool | Purpose | Key Arguments | Output Highlights | +|------|---------|---------------|-------------------| +| `repo__search` | Fuzzy + ranked search (auto-builds) | `root`, `query`, `limit`, `exact_only`, `min_score`, `show_score`, `callers_depth`, `callees_depth`, `langs[]` | ranked results | +| `repo__stats` | Repo statistics (auto-builds if missing) | `root`, `langs[]` | entity/file counts, unresolved imports, weights | + +### Search Tool Output Example +``` +{ + "results": [ + { + "id": 17, + "name": "RepoIndexService", + "kind": "class", + "file": "crates/goose/src/repo_index/service.rs", + "rank": 0.0123, + "score": 0.94, + "callers": [21], + "callees": [42,55] + } + ] +} +``` + +## Programmatic Build Snippet (Rust) – Debug / Bench Only +```rust +use goose::repo_index::RepoIndexOptions; +use goose::repo_index::service::RepoIndexService; +use std::path::Path; +let opts = RepoIndexOptions::builder() + .root(Path::new(".")) + .output_null() // in-memory only; prefer this for benchmarks + .build(); +let (_svc, stats) = RepoIndexService::build(opts)?; +println!("{} entities", stats.entities_indexed); +``` + +## Data Model (Simplified Current) +``` +FileRecord { id: u32, path: String, language: &'static str, entities: Vec } +StoredEntity { + id: u32, + file_id: u32, + kind: EntityKind, // Class | Function | Method | File | Other + name: String, + parent: Option, + signature: String, + start_line: u32, + end_line: u32, + calls: Option>, // unresolved callee names + doc: Option, + rank: f32, +} +``` + +## Per‑language Extraction Notes +- JavaScript / TypeScript: classes, functions, methods, doc comments, call relationships. +- Python: classes, functions (decorators/docstrings), parent and call relationships. +- Rust: structs, enums, traits, impl functions, docs, calls. +- C++: classes, templates, functions, heuristic call edges. +- Go: types, fields, functions, vars, imports, calls. +- Java / C# / Swift: classes/types, methods/functions, baseline call extraction. + +Limitations: variable/field granularity uneven; some languages collect fewer relationship edges; incremental watch mode not yet implemented. + +## Caching Strategy (Agent Internal) +Per canonical root path the agent keeps an in-memory index. First `repo__search` / `repo__stats` triggers build; TTL (env `GOOSE_REPO_INDEX_TTL_SECS`, default 600) causes next query after expiry to rebuild. A per-root async mutex prevents duplicate concurrent builds. + +## Limitations & Roadmap +- No incremental / watch rebuild yet +- Import resolution heuristic only (multi-module/package edge cases) +- Blend weights (0.6/0.4) fixed for now; env controls only PageRank parameters +- Potential memory optimizations (string interning, arenas) pending +- Future: incremental graph updates, richer entity kinds, additional ranking signals + +## Testing (Summary) +Tests validate rank weight overrides, fuzzy ordering, min score filtering, and tool integration (build/search/stats). Additions should extend these suites rather than introduce ad‑hoc test binaries. + +## Upgrade Guidance (Tree-sitter Versions) +Currently targets grammar family around `tree-sitter` 0.20.x for multi-language parity. Migrating to 0.23.x prematurely risks native build conflicts. To upgrade: +1. Audit each language grammar crate for compatible versions. +2. Align all grammar versions & their transitive build deps (`cc`, etc.). +3. Update versions & rebuild (optionally with `--features repo-index`). +4. Run extraction + search tests; confirm no AST kind regressions. +5. Defer if even one core language grammar lags significantly. + +## Performance Considerations +## Observability +Tracing events (all INFO level unless otherwise noted): + +| Event | Description | Fields | +|-------|-------------|--------| +| `repo.index.build` | Index (re)build completed | `root`, `duration_ms`, `files`, `entities`, `trigger` (`query`/`stats`/`background`/`watch`), `background` (bool), `ttl_secs?` | +| `repo.index.search` | Symbol search finished | `root`, `query`, `results`, `limit`, `exact_only`, `callers_depth`, `callees_depth` | +| `repo.index.stats` | Stats retrieval finished | `root`, `files`, `entities` | + +Counters exposed via tracing metadata (monotonic): +| Counter | Meaning | +|---------|---------| +| `counter.goose.repo.builds` | Successful builds | +| `counter.goose.repo.search_calls` | Search tool invocations | +| `counter.goose.repo.stats_calls` | Stats tool invocations | + +Recommended derived metrics: +- Build latency p50 / p95 +- Searches per session before first answer +- Cache reuse ratio = (search_calls - builds) / search_calls +- Time_to_first_build (process start → first build event) + +Potential improvements under evaluation: +- Parallel parsing / controlled thread pool +- String interning and arena allocation +- Optional reduced capture mode (skip docs/signatures) for speed +- Incremental rebuild (watch mode) with dependency tracking + +## Rationale Recap +Structured, ranked indexing unlocks faster symbol discovery, better agent planning, impact analysis (callers), and extensibility for future relationship types—all while staying local. + +## Contributing +Enhancements welcome: new language constructs, improved import resolution, watch mode, alternative ranking signals, performance tuning. Please include tests + docs updates in PRs. + +--- +*Experimental: Interfaces and JSON output may change. Pin to a commit or gate usage downstream.* diff --git a/documentation/docs/experimental/repo-search-with-tree-sitter-indexing.md b/documentation/docs/experimental/repo-search-with-tree-sitter-indexing.md new file mode 100644 index 000000000000..48a9af299453 --- /dev/null +++ b/documentation/docs/experimental/repo-search-with-tree-sitter-indexing.md @@ -0,0 +1,384 @@ +--- +title: Repository Search with Tree-sitter Indexing +sidebar_label: Repository Search (Experimental) +sidebar_position: 5 +--- + +> Experimental: Gives Goose a structured understanding of your codebase so it can answer deeper questions (relationships, key entry points, symbol lookup) across multiple languages. + +## What Is This? +An internal capability the LLM agent uses (not something you routinely invoke) that scans the repository and keeps an in‑memory graph of symbols (functions, types, etc.) plus relationships (callers, callees, containment, imports). The agent consults this graph before or during multi‑step reasoning to choose better starting points and jump across code logically. + +## Why You Might Care +| If you want to… | Indexing helps by… | +|------------------|--------------------| +| Find where to start in a large unfamiliar repo | Surfacing ranked "central" entry points | +| Jump across a call chain while debugging | Providing callers / callees relationships | +| Ask “Who uses X?” or “Where is Y defined?” | Resolving definitions & references quickly | +| Reduce irrelevant file reads by the agent | Prioritizing important symbols first | +| Work in a polyglot repo | Normalizing entities across languages | + +## Common Use Cases +- On‑ramp to a new service: “List key entry points related to auth/session.” +- Debugging a failing request path by traversing callers. +- Estimating impact before a refactor (who calls this function?). +- Quickly locating a type/struct/class whose name you only half remember. +- Improving multi‑step agent plans that need good starting symbols. + +## Key Benefits (User View) +| Benefit | What You Get | +|---------|--------------| +| Faster symbol lookup | Jump to definitions or related code quickly | +| Call graph awareness | Ask about callers / callees of important functions | +| Cross‑language support | Mixed repos (e.g. frontend TS + backend Rust + scripts) still index | +| Better agent decisions | Improves tool routing & reduces irrelevant file reads | +| Lightweight & local | Runs locally; no code leaves your machine | + +## When Does It Run? +You generally do not run anything manually. The first time the agent needs structural code insight it transparently builds the index (in memory). A time‑to‑live (TTL, default 600s) triggers a silent refresh later. Optional background mode can pre‑warm the index. Manual CLI queries exist only for debugging. + +## Quick Start (Agent Focused) +1. Enable experimental features: +``` +export ALPHA_FEATURES=true +``` +2. Start Goose (CLI, desktop, or web) and simply ask a structural question, e.g.: + “List key entry points handling scheduling logic” or “Who calls RepoIndexService::build?” +3. The agent will auto‑build (first use) then answer using ranked symbols + call graph data. +4. (Optional) Inspect status: +``` +ALPHA_FEATURES=true goose repo status --path . +ALPHA_FEATURES=true goose repo status --path . --json +``` +5. (Optional, debugging) Run a direct symbol query outside the agent: +``` +ALPHA_FEATURES=true goose repo query --path . --symbol AuthService +``` +This bypasses reasoning and just prints raw matches – useful for verifying extraction. + +## Desktop UI (Optional Visibility) +If the menu item “Index Repository (Tree-sitter)” is enabled (ALPHA_FEATURES), invoking it forces an immediate build; otherwise the agent will still build lazily on demand. For normal usage you can ignore the menu and just converse. + +## Supported Languages (Current) +Rust, Python, JavaScript, TypeScript, Go, C#, Java, C++, Swift. + +## What Changes After Indexing? +Before: Goose relies on ad‑hoc fuzzy text scans. +After: Goose can resolve symbol definitions, surface ranked “central” entities sooner, and traverse callers / callees for richer answers. + +## Example Output (Truncated) +``` +ALPHA_FEATURES=true goose repo query --path . --symbol RepoIndexService --show-score +{ + "results": [ + { + "name": "RepoIndexService", + "kind": "class", + "file": "crates/goose/src/repo_index/service.rs", + "rank": 0.0123, + "score": 0.94 + } + ] +} +``` + +## Enabling / Disabling +- Set `ALPHA_FEATURES=true` to expose the feature (CLI + UI menu / background auto index). Unset it to hide. +- Optional env for background behavior: `GOOSE_AUTO_INDEX=0` (disable), `GOOSE_AUTO_INDEX_WATCH=1` (enable watch), `GOOSE_AUTO_INDEX_WRITE_FILE=1` (persist JSONL on background runs). + - Status meta file: `.goose-repo-index.meta.json` (written after each background/manual run when ALPHA_FEATURES enabled) + +## Typical Workflow (Agent Centric) +1. Open a project. +2. Ask a question needing structural understanding. +3. Agent triggers first build (transparent) and caches it. +4. Further symbol questions use the cache; a refresh happens silently after TTL or via watch mode. +5. Optionally inspect with `repo status` if you want confirmation or counts. + +## Troubleshooting +| Issue | Fix | +|-------|-----| +| Menu item missing | Ensure `ALPHA_FEATURES=true` was exported before launching the UI | +| CLI says experimental | Re-run with `ALPHA_FEATURES=true` prefixed | +| Output file empty / tiny | Verify there are supported language files; check for glob‑ignored paths | +| Slow first build | Large repos: allow full pass; subsequent builds reuse OS caches | + +## Limitations (User Level) +- No automatic watch mode yet—rerun after large refactors. +- Only symbol/function level granularity; variable/field level coverage is minimal in some languages. +- Ranking heuristics are experimental and may change. + +## Want the Deep Dive? +Full technical architecture, data model, ranking math, environment overrides, search scoring, and upgrade guidance live in the separate technical reference: + +➡️ See: [Repository Indexing Technical Reference](./repo-index-technical-reference) + +--- +*Experimental: interfaces and JSON output may change. Pin to a commit or feature‑gate usage in downstream tooling.* + + + +## Feature Flags & Stability +The repository indexing system is experimental; APIs and output schemas may evolve. The Rust implementation currently builds in the `repo-index` code by default (via the CLI crate's dependency enabling the feature). Exposure to end users is controlled at runtime by the environment variable `ALPHA_FEATURES`. + +### Enabling the feature (current behavior) + +Runtime opt‑in only: + +1. Set `ALPHA_FEATURES=true` to expose the experimental CLI `repo` subcommands and the desktop UI menu item "Index Repository (Tree-sitter)". +2. Launch the CLI / UI as usual. + +Examples: + +Enable for the desktop app during development: +``` +export ALPHA_FEATURES=true +cd ui/desktop +npm run start-gui +``` + +Just recipe (if defined) for UI: +``` +just run-ui-alpha # assumes it exports ALPHA_FEATURES=true for you +``` + +Without `ALPHA_FEATURES=true` the agent falls back to lighter text heuristics only (no structured symbol graph) and the repo subcommands/menu stay hidden. + +### About the Cargo feature +Internally the code is still feature‑gated with `repo-index` (plus language specific `tree-sitter-*` features). Because the CLI crate depends on the core crate with `features = ["repo-index"]`, workspace builds already compile the indexing code—no extra `--features` flag is required right now. If the project later decides to make the build truly optional (e.g. to reduce compile time or binary size) documentation will be updated with the explicit build command. + +If you are experimenting locally and want to trim unused grammars, you can manually adjust the dependency features and rebuild (advanced / not required for normal usage). + +## Architecture at a Glance +1. Tree-sitter extraction streams JSONL entities (legacy path retained). +2. `RepoIndexService::build` loads entities into memory, constructs graphs, runs PageRank. +3. Search layer blends lexical similarity with normalized rank for ordering. +4. Agent tool layer exposes search/stats (auto-build on demand) with caching. +5. CLI / UI expose only thin debugging/status surfaces; normal users rely purely on agent behavior. + +``` ++-------------+ +------------------+ +------------------+ +---------------------+ +| Source Tree | --> | Tree-sitter Pass | --> | RepoIndexService | --> | Agent Tools / CLI | ++-------------+ +------------------+ | Graph + Rank | | Build / Search | + +------------------+ +---------------------+ +``` + +## Extracted Entities & Relationships +Languages supported (0.20.x grammar family): Rust, Python, JavaScript, TypeScript, Go, C#, Java, C++, Swift. + +For each file we record entities (language-specific kinds collapsed to: class/struct/type, function/method, other, file pseudo-entity). Captured fields include: +- `file`, `language`, `kind`, `name`, `parent`, `signature`, `start_line`, `end_line`, `doc`, `calls` (if collected). + +### Relationship Graph +Edges stored during build: +- Call edges (function → callee) +- Containment edges (parent ↔ child, bidirectional weight share) +- Import edges (file → imported file) with unresolved imports tracked separately. + +### PageRank Weights (Env Overrides) +| Variable | Default | Meaning | +|----------|---------|---------| +| `GOOSE_REPO_RANK_CALL_WEIGHT` | 1.0 | Weight of call edges | +| `GOOSE_REPO_RANK_IMPORT_WEIGHT` | 0.5 | Weight of import edges | +| `GOOSE_REPO_RANK_CONTAINMENT_WEIGHT` | 0.2 | Weight for containment (both directions) | +| `GOOSE_REPO_RANK_DAMPING` | 0.85 | PageRank damping factor | +| `GOOSE_REPO_RANK_ITERATIONS` | 20 | Iteration count | + +If all three edge weights are zero defaults are restored to avoid a degenerate matrix. + +## Search Ranking +Lexical tiers (exact > prefix > substring > Levenshtein<=2) produce a lexical score (0–1). Final score = `0.6 * lexical + 0.4 * normalized_rank` (subject to future tuning). Exact-only mode bypasses blending. + +Filters / flags: +- `--exact-only` +- `--min-score ` +- `--show-score` (surfaced in tool JSON) +- Depth-limited traversal: callers / callees. + +## Agent Tools (Internal) +| Tool | Purpose (Agent) | Notes | +|------|-----------------|-------| +| `repo__search` | Retrieve ranked symbol matches + optional callers/callees | Triggers initial build if cache absent / expired | +| `repo__stats` | Lightweight counts / weights (also auto-build) | Mainly for debugging & status surfacing | + +### Search Tool Output +``` +{ + "results": [ + { + "id": , + "name": "symbol", + "kind": "function" | "class" | ..., + "file": "relative/or/abs/path", + "rank": , + "score": , + "callers": []?, + "callees": []? + } + ] +} +``` + +### Stats Tool Output +``` +{ + "root": "/abs/path", + "files": , + "entities": , + "unresolved_imports_files": , + "rank_weights": { + "call": , + "import": , + "containment": , + "damping": , + "iterations": + } +} +``` + +## Debug CLI Example +``` +ALPHA_FEATURES=true goose repo query --path . --symbol RepoIndexService --show-score --callers-depth 1 +``` +Use only to inspect extraction / ranking; the agent already does this internally. + +## Programmatic Build +```rust +use goose::repo_index::{RepoIndexOptions}; +use std::path::Path; +let opts = RepoIndexOptions::builder() + .root(Path::new(".")) + .output_file(Path::new("/dev/null")) + .build(); +let (svc, stats) = goose::repo_index::service::RepoIndexService::build(opts)?; +println!("{} entities", stats.entities_indexed); +``` + +## Caching Strategy (Invisible to User) +Per-root in-memory cache keyed by canonical path. First tool call builds; TTL (env `GOOSE_REPO_INDEX_TTL_SECS`, default 600) triggers rebuild on next access. Set TTL to 0 to pin the initial build. + +## Limitations & Roadmap +- No incremental / watch rebuild yet +- Import resolution heuristic; does not fully resolve packages/modules across complex layouts. +- Blended score weights fixed in code (env overrides only for PageRank, not lexical/rank blend proportion yet). +- Potential memory optimizations (string interning, arena allocation) not implemented. + +## Testing +Unified tests cover: +- Rank variance & env overrides. +- Fuzzy ordering & minimum score filter. +- Tool integration (build/search/stats) via `repo_tools_tests`. + +## Migration Notes +This feature is new; earlier draft documents have been removed after consolidation. + +## Implementation Phases +High-level phased rollout of the feature (current status: Steps 1–6 and 8 complete; Step 7 watch mode pending): + +1. Service Skeleton: In‑memory `RepoIndexService`, entity storage, inverted index, JSONL export parity. +2. Graph Construction: Call + containment edges, file pseudo‑entities, traversal helpers (BFS callers/callees). +3. Query & CLI: CLI `goose repo query ` with optional traversal depth. +4. Import Extraction: Per‑language heuristics (python, js/ts, go, rust, cpp includes, java, c_sharp, swift) + unresolved tracking. +5. PageRank: Weighted centrality over combined edges with env overrides. +6. Fuzzy & Ranked Search: Lexical tiers + blended score ordering; min score & exact‑only flags. +7. Incremental / Watch (Planned): Not yet implemented; future partial rebuild + rank refresh. +8. Agent Tool Integration: Expose build/search/stats as agent tools & caching layer. + +## Data Model (Current) +Canonical (subject to change): +``` +FileRecord { + id: u32, + path: String, + language: &'static str, + entities: Vec, +} + +StoredEntity { + id: u32, + file_id: u32, + kind: EntityKind, // Class | Function | Method | File | Other + name: String, + parent: Option, + signature: String, + start_line: u32, + end_line: u32, + calls: Option>, // raw callee names (resolved later) + doc: Option, + rank: f32, // filled after PageRank +} +``` + +## Per‑language Extraction Details +Expanded specifics per language (extraction richness varies): + +- JavaScript / TypeScript: classes, functions (top‑level + methods), doc comments, intra‑function call relationships. +- Python: classes, functions (with decorators + docstrings), parent relationships, call relationships. +- Rust: structs, enums, traits, impl fns (names, signatures, doc comments), call relationships. +- C++: classes, templates, functions, call relationships (heuristic; templates recorded as entities). +- Go: types (struct/interface/etc.), fields, functions (with generics where present), variables (top‑level), imports, call relationships. +- Java / C# / Swift: classes/structs/protocols, methods/functions, call relationships (baseline extraction). + +Common entity fields: `file`, `language`, `kind`, `name`, `parent`, `signature`, `start_line`, `end_line`, `doc`, `calls` (where available). + +Limitations per language mirror general limitations: additional constructs (variables, constants, properties, enum variants) mostly unindexed outside Go. + +## Environment Variable Constraints +Existing table lists defaults; implicit constraints retained here for clarity: +- Edge weights (`*_WEIGHT`) must be >= 0. If all three are 0, defaults are restored to avoid a degenerate matrix. +- `GOOSE_REPO_RANK_DAMPING` in [0.0,1.0]. +- `GOOSE_REPO_RANK_ITERATIONS` in [1,200]. + +## Performance & Future Optimization Notes +## Observability (Events & Metrics) +The indexing layer emits structured tracing events you can forward to OTLP / Langfuse / logs. Each event includes stable fields for internal dashboards. + +| Event | When | Key Fields | +|-------|------|------------| +| `repo.index.build` | Any index build (lazy query, stats, background, watch) | `root`, `duration_ms`, `files`, `entities`, `trigger` (`query`\|`stats`\|`background`\|`watch`), `ttl_secs?`, `background` (bool) | +| `repo.index.search` | After a symbol search completes | `root`, `query`, `results`, `limit`, `exact_only`, `callers_depth`, `callees_depth` | +| `repo.index.stats` | After stats gathered | `root`, `files`, `entities` | + +Monotonic counters (prefixed via tracing metadata): +| Counter | Increment Condition | +|---------|---------------------| +| `counter.goose.repo.builds` | Each successful build (any trigger) | +| `counter.goose.repo.search_calls` | Each search invocation | +| `counter.goose.repo.stats_calls` | Each stats invocation | + +Example log line (conceptual): +``` +INFO event=repo.index.build counter.goose.repo.builds=1 root="/workspace" trigger="query" duration_ms=842 files=120 entities=1435 ttl_secs=600 Repository index built (query path) +``` + +Dashboards can chart: build durations (p95), search results count distribution, build frequency per root, time from process start to first build. + +Initial considerations (summarized): +- Potential parallel parse (currently sequential/simple concurrency depending on implementation state). +- String interning / arena allocation to reduce memory for large repos. +- Optional reduced mode skipping doc/signature capture for speed. +- Incremental rebuild (watch) to avoid full parse on small edits (future Step 7). + + + +## Tree-sitter Version Compatibility +Current grammar family targets `tree-sitter` 0.20.x to maximize multi-language support (javascript, typescript, go, c_sharp, java, python, rust, cpp, swift). Many upstream grammar crates have not fully migrated to 0.23.x at the time of writing; attempting to upgrade prematurely can trigger `cc` crate / native lib conflicts. Track grammar crate releases and only upgrade when a consistent set for all enabled languages is available. + +### Upgrade Guidance +1. Check crates.io (or upstream repos) for each language grammar crate version supporting the desired core `tree-sitter`. +2. Ensure all grammar crates + core crate share compatible `cc` / build dependencies. +3. Update versions in one commit; run full build with `--features repo-index`. +4. Re-run extraction tests + a smoke search to ensure no AST kind regressions. +5. If a subset lags behind, prefer deferring upgrade vs. fragmenting support. + +## Rationale +An in-memory, graph-enriched repository index unlocks: +- Fast iterative symbol search (avoids repeated JSON streaming passes). +- Higher-level reasoning: influence (PageRank), impact analysis (callers/callees), import surface. +- Improved agent tool routing by surfacing central entities rather than only lexical matches. +- Extensibility: future incremental updates, richer relationship extraction. + +## Contributing +Contributions welcome: additional language constructs, improved import resolution, incremental indexing, richer search filters. + +--- +*Experimental: interfaces and JSON output may change. Pin to a commit or feature-gate usage in downstream tooling.* diff --git a/examples/example-treesitter-repo/repo-index.jsonl b/examples/example-treesitter-repo/repo-index.jsonl new file mode 100644 index 000000000000..c1eb424f82da --- /dev/null +++ b/examples/example-treesitter-repo/repo-index.jsonl @@ -0,0 +1,162 @@ +{"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":5,"name":"Point","signature":"class Point {","type":"class"} +{"calls":["std::atan2"],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":14,"name":"headingTo","signature":"double headingTo(const Point& other) const {","type":"function"} +{"calls":["std::sqrt"],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":9,"name":"distanceTo","signature":"double distanceTo(const Point& other) const {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":8,"name":"Point","signature":"Point(double x, double y) : x(x), y(y) {}","type":"function"} +{"calls":["std::atan2"],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":14,"name":"headingTo","parent":"Point","signature":"double headingTo(const Point& other) const {","type":"function"} +{"calls":["std::sqrt"],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":9,"name":"distanceTo","parent":"Point","signature":"double distanceTo(const Point& other) const {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/point.hpp","language":"cpp","line":8,"name":"Point","parent":"Point","signature":"Point(double x, double y) : x(x), y(y) {}","type":"function"} +{"file":"examples/example-treesitter-repo/src/cpp/pose.hpp","language":"cpp","line":5,"name":"Pose","signature":"class Pose : public Point {","type":"class"} +{"calls":["Point::headingTo"],"file":"examples/example-treesitter-repo/src/cpp/pose.hpp","language":"cpp","line":10,"name":"headingTo","signature":"double headingTo(const Point& other) const override {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/pose.hpp","language":"cpp","line":8,"name":"Pose","signature":"Pose(double x, double y, double heading)","type":"function"} +{"calls":["Point::headingTo"],"file":"examples/example-treesitter-repo/src/cpp/pose.hpp","language":"cpp","line":10,"name":"headingTo","parent":"Pose","signature":"double headingTo(const Point& other) const override {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/pose.hpp","language":"cpp","line":8,"name":"Pose","parent":"Pose","signature":"Pose(double x, double y, double heading)","type":"function"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":49,"name":"Cat","signature":"class Cat : public Animal, public Named {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":52,"name":"getName","signature":"std::string getName() const override { return name; }","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":51,"name":"Cat","signature":"Cat(const std::string& name, const Pose& pose) : Animal(name, pose) {}","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":52,"name":"getName","parent":"Cat","signature":"std::string getName() const override { return name; }","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":51,"name":"Cat","parent":"Cat","signature":"Cat(const std::string& name, const Pose& pose) : Animal(name, pose) {}","type":"function"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":44,"name":"Named","signature":"class Named {","type":"class"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":36,"name":"template","signature":"template ","type":"template"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":37,"name":"Box","signature":"class Box {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":41,"name":"get","signature":"T get() const { return value; }","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":40,"name":"Box","signature":"Box(const T& value) : value(value) {}","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":41,"name":"get","parent":"Box","signature":"T get() const { return value; }","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":40,"name":"Box","parent":"Box","signature":"Box(const T& value) : value(value) {}","type":"function"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":31,"name":"Dog","signature":"class Dog : public Animal {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":33,"name":"Dog","signature":"Dog(const std::string& name, const Pose& pose) : Animal(name, pose) {}","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":33,"name":"Dog","parent":"Dog","signature":"Dog(const std::string& name, const Pose& pose) : Animal(name, pose) {}","type":"function"} +{"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":19,"name":"Animal","signature":"class Animal {","type":"class"} +{"calls":["pose.headingTo","Point","pose.distanceTo","Point"],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":24,"name":"distance_and_heading_to","signature":"std::pair distance_and_heading_to(const Animal& other) const {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":23,"name":"Animal","signature":"Animal(const std::string& name, const Pose& pose) : name(name), pose(pose) {}","type":"function"} +{"calls":["pose.headingTo","Point","pose.distanceTo","Point"],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":24,"name":"distance_and_heading_to","parent":"Animal","signature":"std::pair distance_and_heading_to(const Animal& other) const {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":23,"name":"Animal","parent":"Animal","signature":"Animal(const std::string& name, const Pose& pose) : name(name), pose(pose) {}","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/cpp/test.cpp","language":"cpp","line":15,"name":"add","signature":"int add(int a, int b) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":37,"name":"Dog","signature":"public class Dog {","type":"class"} +{"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":23,"name":"Animal","signature":"public class Animal {","type":"class"} +{"calls":["this.pose.HeadingTo","this.pose.DistanceTo"],"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":30,"name":"DistanceAndHeadingTo","parent":"Animal","signature":"public (double dist, double heading) DistanceAndHeadingTo(Animal other) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":10,"name":"MainClass","signature":"public class MainClass {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":11,"name":"Add","parent":"MainClass","signature":"public static int Add(int a, int b) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/csharp/test.cs","language":"c_sharp","line":2,"name":"Point","signature":"public class Point {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":68,"name":"NewDog","signature":"func NewDog(name string, pose Pose) *Dog {","type":"function"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":64,"name":"Dog","signature":"Dog struct {","type":"struct"} +{"calls":[],"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":54,"name":"NewAnimal","signature":"func NewAnimal(name string, pose Pose) *Animal {","type":"function"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":49,"name":"Animal","signature":"Animal struct {","type":"struct"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":40,"name":"Direction","signature":"Direction int","type":"type"} +{"calls":[],"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":32,"name":"NewPose","signature":"func NewPose(x, y, heading float64) *Pose {","type":"function"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":27,"name":"Pose","signature":"Pose struct {","type":"struct"} +{"calls":[],"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":13,"name":"NewPoint","signature":"func NewPoint(x, y float64) *Point {","type":"function"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":8,"name":"Point","signature":"Point struct {","type":"struct"} +{"file":"examples/example-treesitter-repo/src/go/test.go","language":"go","line":5,"name":"\"math\"","signature":"\"math\"","type":"import"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":77,"name":"Box","signature":"class Box {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":80,"name":"get","parent":"Box","signature":"public T get() { return value; }","type":"function"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":67,"name":"Cat","signature":"class Cat extends Animal implements Named {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":71,"name":"getName","parent":"Cat","signature":"@Override","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":64,"name":"getName","signature":"String getName();","type":"function"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":57,"name":"Dog","signature":"class Dog extends Animal {","type":"class"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":43,"name":"Animal","signature":"class Animal {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":50,"name":"distanceAndHeadingTo","parent":"Animal","signature":"public double[] distanceAndHeadingTo(Animal other) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":30,"name":"Main","signature":"class Main {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":31,"name":"add","parent":"Main","signature":"public static int add(int a, int b) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":18,"name":"Pose","signature":"class Pose extends Point {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":24,"name":"headingTo","parent":"Pose","signature":"@Override","type":"function"} +{"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":2,"name":"Point","signature":"public class Point {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":13,"name":"headingTo","parent":"Point","signature":"public double headingTo(Point other) {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/java/test.java","language":"java","line":8,"name":"distanceTo","parent":"Point","signature":"public double distanceTo(Point other) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/js/test.js","language":"javascript","line":50,"name":"Dog","signature":"class Dog extends Animal {","type":"class"} +{"file":"examples/example-treesitter-repo/src/js/test.js","language":"javascript","line":38,"name":"Animal","signature":"class Animal {","type":"class"} +{"file":"examples/example-treesitter-repo/src/js/test.js","language":"javascript","line":21,"name":"Pose","signature":"class Pose extends Point {","type":"class"} +{"file":"examples/example-treesitter-repo/src/js/test.js","language":"javascript","line":6,"name":"Point","signature":"class Point {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/js/test.js","language":"javascript","line":2,"name":"add","signature":"function add(a, b) {","type":"function"} +{"doc":"\n Represents a dog, which is a type of Animal.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":122,"name":"Dog","signature":"class Dog(Animal):","type":"class"} +{"calls":[],"doc":"\n Initializes a Dog with a name and pose.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":126,"name":"__init__","parent":"Dog","signature":"def __init__(self, name, pose):","type":"function"} +{"doc":"\n Represents an animal with a name and pose.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":104,"name":"Animal","signature":"class Animal:","type":"class"} +{"calls":[],"doc":"\n Returns the distance and heading to another Animal.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":114,"name":"distance_and_heading_to","parent":"Animal","signature":"def distance_and_heading_to(self, other):","type":"function"} +{"calls":[],"doc":"\n Initializes an Animal with a name and pose.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":108,"name":"__init__","parent":"Animal","signature":"def __init__(self, name, pose):","type":"function"} +{"doc":"\n Cardinal directions.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":95,"name":"Direction","signature":"class Direction(Enum):","type":"class"} +{"doc":"\n Represents a pose with position and heading.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":78,"name":"Pose","signature":"class Pose(Point):","type":"class"} +{"calls":[],"doc":"\n Returns the relative heading to another Point, adjusted by this pose's heading.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":89,"name":"heading_to","parent":"Pose","signature":"def heading_to(self, other):","type":"function"} +{"calls":[],"doc":"\n Initializes a Pose with x, y, and heading.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":82,"name":"__init__","parent":"Pose","signature":"def __init__(self, x, y, heading):","type":"function"} +{"doc":"\n Represents a 2D point.\n\n This class demonstrates a class-level docstring with a summary and a longer description.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":37,"name":"Point","signature":"class Point:","type":"class"} +{"calls":[],"doc":"\n Returns the angle (in radians) from this point to another Point.\n\n Args:\n other (Point): The other point.\n Returns:\n float: The angle in radians.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":67,"name":"heading_to","parent":"Point","signature":"def heading_to(self, other):","type":"function"} +{"calls":[],"doc":"\n Calculates the Euclidean distance to another Point.\n\n Args:\n other (Point): The other point.\n Returns:\n float: The Euclidean distance.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":54,"name":"distance_to","parent":"Point","signature":"def distance_to(self, other):","type":"function"} +{"calls":[],"doc":"\n Initializes a Point with x and y coordinates.\n\n Args:\n x (float): The x coordinate.\n y (float): The y coordinate.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":43,"name":"__init__","parent":"Point","signature":"def __init__(self, x, y):","type":"function"} +{"calls":[],"doc":"\n Adds two numbers and returns the result.\n\n This function demonstrates a simple docstring with a summary line, a blank line,\n and a more detailed description. It also includes argument and return value documentation.\n\n Args:\n a (int or float): First number to add.\n b (int or float): Second number to add.\n\n Returns:\n int or float: The sum of a and b.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":21,"name":"add","signature":"def add(a, b):","type":"function"} +{"calls":[],"doc":"\n Greets a person by name.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":15,"name":"greet","signature":"def greet(name):","type":"function"} +{"calls":[],"doc":"\n A decorator that logs the function call with its arguments.\n ","file":"examples/example-treesitter-repo/src/python/test.py","language":"python","line":5,"name":"log_call","signature":"def log_call(func):","type":"function"} +{"calls":[],"doc":"Consumes the container and returns the value.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":110,"name":"into_inner","signature":"pub fn into_inner(self) -> T {","type":"function"} +{"calls":[],"doc":"Creates a new Container.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":106,"name":"new","signature":"pub fn new(value: T) -> Self {","type":"function"} +{"doc":"A generic container for any type.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":100,"name":"Container","signature":"pub struct Container {","type":"class"} +{"calls":[],"doc":"","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":94,"name":"name","signature":"fn name(&self) -> &str {","type":"function"} +{"calls":["Animal::new"],"doc":"Creates a new Dog with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":82,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"doc":"Represents a dog, which is a type of Animal.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":76,"name":"Dog","signature":"pub struct Dog {","type":"class"} +{"calls":["self.pose.heading_to","self.pose.distance_to"],"doc":"Returns the distance and heading to another Animal.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":68,"name":"distance_and_heading_to","signature":"pub fn distance_and_heading_to(&self, other: &Animal) -> (f64, f64) {","type":"function"} +{"calls":["name.to_string"],"doc":"Creates a new Animal with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":64,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"doc":"Represents an animal with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":57,"name":"Animal","signature":"pub struct Animal {","type":"class"} +{"calls":["self.point.heading_to"],"doc":"Returns the relative heading to another Point, adjusted by this pose's heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":43,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["self.point.distance_to"],"doc":"Returns the distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":39,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Pose with x, y, and heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":35,"name":"new","signature":"pub fn new(x: f64, y: f64, heading: f64) -> Self {","type":"function"} +{"doc":"Represents a pose with position and heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":28,"name":"Pose","signature":"pub struct Pose {","type":"class"} +{"calls":["(other.y - self.y).atan2"],"doc":"Returns the angle (in radians) from this point to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":22,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["(dx * dx + dy * dy).sqrt"],"doc":"Returns the Euclidean distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":16,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":12,"name":"new","signature":"pub fn new(x: f64, y: f64) -> Self {","type":"function"} +{"doc":"Represents a 2D point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":5,"name":"Point","signature":"pub struct Point {","type":"class"} +{"calls":[],"doc":"Consumes the container and returns the value.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":110,"name":"into_inner","signature":"pub fn into_inner(self) -> T {","type":"function"} +{"calls":[],"doc":"Creates a new Container.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":106,"name":"new","signature":"pub fn new(value: T) -> Self {","type":"function"} +{"calls":[],"doc":"","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":94,"name":"name","signature":"fn name(&self) -> &str {","type":"function"} +{"calls":["Animal::new"],"doc":"Creates a new Dog with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":82,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"calls":["self.pose.heading_to","self.pose.distance_to"],"doc":"Returns the distance and heading to another Animal.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":68,"name":"distance_and_heading_to","signature":"pub fn distance_and_heading_to(&self, other: &Animal) -> (f64, f64) {","type":"function"} +{"calls":["name.to_string"],"doc":"Creates a new Animal with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":64,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"doc":"Cardinal directions.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":49,"name":"Direction","signature":"pub enum Direction {","type":"class"} +{"calls":["self.point.heading_to"],"doc":"Returns the relative heading to another Point, adjusted by this pose's heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":43,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["self.point.distance_to"],"doc":"Returns the distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":39,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Pose with x, y, and heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":35,"name":"new","signature":"pub fn new(x: f64, y: f64, heading: f64) -> Self {","type":"function"} +{"calls":["(other.y - self.y).atan2"],"doc":"Returns the angle (in radians) from this point to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":22,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["(dx * dx + dy * dy).sqrt"],"doc":"Returns the Euclidean distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":16,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":12,"name":"new","signature":"pub fn new(x: f64, y: f64) -> Self {","type":"function"} +{"calls":[],"doc":"Consumes the container and returns the value.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":110,"name":"into_inner","signature":"pub fn into_inner(self) -> T {","type":"function"} +{"calls":[],"doc":"Creates a new Container.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":106,"name":"new","signature":"pub fn new(value: T) -> Self {","type":"function"} +{"calls":[],"doc":"","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":94,"name":"name","signature":"fn name(&self) -> &str {","type":"function"} +{"doc":"A trait for types that can be named.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":88,"name":"Named","signature":"pub trait Named {","type":"class"} +{"calls":["Animal::new"],"doc":"Creates a new Dog with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":82,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"calls":["self.pose.heading_to","self.pose.distance_to"],"doc":"Returns the distance and heading to another Animal.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":68,"name":"distance_and_heading_to","signature":"pub fn distance_and_heading_to(&self, other: &Animal) -> (f64, f64) {","type":"function"} +{"calls":["name.to_string"],"doc":"Creates a new Animal with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":64,"name":"new","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"calls":["self.point.heading_to"],"doc":"Returns the relative heading to another Point, adjusted by this pose's heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":43,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["self.point.distance_to"],"doc":"Returns the distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":39,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Pose with x, y, and heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":35,"name":"new","signature":"pub fn new(x: f64, y: f64, heading: f64) -> Self {","type":"function"} +{"calls":["(other.y - self.y).atan2"],"doc":"Returns the angle (in radians) from this point to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":22,"name":"heading_to","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["(dx * dx + dy * dy).sqrt"],"doc":"Returns the Euclidean distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":16,"name":"distance_to","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":12,"name":"new","signature":"pub fn new(x: f64, y: f64) -> Self {","type":"function"} +{"calls":[],"doc":"Consumes the container and returns the value.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":110,"name":"into_inner","parent":"Container","signature":"pub fn into_inner(self) -> T {","type":"function"} +{"calls":[],"doc":"Creates a new Container.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":106,"name":"new","parent":"Container","signature":"pub fn new(value: T) -> Self {","type":"function"} +{"calls":[],"doc":"","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":94,"name":"name","parent":"Animal","signature":"fn name(&self) -> &str {","type":"function"} +{"calls":["Animal::new"],"doc":"Creates a new Dog with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":82,"name":"new","parent":"Dog","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"calls":["self.pose.heading_to","self.pose.distance_to"],"doc":"Returns the distance and heading to another Animal.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":68,"name":"distance_and_heading_to","parent":"Animal","signature":"pub fn distance_and_heading_to(&self, other: &Animal) -> (f64, f64) {","type":"function"} +{"calls":["name.to_string"],"doc":"Creates a new Animal with a name and pose.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":64,"name":"new","parent":"Animal","signature":"pub fn new(name: &str, pose: Pose) -> Self {","type":"function"} +{"calls":["self.point.heading_to"],"doc":"Returns the relative heading to another Point, adjusted by this pose's heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":43,"name":"heading_to","parent":"Pose","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["self.point.distance_to"],"doc":"Returns the distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":39,"name":"distance_to","parent":"Pose","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Pose with x, y, and heading.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":35,"name":"new","parent":"Pose","signature":"pub fn new(x: f64, y: f64, heading: f64) -> Self {","type":"function"} +{"calls":["(other.y - self.y).atan2"],"doc":"Returns the angle (in radians) from this point to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":22,"name":"heading_to","parent":"Point","signature":"pub fn heading_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":["(dx * dx + dy * dy).sqrt"],"doc":"Returns the Euclidean distance to another Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":16,"name":"distance_to","parent":"Point","signature":"pub fn distance_to(&self, other: &Point) -> f64 {","type":"function"} +{"calls":[],"doc":"Creates a new Point.","file":"examples/example-treesitter-repo/src/rust/test.rs","language":"rust","line":12,"name":"new","parent":"Point","signature":"pub fn new(x: f64, y: f64) -> Self {","type":"function"} +{"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":57,"name":"Dog","signature":"class Dog: Animal {","type":"class"} +{"calls":["super.init"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":58,"name":"init","parent":"Dog","signature":"override init(name: String, pose: Pose) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":43,"name":"Animal","signature":"class Animal {","type":"class"} +{"calls":["pose.headingTo","Point","pose.distanceTo","Point"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":50,"name":"distanceAndHeadingTo","parent":"Animal","signature":"func distanceAndHeadingTo(_ other: Animal) -> (Double, Double) {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":46,"name":"init","parent":"Animal","signature":"init(name: String, pose: Pose) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":36,"name":"Direction","signature":"enum Direction {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":32,"name":"add","signature":"func add(a: Int, b: Int) -> Int {","type":"function"} +{"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":21,"name":"Pose","signature":"class Pose: Point {","type":"class"} +{"calls":["super.headingTo"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":27,"name":"headingTo","parent":"Pose","signature":"override func headingTo(_ other: Point) -> Double {","type":"function"} +{"calls":["super.init"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":23,"name":"init","parent":"Pose","signature":"init(x: Double, y: Double, heading: Double) {","type":"function"} +{"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":4,"name":"Point","signature":"class Point {","type":"class"} +{"calls":["atan2"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":16,"name":"headingTo","parent":"Point","signature":"func headingTo(_ other: Point) -> Double {","type":"function"} +{"calls":["(dx * dx + dy * dy).squareRoot"],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":11,"name":"distanceTo","parent":"Point","signature":"func distanceTo(_ other: Point) -> Double {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/swift/test.swift","language":"swift","line":7,"name":"init","parent":"Point","signature":"init(x: Double, y: Double) {","type":"function"} +{"calls":[],"file":"examples/example-treesitter-repo/src/tsx/test.tsx","language":"typescript","line":10,"name":"add","signature":"function add(a: number, b: number): number {","type":"function"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":74,"name":"Box","signature":"class Box {","type":"class"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":65,"name":"Cat","signature":"class Cat extends Animal implements Named {","type":"class"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":55,"name":"Dog","signature":"class Dog extends Animal {","type":"class"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":41,"name":"Animal","signature":"class Animal {","type":"class"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":23,"name":"Pose","signature":"class Pose extends Point {","type":"class"} +{"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":6,"name":"Point","signature":"class Point {","type":"class"} +{"calls":[],"file":"examples/example-treesitter-repo/src/typescript/test.ts","language":"typescript","line":2,"name":"add","signature":"function add(a: number, b: number): number {","type":"function"} diff --git a/examples/example-treesitter-repo/src/cpp/point.hpp b/examples/example-treesitter-repo/src/cpp/point.hpp new file mode 100644 index 000000000000..ea7b52cc4f33 --- /dev/null +++ b/examples/example-treesitter-repo/src/cpp/point.hpp @@ -0,0 +1,19 @@ +#ifndef POINT_HPP +#define POINT_HPP +#include + +class Point { +public: + double x, y; + Point(double x, double y) : x(x), y(y) {} + double distanceTo(const Point& other) const { + double dx = x - other.x; + double dy = y - other.y; + return std::sqrt(dx * dx + dy * dy); + } + double headingTo(const Point& other) const { + return std::atan2(other.y - y, other.x - x); + } +}; + +#endif // POINT_HPP diff --git a/examples/example-treesitter-repo/src/cpp/pose.hpp b/examples/example-treesitter-repo/src/cpp/pose.hpp new file mode 100644 index 000000000000..5915f1de9eea --- /dev/null +++ b/examples/example-treesitter-repo/src/cpp/pose.hpp @@ -0,0 +1,15 @@ +#ifndef POSE_HPP +#define POSE_HPP +#include "point.hpp" + +class Pose : public Point { +public: + double heading; + Pose(double x, double y, double heading) + : Point(x, y), heading(heading) {} + double headingTo(const Point& other) const override { + return Point::headingTo(other) - heading; + } +}; + +#endif // POSE_HPP diff --git a/examples/example-treesitter-repo/src/cpp/test.cpp b/examples/example-treesitter-repo/src/cpp/test.cpp new file mode 100644 index 000000000000..2c4ed412fb66 --- /dev/null +++ b/examples/example-treesitter-repo/src/cpp/test.cpp @@ -0,0 +1,53 @@ +#include "point.hpp" +#include "pose.hpp" +#include +#include +#include +#include + +enum class Direction { + North, + East, + South, + West +}; + +int add(int a, int b) { + return a + b; +} + +class Animal { +public: + std::string name; + Pose pose; + Animal(const std::string& name, const Pose& pose) : name(name), pose(pose) {} + std::pair distance_and_heading_to(const Animal& other) const { + double dist = pose.distanceTo(Point(other.pose.x, other.pose.y)); + double heading = pose.headingTo(Point(other.pose.x, other.pose.y)); + return {dist, heading}; + } +}; + +class Dog : public Animal { +public: + Dog(const std::string& name, const Pose& pose) : Animal(name, pose) {} +}; + +template +class Box { +public: + T value; + Box(const T& value) : value(value) {} + T get() const { return value; } +}; + +class Named { +public: + virtual std::string getName() const = 0; +}; + +class Cat : public Animal, public Named { +public: + Cat(const std::string& name, const Pose& pose) : Animal(name, pose) {} + std::string getName() const override { return name; } +}; diff --git a/examples/example-treesitter-repo/src/csharp/test.cs b/examples/example-treesitter-repo/src/csharp/test.cs new file mode 100644 index 000000000000..2ab40a1045ce --- /dev/null +++ b/examples/example-treesitter-repo/src/csharp/test.cs @@ -0,0 +1,42 @@ +// C# example +public class Point { + public double x, y; + public Point(double x, double y) { + this.x = x; + this.y = y; + } +} + +public class MainClass { + public static int Add(int a, int b) { + return a + b; + } +} + +public enum Direction { + North, + East, + South, + West +} + +public class Animal { + public string name; + public Pose pose; + public Animal(string name, Pose pose) { + this.name = name; + this.pose = pose; + } + public (double dist, double heading) DistanceAndHeadingTo(Animal other) { + double dist = this.pose.DistanceTo(new Point { x = other.pose.x, y = other.pose.y }); + double heading = this.pose.HeadingTo(new Point { x = other.pose.x, y = other.pose.y }); + return (dist, heading); + } +} + +public class Dog { + public Animal animal; + public Dog(string name, Pose pose) { + this.animal = new Animal(name, pose); + } +} diff --git a/examples/example-treesitter-repo/src/go/test.go b/examples/example-treesitter-repo/src/go/test.go new file mode 100644 index 000000000000..c08d4fab907f --- /dev/null +++ b/examples/example-treesitter-repo/src/go/test.go @@ -0,0 +1,70 @@ +// Go example +package main + +import ( + "math" +) + +type Point struct { + X float64 + Y float64 +} + +func NewPoint(x, y float64) *Point { + return &Point{X: x, Y: y} +} + +func (p *Point) DistanceTo(other *Point) float64 { + dx := p.X - other.X + dy := p.Y - other.Y + return math.Hypot(dx, dy) +} + +func (p *Point) HeadingTo(other *Point) float64 { + return math.Atan2(other.Y-p.Y, other.X-p.X) +} + +type Pose struct { + Point + Heading float64 +} + +func NewPose(x, y, heading float64) *Pose { + return &Pose{Point: Point{X: x, Y: y}, Heading: heading} +} + +func (p *Pose) HeadingTo(other *Point) float64 { + return p.Point.HeadingTo(other) - p.Heading +} + +type Direction int + +const ( + North Direction = iota + East + South + West +) + +type Animal struct { + Name string + Pose Pose +} + +func NewAnimal(name string, pose Pose) *Animal { + return &Animal{Name: name, Pose: pose} +} + +func (a *Animal) DistanceAndHeadingTo(other *Animal) (float64, float64) { + dist := a.Pose.DistanceTo(&Point{X: other.Pose.X, Y: other.Pose.Y}) + heading := a.Pose.HeadingTo(&Point{X: other.Pose.X, Y: other.Pose.Y}) + return dist, heading +} + +type Dog struct { + Animal +} + +func NewDog(name string, pose Pose) *Dog { + return &Dog{Animal: Animal{Name: name, Pose: pose}} +} diff --git a/examples/example-treesitter-repo/src/java/test.java b/examples/example-treesitter-repo/src/java/test.java new file mode 100644 index 000000000000..4eef7048e351 --- /dev/null +++ b/examples/example-treesitter-repo/src/java/test.java @@ -0,0 +1,81 @@ +// Java example +public class Point { + public double x, y; + public Point(double x, double y) { + this.x = x; + this.y = y; + } + public double distanceTo(Point other) { + double dx = x - other.x; + double dy = y - other.y; + return Math.hypot(dx, dy); + } + public double headingTo(Point other) { + return Math.atan2(other.y - y, other.x - x); + } +} + +class Pose extends Point { + public double heading; + public Pose(double x, double y, double heading) { + super(x, y); + this.heading = heading; + } + @Override + public double headingTo(Point other) { + return super.headingTo(other) - heading; + } +} + +class Main { + public static int add(int a, int b) { + return a + b; + } +} + +enum Direction { + NORTH, + EAST, + SOUTH, + WEST +} + +class Animal { + public String name; + public Pose pose; + public Animal(String name, Pose pose) { + this.name = name; + this.pose = pose; + } + public double[] distanceAndHeadingTo(Animal other) { + double dist = this.pose.distanceTo(new Point(other.pose.x, other.pose.y)); + double heading = this.pose.headingTo(new Point(other.pose.x, other.pose.y)); + return new double[] { dist, heading }; + } +} + +class Dog extends Animal { + public Dog(String name, Pose pose) { + super(name, pose); + } +} + +interface Named { + String getName(); +} + +class Cat extends Animal implements Named { + public Cat(String name, Pose pose) { + super(name, pose); + } + @Override + public String getName() { + return name; + } +} + +class Box { + private T value; + public Box(T value) { this.value = value; } + public T get() { return value; } +} diff --git a/examples/example-treesitter-repo/src/js/test.js b/examples/example-treesitter-repo/src/js/test.js new file mode 100644 index 000000000000..d3e3dc165ac7 --- /dev/null +++ b/examples/example-treesitter-repo/src/js/test.js @@ -0,0 +1,54 @@ +// JavaScript example +function add(a, b) { + return a + b; +} + +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + distanceTo(other) { + const dx = this.x - other.x; + const dy = this.y - other.y; + return Math.hypot(dx, dy); + } + headingTo(other) { + return Math.atan2(other.y - this.y, other.x - this.x); + } +} + +class Pose extends Point { + constructor(x, y, heading) { + super(x, y); + this.heading = heading; + } + headingTo(other) { + return super.headingTo(other) - this.heading; + } +} + +const Direction = Object.freeze({ + North: 0, + East: 1, + South: 2, + West: 3 +}); + +class Animal { + constructor(name, pose) { + this.name = name; + this.pose = pose; + } + distanceAndHeadingTo(other) { + const dist = this.pose.distanceTo(new Point(other.pose.x, other.pose.y)); + const heading = this.pose.headingTo(new Point(other.pose.x, other.pose.y)); + return { dist, heading }; + } +} + +class Dog extends Animal { + constructor(name, pose) { + super(name, pose); + } +} diff --git a/examples/example-treesitter-repo/src/python/test.py b/examples/example-treesitter-repo/src/python/test.py new file mode 100644 index 000000000000..5f90052d4888 --- /dev/null +++ b/examples/example-treesitter-repo/src/python/test.py @@ -0,0 +1,130 @@ +# Python example +import math +from enum import Enum + +def log_call(func): + """ + A decorator that logs the function call with its arguments. + """ + def wrapper(*args, **kwargs): + print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") + return func(*args, **kwargs) + return wrapper + +@log_call +def greet(name): + """ + Greets a person by name. + """ + print(f"Hello, {name}!") + +def add(a, b): + """ + Adds two numbers and returns the result. + + This function demonstrates a simple docstring with a summary line, a blank line, + and a more detailed description. It also includes argument and return value documentation. + + Args: + a (int or float): First number to add. + b (int or float): Second number to add. + + Returns: + int or float: The sum of a and b. + """ + return a + b + +class Point: + """ + Represents a 2D point. + + This class demonstrates a class-level docstring with a summary and a longer description. + """ + def __init__(self, x, y): + """ + Initializes a Point with x and y coordinates. + + Args: + x (float): The x coordinate. + y (float): The y coordinate. + """ + self.x = x + self.y = y + + def distance_to(self, other): + """ + Calculates the Euclidean distance to another Point. + + Args: + other (Point): The other point. + Returns: + float: The Euclidean distance. + """ + dx = self.x - other.x + dy = self.y - other.y + return math.hypot(dx, dy) + + def heading_to(self, other): + """ + Returns the angle (in radians) from this point to another Point. + + Args: + other (Point): The other point. + Returns: + float: The angle in radians. + """ + return math.atan2(other.y - self.y, other.x - self.x) + +class Pose(Point): + """ + Represents a pose with position and heading. + """ + def __init__(self, x, y, heading): + """ + Initializes a Pose with x, y, and heading. + """ + super().__init__(x, y) + self.heading = heading + + def heading_to(self, other): + """ + Returns the relative heading to another Point, adjusted by this pose's heading. + """ + return super().heading_to(other) - self.heading + +class Direction(Enum): + """ + Cardinal directions. + """ + NORTH = 1 + EAST = 2 + SOUTH = 3 + WEST = 4 + +class Animal: + """ + Represents an animal with a name and pose. + """ + def __init__(self, name, pose): + """ + Initializes an Animal with a name and pose. + """ + self.name = name + self.pose = pose + def distance_and_heading_to(self, other): + """ + Returns the distance and heading to another Animal. + """ + dist = self.pose.distance_to(Point(other.pose.x, other.pose.y)) + heading = self.pose.heading_to(Point(other.pose.x, other.pose.y)) + return dist, heading + +class Dog(Animal): + """ + Represents a dog, which is a type of Animal. + """ + def __init__(self, name, pose): + """ + Initializes a Dog with a name and pose. + """ + super().__init__(name, pose) diff --git a/examples/example-treesitter-repo/src/rust/test.rs b/examples/example-treesitter-repo/src/rust/test.rs new file mode 100644 index 000000000000..cea6eef62216 --- /dev/null +++ b/examples/example-treesitter-repo/src/rust/test.rs @@ -0,0 +1,113 @@ +// Rust example +use std::f64::consts::PI; + +/// Represents a 2D point. +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + /// Creates a new Point. + pub fn new(x: f64, y: f64) -> Self { + Point { x, y } + } + /// Returns the Euclidean distance to another Point. + pub fn distance_to(&self, other: &Point) -> f64 { + let dx = self.x - other.x; + let dy = self.y - other.y; + (dx * dx + dy * dy).sqrt() + } + /// Returns the angle (in radians) from this point to another Point. + pub fn heading_to(&self, other: &Point) -> f64 { + (other.y - self.y).atan2(other.x - self.x) + } +} + +/// Represents a pose with position and heading. +pub struct Pose { + pub point: Point, + pub heading: f64, +} + +impl Pose { + /// Creates a new Pose with x, y, and heading. + pub fn new(x: f64, y: f64, heading: f64) -> Self { + Pose { point: Point { x, y }, heading } + } + /// Returns the distance to another Point. + pub fn distance_to(&self, other: &Point) -> f64 { + self.point.distance_to(other) + } + /// Returns the relative heading to another Point, adjusted by this pose's heading. + pub fn heading_to(&self, other: &Point) -> f64 { + self.point.heading_to(other) - self.heading + } +} + +/// Cardinal directions. +pub enum Direction { + North, + East, + South, + West, +} + +/// Represents an animal with a name and pose. +pub struct Animal { + pub name: String, + pub pose: Pose, +} + +impl Animal { + /// Creates a new Animal with a name and pose. + pub fn new(name: &str, pose: Pose) -> Self { + Animal { name: name.to_string(), pose } + } + /// Returns the distance and heading to another Animal. + pub fn distance_and_heading_to(&self, other: &Animal) -> (f64, f64) { + let dist = self.pose.distance_to(&other.pose.point); + let heading = self.pose.heading_to(&other.pose.point); + (dist, heading) + } +} + +/// Represents a dog, which is a type of Animal. +pub struct Dog { + pub animal: Animal, +} + +impl Dog { + /// Creates a new Dog with a name and pose. + pub fn new(name: &str, pose: Pose) -> Self { + Dog { animal: Animal::new(name, pose) } + } +} + +/// A trait for types that can be named. +pub trait Named { + /// Returns the name of the object. + fn name(&self) -> &str; +} + +impl Named for Animal { + fn name(&self) -> &str { + &self.name + } +} + +/// A generic container for any type. +pub struct Container { + pub value: T, +} + +impl Container { + /// Creates a new Container. + pub fn new(value: T) -> Self { + Container { value } + } + /// Consumes the container and returns the value. + pub fn into_inner(self) -> T { + self.value + } +} diff --git a/examples/example-treesitter-repo/src/swift/test.swift b/examples/example-treesitter-repo/src/swift/test.swift new file mode 100644 index 000000000000..191b6638f7c6 --- /dev/null +++ b/examples/example-treesitter-repo/src/swift/test.swift @@ -0,0 +1,61 @@ +// Swift example +import Foundation + +class Point { + var x: Double + var y: Double + init(x: Double, y: Double) { + self.x = x + self.y = y + } + func distanceTo(_ other: Point) -> Double { + let dx = x - other.x + let dy = y - other.y + return (dx * dx + dy * dy).squareRoot() + } + func headingTo(_ other: Point) -> Double { + return atan2(other.y - y, other.x - x) + } +} + +class Pose: Point { + var heading: Double + init(x: Double, y: Double, heading: Double) { + self.heading = heading + super.init(x: x, y: y) + } + override func headingTo(_ other: Point) -> Double { + return super.headingTo(other) - heading + } +} + +func add(a: Int, b: Int) -> Int { + return a + b +} + +enum Direction { + case north + case east + case south + case west +} + +class Animal { + var name: String + var pose: Pose + init(name: String, pose: Pose) { + self.name = name + self.pose = pose + } + func distanceAndHeadingTo(_ other: Animal) -> (Double, Double) { + let dist = pose.distanceTo(Point(x: other.pose.x, y: other.pose.y)) + let heading = pose.headingTo(Point(x: other.pose.x, y: other.pose.y)) + return (dist, heading) + } +} + +class Dog: Animal { + override init(name: String, pose: Pose) { + super.init(name: name, pose: pose) + } +} diff --git a/examples/example-treesitter-repo/src/tsx/test.tsx b/examples/example-treesitter-repo/src/tsx/test.tsx new file mode 100644 index 000000000000..d77cdd95dfa8 --- /dev/null +++ b/examples/example-treesitter-repo/src/tsx/test.tsx @@ -0,0 +1,12 @@ +// TypeScript React example +import React from 'react'; + +type PointProps = { x: number; y: number }; + +const Point: React.FC = ({ x, y }) => ( +
{`(${x}, ${y})`}
+); + +export function add(a: number, b: number): number { + return a + b; +} diff --git a/examples/example-treesitter-repo/src/typescript/test.ts b/examples/example-treesitter-repo/src/typescript/test.ts new file mode 100644 index 000000000000..1575e3a60479 --- /dev/null +++ b/examples/example-treesitter-repo/src/typescript/test.ts @@ -0,0 +1,78 @@ +// TypeScript example +function add(a: number, b: number): number { + return a + b; +} + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + distanceTo(other: Point): number { + const dx = this.x - other.x; + const dy = this.y - other.y; + return Math.hypot(dx, dy); + } + headingTo(other: Point): number { + return Math.atan2(other.y - this.y, other.x - this.x); + } +} + +class Pose extends Point { + heading: number; + constructor(x: number, y: number, heading: number) { + super(x, y); + this.heading = heading; + } + headingTo(other: Point): number { + return super.headingTo(other) - this.heading; + } +} + +enum Direction { + North, + East, + South, + West +} + +class Animal { + name: string; + pose: Pose; + constructor(name: string, pose: Pose) { + this.name = name; + this.pose = pose; + } + distanceAndHeadingTo(other: Animal): { dist: number; heading: number } { + const dist = this.pose.distanceTo(new Point(other.pose.x, other.pose.y)); + const heading = this.pose.headingTo(new Point(other.pose.x, other.pose.y)); + return { dist, heading }; + } +} + +class Dog extends Animal { + constructor(name: string, pose: Pose) { + super(name, pose); + } +} + +interface Named { + getName(): string; +} + +class Cat extends Animal implements Named { + constructor(name: string, pose: Pose) { + super(name, pose); + } + getName(): string { + return this.name; + } +} + +class Box { + value: T; + constructor(value: T) { this.value = value; } + get(): T { return this.value; } +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 3dab1c12947c..ab7194c40d8a 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1,84 +1,53 @@ -import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron'; import { app, - App, + session, BrowserWindow, dialog, - Event, - globalShortcut, ipcMain, Menu, MenuItem, Notification, powerSaveBlocker, - session, - shell, Tray, + App, + globalShortcut, + Event, } from 'electron'; +import type { OpenDialogReturnValue } from 'electron'; import { Buffer } from 'node:buffer'; -import { MouseUpEvent } from './types/electron'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import started from 'electron-squirrel-startup'; import path from 'node:path'; -import os from 'node:os'; -import { spawn } from 'child_process'; +import { spawn, execFileSync } from 'child_process'; import 'dotenv/config'; import { startGoosed } from './goosed'; -import { expandTilde, getBinaryPath } from './utils/pathUtils'; +import { getBinaryPath } from './utils/pathUtils'; import { loadShellEnv } from './utils/loadEnv'; import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { + createEnvironmentMenu, EnvToggles, loadSettings, saveSettings, - SchedulingEngine, updateEnvironmentVariables, updateSchedulingEngineEnvironment, + SchedulingEngine, } from './utils/settings'; import * as crypto from 'crypto'; -// import electron from "electron"; +import * as electron from 'electron'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; import { - getUpdateAvailable, + setupAutoUpdater, registerUpdateIpcHandlers, setTrayRef, - setupAutoUpdater, updateTrayMenu, + getUpdateAvailable, } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; -import { Recipe } from './recipe'; -import './utils/recipeHash'; - -// API URL constructor for main process before window is ready -function getApiUrlMain(endpoint: string, dynamicPort: number): string { - const host = process.env.GOOSE_API_HOST || 'http://127.0.0.1'; - const port = dynamicPort || process.env.GOOSE_PORT; - const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; - return `${host}:${port}${cleanEndpoint}`; -} - -// When opening the app with a deeplink, the window is still initializing so we have to duplicate some window dependant logic here. -async function decodeRecipeMain(deeplink: string, port: number): Promise { - try { - const response = await fetch(getApiUrlMain('/recipes/decode', port), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deeplink }), - }); - - if (response.ok) { - const data = await response.json(); - return data.recipe; - } - } catch (error) { - console.error('Failed to decode recipe:', error); - } - return null; -} // Updater functions (moved here to keep updates.ts minimal for release replacement) function shouldSetupUpdater(): boolean { @@ -163,28 +132,7 @@ async function ensureTempDirExists(): Promise { if (started) app.quit(); -// In development mode, force registration as the default protocol client -// In production, register normally -if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - // Development mode - force registration - console.log('[Main] Development mode: Forcing protocol registration for goose://'); - app.setAsDefaultProtocolClient('goose'); - - if (process.platform === 'darwin') { - try { - // Reset the default handler to ensure dev version takes precedence - spawn('open', ['-a', process.execPath, '--args', '--reset-protocol-handler', 'goose'], { - detached: true, - stdio: 'ignore', - }); - } catch (error) { - console.warn('[Main] Could not reset protocol handler:', error); - } - } -} else { - // Production mode - normal registration - app.setAsDefaultProtocolClient('goose'); -} +app.setAsDefaultProtocolClient('goose'); // Only apply single instance lock on Windows where it's needed for deep links let gotTheLock = true; @@ -198,26 +146,34 @@ if (process.platform === 'win32') { const protocolUrl = commandLine.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { const parsedUrl = new URL(protocolUrl); + // If it's a bot/recipe URL, handle it directly by creating a new window if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - app.whenReady().then(async () => { + app.whenReady().then(() => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; - const recipeDeeplink = parsedUrl.searchParams.get('config'); - const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); - - createChat( - app, - undefined, - openDir || undefined, - undefined, - undefined, - undefined, - undefined, - recipeDeeplink || undefined, - scheduledJobId || undefined - ); + let recipeConfig = null; + const configParam = parsedUrl.searchParams.get('config'); + if (configParam) { + try { + recipeConfig = JSON.parse( + Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8') + ); + + // Check if this is a scheduled job + const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); + if (scheduledJobId) { + console.log(`[main] Opening scheduled job: ${scheduledJobId}`); + recipeConfig.scheduledJobId = scheduledJobId; + recipeConfig.isScheduledExecution = true; + } + } catch (e) { + console.error('Failed to parse bot config:', e); + } + } + + createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); }); return; // Skip the rest of the handler } @@ -266,7 +222,7 @@ async function handleProtocolUrl(url: string) { existingWindows.length > 0 ? existingWindows[0] : await createChat(app, undefined, openDir || undefined); - await processProtocolUrl(parsedUrl, targetWindow); + processProtocolUrl(parsedUrl, targetWindow); } else { // For other URL types, reuse existing window if available const existingWindows = BrowserWindow.getAllWindows(); @@ -283,17 +239,17 @@ async function handleProtocolUrl(url: string) { if (firstOpenWindow) { const webContents = firstOpenWindow.webContents; if (webContents.isLoadingMainFrame()) { - webContents.once('did-finish-load', async () => { - await processProtocolUrl(parsedUrl, firstOpenWindow); + webContents.once('did-finish-load', () => { + processProtocolUrl(parsedUrl, firstOpenWindow); }); } else { - await processProtocolUrl(parsedUrl, firstOpenWindow); + processProtocolUrl(parsedUrl, firstOpenWindow); } } } } -async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { +function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; @@ -302,21 +258,27 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { } else if (parsedUrl.hostname === 'sessions') { window.webContents.send('open-shared-session', pendingDeepLink); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - const recipeDeeplink = parsedUrl.searchParams.get('config'); - const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); + let recipeConfig = null; + const configParam = parsedUrl.searchParams.get('config'); + if (configParam) { + try { + recipeConfig = JSON.parse( + Buffer.from(decodeURIComponent(configParam), 'base64').toString('utf-8') + ); + // Check if this is a scheduled job + const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); + if (scheduledJobId) { + console.log(`[main] Opening scheduled job: ${scheduledJobId}`); + recipeConfig.scheduledJobId = scheduledJobId; + recipeConfig.isScheduledExecution = true; + } + } catch (e) { + console.error('Failed to parse bot config:', e); + } + } // Create a new window and ignore the passed-in window - createChat( - app, - undefined, - openDir || undefined, - undefined, - undefined, - undefined, - undefined, - recipeDeeplink || undefined, - scheduledJobId || undefined - ); + createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); } pendingDeepLink = null; } @@ -328,24 +290,28 @@ app.on('open-url', async (_event, url) => { const openDir = recentDirs.length > 0 ? recentDirs[0] : null; // Handle bot/recipe URLs by directly creating a new window - console.log('[Main] Received open-url event:', url); if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - console.log('[Main] Detected bot/recipe URL, creating new chat window'); - const recipeDeeplink = parsedUrl.searchParams.get('config'); - const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); + let recipeConfig = null; + const configParam = parsedUrl.searchParams.get('config'); + const base64 = decodeURIComponent(configParam || ''); + if (configParam) { + try { + recipeConfig = JSON.parse(Buffer.from(base64, 'base64').toString('utf-8')); + + // Check if this is a scheduled job + const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); + if (scheduledJobId) { + console.log(`[main] Opening scheduled job: ${scheduledJobId}`); + recipeConfig.scheduledJobId = scheduledJobId; + recipeConfig.isScheduledExecution = true; + } + } catch (e) { + console.error('Failed to parse bot config:', e); + } + } // Create a new window directly - await createChat( - app, - undefined, - openDir || undefined, - undefined, - undefined, - undefined, - undefined, - recipeDeeplink || undefined, - scheduledJobId || undefined - ); + await createChat(app, undefined, openDir || undefined, undefined, undefined, recipeConfig); return; // Skip the rest of the handler } @@ -456,11 +422,13 @@ const getGooseProvider = () => { //{env-macro-start}// //needed when goose is bundled for a specific provider //{env-macro-end}// - return [ - process.env.GOOSE_DEFAULT_PROVIDER, - process.env.GOOSE_DEFAULT_MODEL, - process.env.GOOSE_PREDEFINED_MODELS, - ]; + return [process.env.GOOSE_DEFAULT_PROVIDER, process.env.GOOSE_DEFAULT_MODEL]; +}; + +const generateSecretKey = () => { + const key = crypto.randomBytes(32).toString('hex'); + process.env.GOOSE_SERVER__SECRET_KEY = key; + return key; }; const getSharingUrl = () => { @@ -478,33 +446,35 @@ const getVersion = () => { return process.env.GOOSE_VERSION; }; -const [provider, model, predefinedModels] = getGooseProvider(); - -const sharingUrl = getSharingUrl(); +let [provider, model] = getGooseProvider(); -const gooseVersion = getVersion(); +let sharingUrl = getSharingUrl(); -const SERVER_SECRET = process.env.GOOSE_EXTERNAL_BACKEND - ? 'test' - : crypto.randomBytes(32).toString('hex'); +let gooseVersion = getVersion(); let appConfig = { GOOSE_DEFAULT_PROVIDER: provider, GOOSE_DEFAULT_MODEL: model, - GOOSE_PREDEFINED_MODELS: predefinedModels, GOOSE_API_HOST: 'http://127.0.0.1', GOOSE_PORT: 0, GOOSE_WORKING_DIR: '', // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', + secretKey: generateSecretKey(), }; // Track windows by ID let windowCounter = 0; const windowMap = new Map(); -// Track power save blockers per window -const windowPowerSaveBlockers = new Map(); // windowId -> blockerId +interface RecipeConfig { + id: string; + title: string; + description: string; + instructions: string; + activities: string[]; + prompt: string; +} const createChat = async ( app: App, @@ -512,10 +482,8 @@ const createChat = async ( dir?: string, _version?: string, resumeSessionId?: string, - recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink - viewType?: string, - recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding. - scheduledJobId?: string // Scheduled job ID if applicable + recipeConfig?: RecipeConfig, // Bot configuration + viewType?: string // View type ) => { // Initialize variables for process and configuration let port = 0; @@ -558,8 +526,8 @@ const createChat = async ( }; const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed( app, - SERVER_SECRET, - dir, + appConfig.secretKey, + dir ?? null, envVars ); port = newPort; @@ -567,30 +535,24 @@ const createChat = async ( goosedProcess = newGoosedProcess; } - // Create window config with loading state for recipe deeplinks - let isLoadingRecipe = false; - if (!recipe && recipeDeeplink) { - isLoadingRecipe = true; - console.log('[Main] Creating window with recipe loading state for deeplink:', recipeDeeplink); - } - // Load and manage window state const mainWindowState = windowStateKeeper({ - defaultWidth: 940, // large enough to show the sidebar on launch + defaultWidth: 750, defaultHeight: 800, }); const mainWindow = new BrowserWindow({ titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', - trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined, + trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 20 } : undefined, vibrancy: process.platform === 'darwin' ? 'window' : undefined, - frame: process.platform !== 'darwin', + frame: process.platform === 'darwin' ? false : true, x: mainWindowState.x, y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, - minWidth: 750, + minWidth: 650, resizable: true, + transparent: false, useContentSize: true, icon: path.join(__dirname, '../images/icon'), webPreferences: { @@ -608,7 +570,7 @@ const createChat = async ( REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: sharingUrl, GOOSE_VERSION: gooseVersion, - recipe: recipe, + recipeConfig: recipeConfig, }), ], partition: 'persist:goose', // Add this line to ensure persistence @@ -662,59 +624,27 @@ const createChat = async ( GOOSE_WORKING_DIR: working_dir, REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: sharingUrl, - recipe: recipe, + recipeConfig: recipeConfig, }; // We need to wait for the window to load before we can access localStorage mainWindow.webContents.on('did-finish-load', () => { const configStr = JSON.stringify(windowConfig).replace(/'/g, "\\'"); - mainWindow.webContents - .executeJavaScript( - ` - (function() { - function setConfig() { - try { - if (window.localStorage) { - localStorage.setItem('gooseConfig', '${configStr}'); - return true; - } - } catch (e) { - console.warn('localStorage access failed:', e); - } - return false; - } - - if (!setConfig()) { - setTimeout(() => { - if (!setConfig()) { - console.error('Failed to set localStorage after retry - continuing without localStorage config'); - } - }, 100); - } - })(); - ` - ) - .catch((error) => { - console.error('Failed to execute localStorage script:', error); - }); + mainWindow.webContents.executeJavaScript(` + localStorage.setItem('gooseConfig', '${configStr}') + `); }); // Handle new window creation for links mainWindow.webContents.setWindowOpenHandler(({ url }) => { // Open all links in external browser if (url.startsWith('http:') || url.startsWith('https:')) { - shell.openExternal(url); + electron.shell.openExternal(url); return { action: 'deny' }; } return { action: 'allow' }; }); - // Handle new-window events (alternative approach for external links) - mainWindow.webContents.on('new-window', (event, url) => { - event.preventDefault(); - shell.openExternal(url); - }); - // Load the index.html of the app. let queryParams = ''; if (query) { @@ -735,11 +665,6 @@ const createChat = async ( : `?view=${encodeURIComponent(viewType)}`; } - // For recipe deeplinks, navigate directly to pair view - if (recipe || recipeDeeplink) { - queryParams = queryParams ? `${queryParams}&view=pair` : `?view=pair`; - } - // Increment window counter to track number of windows const windowId = ++windowCounter; @@ -766,96 +691,10 @@ const createChat = async ( } }); - mainWindow.on('app-command', (e, cmd) => { - if (cmd === 'browser-backward') { - mainWindow.webContents.send('mouse-back-button-clicked'); - e.preventDefault(); - } - }); - - mainWindow.webContents.on('mouse-up', (_event: MouseUpEvent, mouseButton: number) => { - // MouseButton 3 is the back button. - if (mouseButton === 3) { - mainWindow.webContents.send('mouse-back-button-clicked'); - } - }); - windowMap.set(windowId, mainWindow); - - // Handle recipe decoding in the background after window is created - if (isLoadingRecipe && recipeDeeplink) { - console.log('[Main] Starting background recipe decoding for:', recipeDeeplink); - - // Decode recipe asynchronously after window is created - decodeRecipeMain(recipeDeeplink, port) - .then((decodedRecipe) => { - if (decodedRecipe) { - console.log('[Main] Recipe decoded successfully, updating window config'); - - // Handle scheduled job parameters if present - if (scheduledJobId) { - decodedRecipe.scheduledJobId = scheduledJobId; - decodedRecipe.isScheduledExecution = true; - } - - // Update the window config with the decoded recipe - const updatedConfig = { - ...windowConfig, - recipe: decodedRecipe, - }; - - // Send the decoded recipe to the renderer process - mainWindow.webContents.send('recipe-decoded', decodedRecipe); - - // Update localStorage with the decoded recipe - const configStr = JSON.stringify(updatedConfig).replace(/'/g, "\\'"); - mainWindow.webContents - .executeJavaScript( - ` - try { - localStorage.setItem('gooseConfig', '${configStr}'); - console.log('[Renderer] Recipe decoded and config updated'); - } catch (e) { - console.error('[Renderer] Failed to update config with decoded recipe:', e); - } - ` - ) - .catch((error) => { - console.error('[Main] Failed to update localStorage with decoded recipe:', error); - }); - } else { - console.error('[Main] Failed to decode recipe from deeplink'); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', 'Failed to decode recipe'); - } - }) - .catch((error) => { - console.error('[Main] Error decoding recipe:', error); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', error.message || 'Unknown error'); - }); - } - // Handle window closure mainWindow.on('closed', () => { windowMap.delete(windowId); - - if (windowPowerSaveBlockers.has(windowId)) { - const blockerId = windowPowerSaveBlockers.get(windowId)!; - try { - powerSaveBlocker.stop(blockerId); - console.log( - `[Main] Stopped power save blocker ${blockerId} for closing window ${windowId}` - ); - } catch (error) { - console.error( - `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, - error - ); - } - windowPowerSaveBlockers.delete(windowId); - } - if (goosedProcess && typeof goosedProcess === 'object' && 'kill' in goosedProcess) { goosedProcess.kill(); } @@ -949,60 +788,8 @@ const buildRecentFilesMenu = () => { const openDirectoryDialog = async ( replaceWindow: boolean = false ): Promise => { - // Get the current working directory from the focused window - let defaultPath: string | undefined; - const currentWindow = BrowserWindow.getFocusedWindow(); - - if (currentWindow) { - try { - const currentWorkingDir = await currentWindow.webContents.executeJavaScript( - `window.appConfig ? window.appConfig.get('GOOSE_WORKING_DIR') : null` - ); - - if (currentWorkingDir && typeof currentWorkingDir === 'string') { - // Verify the directory exists before using it as default - try { - const stats = fsSync.lstatSync(currentWorkingDir); - if (stats.isDirectory()) { - defaultPath = currentWorkingDir; - } - } catch (error) { - if (error && typeof error === 'object' && 'code' in error) { - const fsError = error as { code?: string; message?: string }; - if ( - fsError.code === 'ENOENT' || - fsError.code === 'EACCES' || - fsError.code === 'EPERM' - ) { - console.warn( - `Current working directory not accessible (${fsError.code}): ${currentWorkingDir}, falling back to home directory` - ); - defaultPath = os.homedir(); - } else { - console.warn( - `Unexpected filesystem error (${fsError.code}) for directory ${currentWorkingDir}:`, - fsError.message - ); - defaultPath = os.homedir(); - } - } else { - console.warn(`Unexpected error checking directory ${currentWorkingDir}:`, error); - defaultPath = os.homedir(); - } - } - } - } catch (error) { - console.warn('Failed to get current working directory from window:', error); - } - } - - if (!defaultPath) { - defaultPath = os.homedir(); - } - const result = (await dialog.showOpenDialog({ properties: ['openFile', 'openDirectory', 'createDirectory'], - defaultPath: defaultPath, })) as unknown as OpenDialogReturnValue; if (!result.canceled && result.filePaths.length > 0) { @@ -1027,43 +814,9 @@ const openDirectoryDialog = async ( addRecentDir(dirToAdd); const currentWindow = BrowserWindow.getFocusedWindow(); - + await createChat(app, undefined, dirToAdd); if (replaceWindow && currentWindow) { - // Replace current window with new one - await createChat(app, undefined, dirToAdd); currentWindow.close(); - } else { - // Update the working directory in the current window's localStorage - if (currentWindow) { - try { - const updateConfigScript = ` - try { - const currentConfig = JSON.parse(localStorage.getItem('gooseConfig') || '{}'); - const updatedConfig = { - ...currentConfig, - GOOSE_WORKING_DIR: '${dirToAdd.replace(/'/g, "\\'")}', - }; - localStorage.setItem('gooseConfig', JSON.stringify(updatedConfig)); - - // Trigger a config update event so the UI can refresh - window.dispatchEvent(new CustomEvent('goose-config-updated', { - detail: { GOOSE_WORKING_DIR: '${dirToAdd.replace(/'/g, "\\'")}' } - })); - } catch (e) { - console.error('Failed to update working directory in localStorage:', e); - } - `; - await currentWindow.webContents.executeJavaScript(updateConfigScript); - console.log(`Updated working directory to: ${dirToAdd}`); - } catch (error) { - console.error('Failed to update working directory:', error); - // Fallback: create new window - await createChat(app, undefined, dirToAdd); - } - } else { - // No current window, create new one - await createChat(app, undefined, dirToAdd); - } } } return result; @@ -1110,17 +863,14 @@ ipcMain.handle('directory-chooser', (_event, replace: boolean = false) => { // Handle scheduling engine settings ipcMain.handle('get-settings', () => { try { - return loadSettings(); + const settings = loadSettings(); + return settings; } catch (error) { console.error('Error getting settings:', error); return null; } }); -ipcMain.handle('get-secret-key', () => { - return SERVER_SECRET; -}); - ipcMain.handle('set-scheduling-engine', async (_event, engine: string) => { try { const settings = loadSettings(); @@ -1283,80 +1033,47 @@ ipcMain.handle('get-quit-confirmation-state', () => { } }); -// Handle wakelock setting -ipcMain.handle('set-wakelock', async (_event, enable: boolean) => { - try { - const settings = loadSettings(); - settings.enableWakelock = enable; - saveSettings(settings); - - // Stop all existing power save blockers when disabling the setting - if (!enable) { - for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { - try { - powerSaveBlocker.stop(blockerId); - console.log( - `[Main] Stopped power save blocker ${blockerId} for window ${windowId} due to wakelock setting disabled` - ); - } catch (error) { - console.error( - `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, - error - ); - } - } - windowPowerSaveBlockers.clear(); - } +// Add file/directory selection handler +ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { + const result = (await dialog.showOpenDialog({ + properties: process.platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openFile'], + // If provided, use the defaultPath as a hint for the dialog + defaultPath: + typeof defaultPath === 'string' && defaultPath.length > 0 ? defaultPath : undefined, + })) as unknown as OpenDialogReturnValue; - return true; - } catch (error) { - console.error('Error setting wakelock:', error); - return false; + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0]; } + return null; }); -ipcMain.handle('get-wakelock-state', () => { +// Secret key for authenticating local requests +ipcMain.handle('get-secret-key', async () => { try { - const settings = loadSettings(); - return settings.enableWakelock ?? false; - } catch (error) { - console.error('Error getting wakelock state:', error); - return false; + return appConfig.secretKey; + } catch (e) { + console.error('Error retrieving secret key:', e); + return ''; } }); -// Add file/directory selection handler -ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { - const dialogOptions: OpenDialogOptions = { - properties: process.platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openFile'], - }; - - // Set default path if provided - if (defaultPath) { - // Expand tilde to home directory - const expandedPath = expandTilde(defaultPath); - - // Check if the path exists - try { - const stats = await fs.stat(expandedPath); - if (stats.isDirectory()) { - dialogOptions.defaultPath = expandedPath; - } else { - dialogOptions.defaultPath = path.dirname(expandedPath); - } - } catch (error) { - // If path doesn't exist, fall back to home directory and log error - console.error(`Default path does not exist: ${expandedPath}, falling back to home directory`); - dialogOptions.defaultPath = os.homedir(); +// Open a directory in the native file explorer +ipcMain.handle('open-directory-in-explorer', async (_event, dirPath: string) => { + try { + if (!dirPath || typeof dirPath !== 'string') return false; + if (process.platform === 'darwin') { + spawn('open', [dirPath]); + } else if (process.platform === 'win32') { + spawn('explorer.exe', [dirPath]); + } else { + spawn('xdg-open', [dirPath]); } + return true; + } catch (e) { + console.error('Failed to open directory in explorer:', e); + return false; } - - const result = (await dialog.showOpenDialog(dialogOptions)) as unknown as OpenDialogReturnValue; - - if (!result.canceled && result.filePaths.length > 0) { - return result.filePaths[0]; - } - return null; }); // IPC handler to save data URL to a temporary file @@ -1621,7 +1338,9 @@ ipcMain.handle('get-binary-path', (_event, binaryName) => { ipcMain.handle('read-file', (_event, filePath) => { return new Promise((resolve) => { // Expand tilde to home directory - const expandedPath = expandTilde(filePath); + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; const cat = spawn('cat', [expandedPath]); let output = ''; @@ -1654,7 +1373,9 @@ ipcMain.handle('read-file', (_event, filePath) => { ipcMain.handle('write-file', (_event, filePath, content) => { return new Promise((resolve) => { // Expand tilde to home directory - const expandedPath = expandTilde(filePath); + const expandedPath = filePath.startsWith('~') + ? path.join(app.getPath('home'), filePath.slice(1)) + : filePath; // Create a write stream to the file // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -1673,7 +1394,9 @@ ipcMain.handle('write-file', (_event, filePath, content) => { ipcMain.handle('ensure-directory', async (_event, dirPath) => { try { // Expand tilde to home directory - const expandedPath = expandTilde(dirPath); + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; await fs.mkdir(expandedPath, { recursive: true }); return true; @@ -1686,7 +1409,9 @@ ipcMain.handle('ensure-directory', async (_event, dirPath) => { ipcMain.handle('list-files', async (_event, dirPath, extension) => { try { // Expand tilde to home directory - const expandedPath = expandTilde(dirPath); + const expandedPath = dirPath.startsWith('~') + ? path.join(app.getPath('home'), dirPath.slice(1)) + : dirPath; const files = await fs.readdir(expandedPath); if (extension) { @@ -1701,11 +1426,19 @@ ipcMain.handle('list-files', async (_event, dirPath, extension) => { // Handle message box dialogs ipcMain.handle('show-message-box', async (_event, options) => { - return dialog.showMessageBox(options); + const result = await dialog.showMessageBox(options); + return result; }); +// Handle allowed extensions list fetching ipcMain.handle('get-allowed-extensions', async () => { - return await getAllowList(); + try { + const allowList = await getAllowList(); + return allowList; + } catch (error) { + console.error('Error fetching allowed extensions:', error); + throw error; + } }); const createNewWindow = async (app: App, dir?: string | null) => { @@ -1776,18 +1509,18 @@ app.whenReady().then(async () => { "default-src 'self';" + // Allow inline styles since we use them in our React components "style-src 'self' 'unsafe-inline';" + - // Scripts from our app and inline scripts (for theme initialization) - "script-src 'self' 'unsafe-inline';" + + // Scripts only from our app + "script-src 'self';" + // Images from our app and data: URLs (for base64 images) "img-src 'self' data: https:;" + // Connect to our local API and specific external services "connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com" + // Don't allow any plugins "object-src 'none';" + - // Allow all frames (iframes) - "frame-src 'self' https: http:;" + - // Font sources - allow self, data URLs, and external fonts - "font-src 'self' data: https:;" + + // Don't allow any frames + "frame-src 'none';" + + // Font sources + "font-src 'self';" + // Media sources - allow microphone "media-src 'self' mediastream:;" + // Form actions @@ -1812,6 +1545,15 @@ app.whenReady().then(async () => { callback({ cancel: false, requestHeaders: details.requestHeaders }); }); + // Test error feature - only enabled with GOOSE_TEST_ERROR=true + if (process.env.GOOSE_TEST_ERROR === 'true') { + console.log('Test error feature enabled, will throw error in 5 seconds'); + setTimeout(() => { + console.log('Throwing test error now...'); + throw new Error('Test error: This is a simulated fatal error after 5 seconds'); + }, 5000); + } + // Create tray if enabled in settings const settings = loadSettings(); if (settings.showMenuBarIcon) { @@ -1915,6 +1657,25 @@ app.whenReady().then(async () => { ); } + // Add Environment menu items to View menu + const viewMenu = menu?.items.find((item) => item.label === 'View'); + if (viewMenu?.submenu) { + viewMenu.submenu.append(new MenuItem({ type: 'separator' })); + viewMenu.submenu.append( + new MenuItem({ + label: 'Environment', + submenu: Menu.buildFromTemplate( + createEnvironmentMenu(envToggles, (newToggles) => { + envToggles = newToggles; + const currentSettings = loadSettings(); + saveSettings({ ...currentSettings, envToggles: newToggles }); + updateEnvironmentVariables(newToggles); + }) + ), + }) + ); + } + const fileMenu = menu?.items.find((item) => item.label === 'File'); if (fileMenu?.submenu) { @@ -1939,11 +1700,81 @@ app.whenReady().then(async () => { }) ); + // Add Index Repository action when BOTH: + // 1. ALPHA_FEATURES env var is explicitly enabled (opt-in for experimental UI) + // 2. A goose/goosed binary exists that supports the `repo` subcommand + if (process.env.ALPHA_FEATURES === 'true') { + (() => { + const candidates = ['goose', 'goosed']; + type Candidate = { name: string; path: string }; + const supported: Candidate | undefined = candidates + .map((name) => { + try { + const p = getBinaryPath(app, name as 'goose'); + if (!fsSync.existsSync(p)) return undefined; + // Fast synchronous probe for repo subcommand support. + // Use execFileSync to avoid shell interpretation (safer than spawnSync with a variable + // program path). `getBinaryPath` already validates and resolves the binary path to a + // concrete filesystem location, so invoking the binary directly is safe. + let ok = false; + try { + const out = execFileSync(p, ['repo', '--help'], { + timeout: 2000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (out && out.toString().includes('repo')) ok = true; + } catch (e) { + // Non-zero exit or timeout -> treat as unsupported + ok = false; + } + if (ok) return { name, path: p }; + } catch (e) { + // ignore individual candidate errors + } + return undefined; + }) + .find((c): c is Candidate => !!c); + + if (!supported) { + log.info('[repo-index] no repo-capable binary (goose/goosed) found; skipping menu item'); + return; + } + + fileMenu.submenu.insert( + 2, + new MenuItem({ + label: 'Index Repository (Tree-sitter)', + click: async () => { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (!focusedWindow) return; + new Notification({ title: 'Goose', body: 'Starting repository indexing…' }).show(); + const res = await runRepoIndexForWindow(focusedWindow, { + /* could pass path */ + }); + if (res.ok) { + new Notification({ + title: 'Goose', + body: `Indexing complete: ${res.outputPath}`, + }).show(); + } else { + new Notification({ + title: 'Goose', + body: `Indexing failed${res.error ? `: ${res.error}` : ''}`, + }).show(); + } + }, + }) + ); + })(); + } else { + log.info('[repo-index] ALPHA_FEATURES not enabled; hiding Index Repository menu item'); + } + // Add Recent Files submenu const recentFilesSubmenu = buildRecentFilesMenu(); if (recentFilesSubmenu.length > 0) { fileMenu.submenu.insert( - 2, + 3, new MenuItem({ label: 'Recent Directories', submenu: recentFilesSubmenu, @@ -1951,7 +1782,7 @@ app.whenReady().then(async () => { ); } - fileMenu.submenu.insert(3, new MenuItem({ type: 'separator' })); + fileMenu.submenu.insert(4, new MenuItem({ type: 'separator' })); // The Close Window item is here. @@ -2019,18 +1850,21 @@ app.whenReady().then(async () => { } }); - ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, recipe, viewType) => { - if (!dir?.trim()) { - const recentDirs = loadRecentDirs(); - dir = recentDirs.length > 0 ? recentDirs[0] : undefined; - } + ipcMain.on( + 'create-chat-window', + (_, query, dir, version, resumeSessionId, recipeConfig, viewType) => { + if (!dir?.trim()) { + const recentDirs = loadRecentDirs(); + dir = recentDirs.length > 0 ? recentDirs[0] : null; + } - // Log the recipe for debugging - console.log('Creating chat window with recipe:', recipe); + // Log the recipeConfig for debugging + console.log('Creating chat window with recipeConfig:', recipeConfig); - // Pass recipe as part of viewOptions when viewType is recipeEditor - createChat(app, query, dir, version, resumeSessionId, recipe, viewType); - }); + // Pass recipeConfig as part of viewOptions when viewType is recipeEditor + createChat(app, query, dir, version, resumeSessionId, recipeConfig, viewType); + } + ); ipcMain.on('notify', (_event, data) => { try { @@ -2099,37 +1933,62 @@ app.whenReady().then(async () => { } }); - ipcMain.handle('start-power-save-blocker', (event) => { - const window = BrowserWindow.fromWebContents(event.sender); - const windowId = window?.id; + let powerSaveBlockerId: number | null = null; - if (windowId && !windowPowerSaveBlockers.has(windowId)) { - const blockerId = powerSaveBlocker.start('prevent-app-suspension'); - windowPowerSaveBlockers.set(windowId, blockerId); - console.log(`[Main] Started power save blocker ${blockerId} for window ${windowId}`); + ipcMain.handle('start-power-save-blocker', () => { + log.info('Starting power save blocker...'); + if (powerSaveBlockerId === null) { + powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep'); + log.info('Started power save blocker'); return true; } + return false; + }); - if (windowId && windowPowerSaveBlockers.has(windowId)) { - console.log(`[Main] Power save blocker already active for window ${windowId}`); + ipcMain.handle('stop-power-save-blocker', () => { + log.info('Stopping power save blocker...'); + if (powerSaveBlockerId !== null) { + powerSaveBlocker.stop(powerSaveBlockerId); + powerSaveBlockerId = null; + log.info('Stopped power save blocker'); + return true; } - return false; }); - ipcMain.handle('stop-power-save-blocker', (event) => { - const window = BrowserWindow.fromWebContents(event.sender); - const windowId = window?.id; + // Get current wakelock state + ipcMain.handle('get-wakelock-state', () => { + try { + return powerSaveBlockerId !== null; + } catch (e) { + console.error('Error getting wakelock state:', e); + return false; + } + }); - if (windowId && windowPowerSaveBlockers.has(windowId)) { - const blockerId = windowPowerSaveBlockers.get(windowId)!; - powerSaveBlocker.stop(blockerId); - windowPowerSaveBlockers.delete(windowId); - console.log(`[Main] Stopped power save blocker ${blockerId} for window ${windowId}`); + // Enable/disable wakelock and persist in settings + ipcMain.handle('set-wakelock', async (_event, enable: boolean) => { + try { + const settings = loadSettings(); + if (enable) { + if (powerSaveBlockerId === null) { + powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep'); + log.info('Enabled wakelock'); + } + } else { + if (powerSaveBlockerId !== null) { + powerSaveBlocker.stop(powerSaveBlockerId); + powerSaveBlockerId = null; + log.info('Disabled wakelock'); + } + } + settings.enableWakelock = enable; + saveSettings(settings); return true; + } catch (e) { + console.error('Error setting wakelock:', e); + return false; } - - return false; }); // Handle metadata fetching from main process @@ -2194,7 +2053,7 @@ app.whenReady().then(async () => { spawn('xdg-open', [url]); } } catch (error) { - console.error('Error opening URL in browser:', error); + console.error('Error opening URL in Chrome:', error); } }); @@ -2204,70 +2063,239 @@ app.whenReady().then(async () => { app.exit(0); }); + // Close the current window from renderer + ipcMain.on('close-window', (event) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + win?.close(); + } catch (e) { + console.error('Error closing window:', e); + } + }); + // Handler for getting app version ipcMain.on('get-app-version', (event) => { event.returnValue = app.getVersion(); }); - ipcMain.handle('open-directory-in-explorer', async (_event, path: string) => { + // Allow renderer to request window config + ipcMain.handle('get-window-config', async (event) => { try { - return !!(await shell.openPath(path)); - } catch (error) { - console.error('Error opening directory in explorer:', error); - return false; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + const configStr = await win.webContents.executeJavaScript( + `localStorage.getItem('gooseConfig')` + ); + return configStr ? JSON.parse(configStr) : null; + } catch (e) { + console.error('Failed to get window config:', e); + return null; } }); -}); -async function getAllowList(): Promise { - if (!process.env.GOOSE_ALLOWLIST) { - return []; - } + // Helper to run repo indexing for a window + async function runRepoIndexForWindow( + win: BrowserWindow, + opts: { path?: string; output?: string } = {} + ): Promise<{ ok: boolean; outputPath?: string; error?: string }> { + try { + // Ensure the window has finished loading before attempting to access localStorage. + if (win.webContents.isLoadingMainFrame()) { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('window load timeout waiting for config')), + 4000 + ); + win.webContents.once('did-finish-load', () => { + // Use globalThis to appease eslint no-undef in Node env + globalThis.clearTimeout(timeout); + resolve(); + }); + }); + } + // Resolve working dir from window config if not provided + type WindowConfig = { GOOSE_WORKING_DIR?: string; [k: string]: unknown }; + let winConfig: WindowConfig = {}; + try { + const winConfigStr: string | null = await win.webContents.executeJavaScript( + `localStorage.getItem('gooseConfig')` + ); + winConfig = winConfigStr ? JSON.parse(winConfigStr) : {}; + } catch (cfgErr) { + console.warn( + '[repo-index] could not read window config, falling back to defaults:', + cfgErr + ); + } + const cwd: string = + (opts.path && String(opts.path)) || winConfig.GOOSE_WORKING_DIR || process.cwd(); - const response = await fetch(process.env.GOOSE_ALLOWLIST); + // Default output file inside cwd + const outPath: string = + (opts.output && String(opts.output)) || path.join(cwd, '.goose-repo-index.jsonl'); - if (!response.ok) { - throw new Error( - `Failed to fetch allowed extensions: ${response.status} ${response.statusText}` - ); + // Resolve goose binary path + let goosePath: string; + try { + goosePath = getBinaryPath(app, 'goose'); + } catch (e) { + console.error('[repo-index] failed to resolve goose binary:', e); + return { ok: false, error: 'Unable to locate goose binary for indexing' }; + } + if (!fsSync.existsSync(goosePath)) { + console.error('[repo-index] goose binary path resolved but file missing:', goosePath); + return { ok: false, error: 'goose binary not found on filesystem' }; + } + + const args = ['repo', 'index', '--path', cwd, '--output', outPath]; + console.log('[repo-index] spawning', goosePath, args.join(' '), 'cwd=', cwd); + + const child = spawn(goosePath, args, { + cwd, + env: { ...process.env }, + }); + + // Stream progress to renderer + const channel = 'repo-index-progress'; + child.stdout.on('data', (d) => win.webContents.send(channel, d.toString())); + child.stderr.on('data', (d) => win.webContents.send(channel, d.toString())); + + const result: Promise<{ ok: boolean; outputPath?: string; error?: string }> = new Promise( + (resolve) => { + child.on('close', (code) => { + if (code === 0) { + let sizeInfo = ''; + try { + if (fsSync.existsSync(outPath)) { + const st = fsSync.statSync(outPath); + sizeInfo = ` size=${st.size}B`; + } else { + sizeInfo = ' (in-memory; no file written)'; + } + } catch (_) { + /* ignore */ + } + console.log( + `[repo-index] completed successfully${sizeInfo}${fsSync.existsSync(outPath) ? ` output=${outPath}` : ''}` + ); + // Notify renderer + native notification + try { + win.webContents.send('repo-index-complete', { ok: true, outputPath: outPath }); + new Notification({ + title: 'Repository Indexed', + body: fsSync.existsSync(outPath) + ? `Index written to ${path.basename(outPath)}` + : 'Index built in memory', + }).show(); + } catch (notifyErr) { + console.warn('[repo-index] failed to emit completion event:', notifyErr); + } + resolve({ ok: true, outputPath: outPath }); + } else { + const err = `goose exited with code ${code}`; + console.error('[repo-index]', err); + try { + win.webContents.send('repo-index-complete', { ok: false, error: err }); + new Notification({ + title: 'Repository Index Failed', + body: err, + }).show(); + } catch (notifyErr) { + console.warn('[repo-index] failed to emit failure event:', notifyErr); + } + resolve({ ok: false, error: err }); + } + }); + child.on('error', (err) => resolve({ ok: false, error: String(err) })); + } + ); + + return await result; + } catch (e) { + console.error('runRepoIndexForWindow failed:', e); + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } } - // Parse the YAML content - const yamlContent = await response.text(); - const parsedYaml = yaml.parse(yamlContent); + // Run repository index via goose CLI for the current window's working dir + ipcMain.handle( + 'repo-index', + async ( + event, + opts: { path?: string; output?: string } = {} + ): Promise<{ ok: boolean; outputPath?: string; error?: string }> => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) throw new Error('No window'); + return await runRepoIndexForWindow(win, opts); + } catch (e) { + console.error('repo-index failed:', e); + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + } + ); +}); - // Extract the commands from the extensions array - if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) { - const commands = parsedYaml.extensions.map( - (ext: { id: string; command: string }) => ext.command - ); - console.log(`Fetched ${commands.length} allowed extension commands`); - return commands; - } else { - console.error('Invalid YAML structure:', parsedYaml); +/** + * Fetches the allowed extensions list from the remote YAML file if GOOSE_ALLOWLIST is set. + * If the ALLOWLIST is not set, any are allowed. If one is set, it will warn if the deeplink + * doesn't match a command from the list. + * If it fails to load, then it will return an empty list. + * If the format is incorrect, it will return an empty list. + * Format of yaml is: + * + ```yaml: + extensions: + - id: slack + command: uvx mcp_slack + - id: knowledge_graph_memory + command: npx -y @modelcontextprotocol/server-memory + ``` + * + * @returns A promise that resolves to an array of extension commands that are allowed. + */ +async function getAllowList(): Promise { + if (!process.env.GOOSE_ALLOWLIST) { return []; } -} -app.on('will-quit', async () => { - for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { - try { - powerSaveBlocker.stop(blockerId); - console.log( - `[Main] Stopped power save blocker ${blockerId} for window ${windowId} during app quit` + try { + // Fetch the YAML file + const response = await fetch(process.env.GOOSE_ALLOWLIST); + + if (!response.ok) { + throw new Error( + `Failed to fetch allowed extensions: ${response.status} ${response.statusText}` ); - } catch (error) { - console.error( - `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, - error + } + + // Parse the YAML content + const yamlContent = await response.text(); + const parsedYaml = yaml.parse(yamlContent); + + // Extract the commands from the extensions array + if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) { + const commands = parsedYaml.extensions.map( + (ext: { id: string; command: string }) => ext.command ); + console.log(`Fetched ${commands.length} allowed extension commands`); + return commands; + } else { + console.error('Invalid YAML structure:', parsedYaml); + return []; } + } catch (error) { + console.error('Error in getAllowList:', error); + throw error; } - windowPowerSaveBlockers.clear(); +} +app.on('will-quit', async () => { // Unregister all shortcuts when quitting globalShortcut.unregisterAll(); + // Clean up the temp directory on app quit + console.log('[Main] App "will-quit". Cleaning up temporary image directory...'); try { await fs.access(gooseTempDir); // Check if directory exists to avoid error on fs.rm if it doesn't diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 8de1613fce1d..97ea475521ee 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,6 +1,9 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; +// RecipeConfig is used for window creation and should match Recipe interface +type RecipeConfig = Recipe; + interface NotificationData { title: string; body: string; @@ -33,6 +36,12 @@ interface SaveDataUrlResponse { error?: string; } +interface RepoIndexResult { + ok: boolean; + outputPath?: string; + error?: string; +} + const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); interface UpdaterEvent { @@ -52,7 +61,7 @@ type ElectronAPI = { dir?: string, version?: string, resumeSessionId?: string, - recipe?: Recipe, + recipeConfig?: RecipeConfig, viewType?: string ) => void; logInfo: (txt: string) => void; @@ -63,8 +72,8 @@ type ElectronAPI = { reloadApp: () => void; checkForOllama: () => Promise; selectFileOrDirectory: (defaultPath?: string) => Promise; - startPowerSaveBlocker: () => Promise; - stopPowerSaveBlocker: () => Promise; + startPowerSaveBlocker: () => Promise; + stopPowerSaveBlocker: () => Promise; getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; @@ -72,20 +81,26 @@ type ElectronAPI = { listFiles: (dirPath: string, extension?: string) => Promise; getAllowedExtensions: () => Promise; getPathForFile: (file: File) => string; + // Security/identity helpers + getSecretKey: () => Promise; + // OS helpers + openDirectoryInExplorer: (dirPath: string) => Promise; + // Wakelock helpers + getWakelockState: () => Promise; + setWakelock: (enable: boolean) => Promise; setMenuBarIcon: (show: boolean) => Promise; getMenuBarIconState: () => Promise; setDockIcon: (show: boolean) => Promise; getDockIconState: () => Promise; getSettings: () => Promise; - getSecretKey: () => Promise; setSchedulingEngine: (engine: string) => Promise; setQuitConfirmation: (show: boolean) => Promise; getQuitConfirmationState: () => Promise; - setWakelock: (enable: boolean) => Promise; - getWakelockState: () => Promise; openNotificationsSettings: () => Promise; - onMouseBackButtonClicked: (callback: () => void) => void; - offMouseBackButtonClicked: (callback: () => void) => void; + // Recipe acceptance helpers + hasAcceptedRecipeBefore: (recipeConfig: RecipeConfig) => Promise; + recordRecipeHash: (recipeConfig: RecipeConfig) => Promise; + closeWindow: () => void; on: ( channel: string, callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void @@ -108,11 +123,11 @@ type ElectronAPI = { restartApp: () => void; onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => void; getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>; - // Recipe warning functions - closeWindow: () => void; - hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise; - recordRecipeHash: (recipeConfig: Recipe) => Promise; - openDirectoryInExplorer: (directoryPath: string) => Promise; + // Repository indexing + repoIndex: (opts?: { path?: string; output?: string }) => Promise; + onRepoIndexProgress: ( + callback: (event: Electron.IpcRendererEvent, chunk: string) => void + ) => void; }; type AppConfigAPI = { @@ -123,22 +138,7 @@ type AppConfigAPI = { const electronAPI: ElectronAPI = { platform: process.platform, reactReady: () => ipcRenderer.send('react-ready'), - getConfig: () => { - // Add fallback to localStorage if config from preload is empty or missing - if (!config || Object.keys(config).length === 0) { - try { - if (window.localStorage) { - const storedConfig = localStorage.getItem('gooseConfig'); - if (storedConfig) { - return JSON.parse(storedConfig); - } - } - } catch (e) { - console.warn('Failed to parse stored config from localStorage:', e); - } - } - return config; - }, + getConfig: () => config, hideWindow: () => ipcRenderer.send('hide-window'), directoryChooser: (replace?: boolean) => ipcRenderer.invoke('directory-chooser', replace), createChatWindow: ( @@ -146,10 +146,18 @@ const electronAPI: ElectronAPI = { dir?: string, version?: string, resumeSessionId?: string, - recipe?: Recipe, + recipeConfig?: RecipeConfig, viewType?: string ) => - ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType), + ipcRenderer.send( + 'create-chat-window', + query, + dir, + version, + resumeSessionId, + recipeConfig, + viewType + ), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), showNotification: (data: NotificationData) => ipcRenderer.send('notify', data), showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options), @@ -162,6 +170,11 @@ const electronAPI: ElectronAPI = { startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'), stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'), getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName), + getSecretKey: () => ipcRenderer.invoke('get-secret-key'), + openDirectoryInExplorer: (dirPath: string) => + ipcRenderer.invoke('open-directory-in-explorer', dirPath), + getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'), + setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), @@ -175,22 +188,10 @@ const electronAPI: ElectronAPI = { setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show), getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'), getSettings: () => ipcRenderer.invoke('get-settings'), - getSecretKey: () => ipcRenderer.invoke('get-secret-key'), setSchedulingEngine: (engine: string) => ipcRenderer.invoke('set-scheduling-engine', engine), setQuitConfirmation: (show: boolean) => ipcRenderer.invoke('set-quit-confirmation', show), getQuitConfirmationState: () => ipcRenderer.invoke('get-quit-confirmation-state'), - setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), - getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'), openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'), - onMouseBackButtonClicked: (callback: () => void) => { - // Wrapper that ignores the event parameter. - const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback(); - ipcRenderer.on('mouse-back-button-clicked', wrappedCallback); - return wrappedCallback; - }, - offMouseBackButtonClicked: (callback: () => void) => { - ipcRenderer.removeListener('mouse-back-button-clicked', callback); - }, on: ( channel: string, callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void @@ -230,19 +231,29 @@ const electronAPI: ElectronAPI = { restartApp: (): void => { ipcRenderer.send('restart-app'); }, + hasAcceptedRecipeBefore: (recipeConfig: RecipeConfig): Promise => { + return ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig); + }, + recordRecipeHash: (recipeConfig: RecipeConfig): Promise => { + return ipcRenderer.invoke('record-recipe-hash', recipeConfig); + }, + closeWindow: (): void => { + ipcRenderer.send('close-window'); + }, onUpdaterEvent: (callback: (event: UpdaterEvent) => void): void => { ipcRenderer.on('updater-event', (_event, data) => callback(data)); }, getUpdateState: (): Promise<{ updateAvailable: boolean; latestVersion?: string } | null> => { return ipcRenderer.invoke('get-update-state'); }, - closeWindow: () => ipcRenderer.send('close-window'), - hasAcceptedRecipeBefore: (recipeConfig: Recipe) => - ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig), - recordRecipeHash: (recipeConfig: Recipe) => - ipcRenderer.invoke('record-recipe-hash', recipeConfig), - openDirectoryInExplorer: (directoryPath: string) => - ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + repoIndex: (opts?: { path?: string; output?: string }): Promise => { + return ipcRenderer.invoke('repo-index', opts ?? {}); + }, + onRepoIndexProgress: (callback: (event: Electron.IpcRendererEvent, chunk: string) => void) => { + ipcRenderer.on('repo-index-progress', (event: Electron.IpcRendererEvent, data: unknown) => + callback(event, String(data)) + ); + }, }; const appConfigAPI: AppConfigAPI = { @@ -250,11 +261,6 @@ const appConfigAPI: AppConfigAPI = { getAll: () => config, }; -// Listen for recipe updates and update config directly -ipcRenderer.on('recipe-decoded', (_, decodedRecipe) => { - config.recipe = decodedRecipe; -}); - // Expose the APIs contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('appConfig', appConfigAPI); diff --git a/ui/desktop/src/utils/pathUtils.ts b/ui/desktop/src/utils/pathUtils.ts index d13e91cd122a..5bba38ab6f1a 100644 --- a/ui/desktop/src/utils/pathUtils.ts +++ b/ui/desktop/src/utils/pathUtils.ts @@ -31,6 +31,11 @@ export const getBinaryPath = (app: Electron.App, binaryName: string): string => return path.join(process.resourcesPath, 'bin', 'goosed.exe'); } + // For goose.exe, prefer the explicit resources/bin path like goosed + if (binaryName === 'goose') { + return path.join(process.resourcesPath, 'bin', 'goose.exe'); + } + // Map binary names to their Windows equivalents const windowsBinaryMap: Record = { npx: 'npx.cmd', diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 5fb1d64a41a3..cfe3f492b2b5 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -1,4 +1,5 @@ import { app } from 'electron'; +import type Electron from 'electron'; import fs from 'fs'; import path from 'path'; @@ -78,3 +79,32 @@ export function updateSchedulingEngineEnvironment(schedulingEngine: SchedulingEn process.env.GOOSE_SCHEDULER_TYPE = 'legacy'; } } + +// Build Environment submenu items for the View menu +export function createEnvironmentMenu( + toggles: EnvToggles, + onChange: (newToggles: EnvToggles) => void +): Electron.MenuItemConstructorOptions[] { + return [ + { + label: 'Server Memory', + type: 'checkbox', + checked: !!toggles.GOOSE_SERVER__MEMORY, + click: () => + onChange({ + ...toggles, + GOOSE_SERVER__MEMORY: !toggles.GOOSE_SERVER__MEMORY, + }), + }, + { + label: 'Computer Controller', + type: 'checkbox', + checked: !!toggles.GOOSE_SERVER__COMPUTER_CONTROLLER, + click: () => + onChange({ + ...toggles, + GOOSE_SERVER__COMPUTER_CONTROLLER: !toggles.GOOSE_SERVER__COMPUTER_CONTROLLER, + }), + }, + ]; +}