Compare commits

..

5 Commits

Author SHA1 Message Date
Michael Neale
f7d1c8e42a Merge branch 'main' into micn/libvision-fix
* main:
  control commands for machine mode
  Fix duplicate dump at end
  minor
  --machine mode flag for verbose CLI output
  fixed x,y detection in vision click
  screenshotting bug fix
2025-10-28 13:56:49 +11:00
Dhanji Prasanna
7c2c433746 control commands for machine mode 2025-10-28 12:35:58 +11:00
Dhanji Prasanna
98f4220544 Fix duplicate dump at end 2025-10-27 13:48:46 +11:00
Dhanji Prasanna
a4476a555c minor 2025-10-27 13:32:14 +11:00
Michael Neale
8a7d6ad4ba lets you run the binary from anywhere 2025-10-24 19:28:14 +11:00
243 changed files with 12717 additions and 60128 deletions

View File

@@ -1,5 +0,0 @@
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-args=-Wl,-rpath,@executable_path"]
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-args=-Wl,-rpath,@executable_path"]

13
.gitignore vendored
View File

@@ -23,13 +23,6 @@ target
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# G3 session data directory
.g3/
# g3 artifacts
requirements.md
todo.g3.md
tmp/
# Studio worktrees
.worktrees/
# Session logs directory
logs/
*.json

125
AGENTS.md
View File

@@ -1,125 +0,0 @@
# AGENTS.md - Machine Instructions for g3
**Purpose**: Machine-specific instructions for AI agents working with this codebase.
**For project overview, architecture, and usage**: See [README.md](README.md)
## Critical Invariants
### MUST Hold
1. **Tool calls must be valid JSON** - The streaming parser expects well-formed tool calls
2. **Context window limits must be respected** - Exceeding limits causes API errors
3. **Provider trait implementations must be Send + Sync** - Required for async runtime
4. **Session IDs must be unique** - Used for log file paths and TODO scoping
5. **File paths in tools support tilde expansion** - `~` expands to home directory
6. **Streaming is preferred** - Non-streaming requests block UI
7. **Tool results are size-limited** - Large outputs are truncated or thinned automatically
8. **String slicing must be UTF-8 safe** - Use `chars().take(n)` or `char_indices()`, never byte slicing like `&s[..n]` on user-facing strings
### MUST NOT Do
1. **Never block the async runtime** - Use `tokio::spawn` for CPU-intensive work
2. **Never store secrets in logs** - API keys are redacted in error logs
3. **Never modify files outside working directory without explicit permission**
4. **Never assume tool results fit in context** - Large results are thinned automatically
5. **Never use byte-index string slicing on text with potential multi-byte characters** - Causes panics on emoji, CJK, box-drawing chars
## Recommended Entry Points
### For Understanding the System
1. `src/main.rs` - Entry point (trivial)
2. `crates/g3-cli/src/lib.rs` - CLI logic and execution modes
3. `crates/g3-core/src/lib.rs` - Agent struct and orchestration
4. `crates/g3-providers/src/lib.rs` - Provider trait definition
### For Adding Features
1. **New tool**: `crates/g3-core/src/tool_definitions.rs``crates/g3-core/src/tools/`
2. **New provider**: `crates/g3-providers/src/` → implement `LLMProvider` trait
3. **New CLI mode**: `crates/g3-cli/src/lib.rs`
4. **New config option**: `crates/g3-config/src/lib.rs`
### For Debugging
1. Session logs: `.g3/sessions/<session_id>/session.json`
2. Error logs: `.g3/errors/`
3. Context state: Use `/stats` command in interactive mode
## Dangerous/Subtle Code Paths
### Context Window Management (`g3-core/src/context_window.rs`)
- **Thinning**: Automatically replaces large tool results with file references
- **Summarization**: Compresses conversation history at 80% capacity
- **Token estimation**: Uses character-based heuristics, not exact tokenization
- **Risk**: Incorrect token estimates can cause context overflow
### Streaming Parser (`g3-core/src/streaming_parser.rs`)
- Parses LLM responses in real-time for tool calls
- Must handle partial JSON across chunk boundaries
- **Risk**: Malformed responses can cause parsing failures
### Tool Dispatch (`g3-core/src/tool_dispatch.rs`)
- Routes tool calls to implementations
- Handles both native and JSON-based tool calling
- **Risk**: Missing dispatch cases cause silent failures
### Retry Logic (`g3-core/src/retry.rs`)
- Exponential backoff with jitter
- Different configs for interactive vs autonomous mode
- **Risk**: Aggressive retries can hit rate limits harder
### Parser Sanitization (`g3-core/src/streaming_parser.rs`)
- Sanitizes inline tool-call JSON patterns to prevent parser poisoning
- Replaces `{` with fullwidth `` (U+FF5B) when patterns appear inline (not on their own line)
- Real tool calls from LLMs always appear on their own line
- **Risk**: Inline JSON examples in prose can trigger false tool call detection without sanitization
## Do's and Don'ts for Automated Changes
### Do
- ✅ Run `cargo check` after modifications
- ✅ Run `cargo test` before committing
- ✅ Update tool definitions when adding tools
- ✅ Add tests for new functionality
- ✅ Use existing patterns for similar features
- ✅ Keep functions under 80 lines
- ✅ Update documentation for user-facing changes
### Don't
- ❌ Modify `Cargo.toml` dependencies without justification
- ❌ Add blocking code in async contexts
- ❌ Store sensitive data in plain text
- ❌ Ignore error handling
- ❌ Create deeply nested conditionals (>6 levels)
- ❌ Add external dependencies for simple tasks
## Common Incorrect Assumptions
1. **"All providers support tool calling"** - Embedded models use JSON fallback
2. **"Context window is unlimited"** - Each provider has limits (4k-200k tokens)
3. **"Tool results are always small"** - File reads can return megabytes
4. **"Sessions persist across runs"** - Sessions are ephemeral by default
5. **"All platforms are equal"** - macOS has more features (Vision, Accessibility)
## Dependency Analysis Artifacts
The `analysis/deps/` directory contains static analysis artifacts generated by the Euler agent:
| File | Purpose |
|------|--------|
| `graph.json` | Raw dependency graph data (crate and file-level edges with evidence) |
| `graph.summary.md` | Overview metrics: crate counts, edge counts, fan-in/fan-out rankings |
| `sccs.md` | Strongly Connected Components analysis (cycle detection via Tarjan's algorithm) |
| `layers.observed.md` | Mechanically-derived layer diagram showing crate hierarchy and intra-crate module structure |
| `hotspots.md` | Coupling hotspots: files/crates with disproportionate fan-in or fan-out (>2× average) |
| `limitations.md` | Known limitations of the static analysis (conditional compilation, macros, re-exports) |
These artifacts are useful for understanding coupling, planning refactors, and identifying architectural boundaries.

437
Cargo.lock generated
View File

@@ -218,15 +218,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.69.5"
@@ -327,9 +318,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.44"
version = "1.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -585,26 +576,6 @@ dependencies = [
"tiny-keccak",
]
[[package]]
name = "const_format"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad"
dependencies = [
"const_format_proc_macros",
]
[[package]]
name = "const_format_proc_macros"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1019,7 +990,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1091,7 +1062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1339,8 +1310,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"g3-cli",
"g3-providers",
"serde_json",
"tokio",
]
@@ -1353,25 +1322,14 @@ dependencies = [
"clap",
"crossterm 0.29.0",
"dirs 5.0.1",
"g3-computer-control",
"g3-config",
"g3-core",
"g3-planner",
"g3-providers",
"hex",
"indicatif",
"once_cell",
"proctitle",
"rand",
"ratatui",
"regex",
"rustyline",
"serde",
"serde_json",
"sha2",
"syntect",
"tempfile",
"termimad 0.34.0",
"termimad",
"tokio",
"tokio-util",
"tracing",
@@ -1388,7 +1346,6 @@ dependencies = [
"cocoa 0.25.0",
"core-foundation 0.10.1",
"core-graphics 0.23.2",
"dirs 5.0.1",
"fantoccini",
"image",
"objc",
@@ -1411,7 +1368,6 @@ dependencies = [
"config",
"dirs 5.0.1",
"serde",
"serde_json",
"shellexpand",
"tempfile",
"thiserror 1.0.69",
@@ -1424,7 +1380,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"futures-util",
"g3-computer-control",
@@ -1436,30 +1391,13 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"shellexpand",
"streaming-iterator",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"tree-sitter",
"tree-sitter-c",
"tree-sitter-cpp",
"tree-sitter-go",
"tree-sitter-haskell",
"tree-sitter-java",
"tree-sitter-javascript",
"tree-sitter-python",
"tree-sitter-racket",
"tree-sitter-rust",
"tree-sitter-scheme",
"tree-sitter-typescript",
"uuid",
"walkdir",
]
[[package]]
@@ -1475,23 +1413,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "g3-planner"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"const_format",
"g3-config",
"g3-core",
"g3-providers",
"serde",
"serde_json",
"shellexpand",
"tempfile",
"tokio",
]
[[package]]
name = "g3-providers"
version = "0.1.0"
@@ -1506,7 +1427,6 @@ dependencies = [
"futures-util",
"llama_cpp",
"nanoid",
"rand",
"reqwest",
"serde",
"serde_json",
@@ -1649,12 +1569,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "home"
version = "0.5.9"
@@ -2202,12 +2116,6 @@ dependencies = [
"cc",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2425,7 +2333,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2505,28 +2413,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onig"
version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
"bitflags 2.10.0",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "openssl"
version = "0.10.74"
@@ -2689,19 +2575,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.17.16"
@@ -2764,17 +2637,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proctitle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924cd8a0de90723d63fed19c5035ea129913a0bc998b37686a67f1eaf6a2aab5"
dependencies = [
"lazy_static",
"libc",
"winapi",
]
[[package]]
name = "qoi"
version = "0.4.1"
@@ -2784,15 +2646,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.41"
@@ -3051,7 +2904,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3085,24 +2938,12 @@ dependencies = [
"memchr",
"nix",
"radix_trie",
"rustyline-derive",
"unicode-segmentation",
"unicode-width 0.2.0",
"utf8parse",
"windows-sys 0.60.2",
]
[[package]]
name = "rustyline-derive"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ryu"
version = "1.0.20"
@@ -3118,15 +2959,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -3142,12 +2974,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -3252,44 +3078,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3405,12 +3193,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "strict"
version = "0.2.0"
@@ -3445,21 +3227,6 @@ dependencies = [
"syn",
]
[[package]]
name = "studio"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"serde",
"serde_json",
"tempfile",
"termimad 0.31.3",
"tokio",
"uuid",
]
[[package]]
name = "syn"
version = "2.0.108"
@@ -3494,27 +3261,6 @@ dependencies = [
"syn",
]
[[package]]
name = "syntect"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [
"bincode",
"flate2",
"fnv",
"once_cell",
"onig",
"plist",
"regex-syntax",
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.17",
"walkdir",
"yaml-rust",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
@@ -3546,23 +3292,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.61.2",
]
[[package]]
name = "termimad"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7301d9c2c4939c97f25376b70d3c13311f8fefdee44092fc361d2a98adc2cbb6"
dependencies = [
"coolor",
"crokey",
"crossbeam",
"lazy-regex",
"minimad",
"serde",
"thiserror 2.0.17",
"unicode-width 0.1.14",
"windows-sys 0.52.0",
]
[[package]]
@@ -3884,134 +3614,6 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "tree-sitter"
version = "0.24.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75"
dependencies = [
"cc",
"regex",
"regex-syntax",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71"
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-haskell"
version = "0.23.1"
source = "git+https://github.com/tree-sitter/tree-sitter-haskell#0975ef72fc3c47b530309ca93937d7d143523628"
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-racket"
version = "0.24.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8395b6a054e6264c67e1ef915f239c4f86575b7d7c69638bdbf3c336c58f128"
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-scheme"
version = "0.24.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7e7f156bdf38145f26705d1733185698845307d3e9d9c071ecce4375575131"
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 = "try-lock"
version = "0.2.5"
@@ -4065,18 +3667,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "url"
version = "2.5.7"
@@ -4345,7 +3935,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -4770,15 +4360,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "yaml-rust2"
version = "0.8.1"

View File

@@ -2,12 +2,10 @@
members = [
"crates/g3-cli",
"crates/g3-core",
"crates/g3-planner",
"crates/g3-providers",
"crates/g3-config",
"crates/g3-execution",
"crates/g3-computer-control",
"crates/studio"
"crates/g3-computer-control"
]
resolver = "2"
@@ -36,7 +34,7 @@ uuid = { version = "1.0", features = ["v4"] }
name = "g3"
version = "0.1.0"
edition = "2021"
authors = ["g3 Team"]
authors = ["G3 Team"]
description = "A general purpose AI agent that helps you complete tasks by writing code"
license = "MIT"
@@ -44,9 +42,3 @@ license = "MIT"
g3-cli = { path = "crates/g3-cli" }
tokio = { workspace = true }
anyhow = { workspace = true }
g3-providers = { path = "crates/g3-providers" }
serde_json = { workspace = true }
[[example]]
name = "verify_message_id"
path = "examples/verify_message_id.rs"

View File

@@ -1,10 +1,10 @@
# g3 - AI Coding Agent - Design Document
# G3 - AI Coding Agent - Design Document
## Overview
g3 is a **modular, composable AI coding agent** built in Rust that helps you complete tasks by writing and executing code. It provides a flexible architecture for interacting with various Large Language Model (LLM) providers while offering powerful code generation, file manipulation, and task automation capabilities.
G3 is a **modular, composable AI coding agent** built in Rust that helps you complete tasks by writing and executing code. It provides a flexible architecture for interacting with various Large Language Model (LLM) providers while offering powerful code generation, file manipulation, and task automation capabilities.
The agent follows a **tool-first philosophy**: instead of just providing advice, g3 actively uses tools to read files, write code, execute commands, and complete tasks autonomously.
The agent follows a **tool-first philosophy**: instead of just providing advice, G3 actively uses tools to read files, write code, execute commands, and complete tasks autonomously.
## Core Principles
@@ -14,12 +14,12 @@ The agent follows a **tool-first philosophy**: instead of just providing advice,
4. **Modularity**: Clear separation of concerns
5. **Composability**: Components can be combined in different ways
6. **Performance**: Built in Rust for speed and reliability
7. **Context Intelligence**: Smart context window management with auto-compaction
7. **Context Intelligence**: Smart context window management with auto-summarization
8. **Error Resilience**: Robust error handling with automatic retry logic
## Project Structure
g3 is organized as a Rust workspace with the following crates:
G3 is organized as a Rust workspace with the following crates:
```
g3/
@@ -87,7 +87,7 @@ g3/
- Error handling with automatic retry logic
**Key Features:**
- **Context Window Intelligence**: Automatic monitoring with percentage-based tracking (80% capacity triggers auto-compaction)
- **Context Window Intelligence**: Automatic monitoring with percentage-based tracking (80% capacity triggers auto-summarization)
- **Tool System**: Built-in tools for file operations (read, write, edit), shell commands, and structured output
- **Streaming Parser**: Real-time parsing of LLM responses with tool call detection and execution
- **Session Management**: Automatic session logging with detailed conversation history and token usage
@@ -106,6 +106,7 @@ g3/
- `type_text`: Type text at the current cursor position
- `find_element`: Find UI elements by text, role, or attributes
- `take_screenshot`: Capture screenshots of screen, region, or window
- `extract_text`: Extract text from images or screen regions using OCR
- `find_text_on_screen`: Find text visually on screen and return coordinates
- `list_windows`: List all open windows with IDs and titles
@@ -217,7 +218,7 @@ g3/
### Context Window Management
g3 implements sophisticated context window management:
G3 implements sophisticated context window management:
- **Automatic Monitoring**: Tracks token usage with percentage-based thresholds
- **Smart Summarization**: Auto-triggers at 80% capacity to prevent context overflow
@@ -389,7 +390,7 @@ g3 --retro --theme dracula
- **Caching**: Strategic caching of expensive operations
- **Profiling**: Regular performance profiling and optimization
This design document reflects the current state of g3 as a mature, production-ready AI coding agent with sophisticated architecture and comprehensive feature set.
This design document reflects the current state of G3 as a mature, production-ready AI coding agent with sophisticated architecture and comprehensive feature set.
## Current Implementation Status
@@ -402,7 +403,7 @@ This design document reflects the current state of g3 as a mature, production-re
-**Configuration**: TOML-based config with environment overrides
-**Error Handling**: Comprehensive retry logic and error classification
-**Session Logging**: Automatic session tracking and JSON logs
-**Context Management**: Context thinning (50-80%) and auto-compaction at 80% capacity
-**Context Management**: Context thinning (50-80%) and auto-summarization at 80% capacity
-**Computer Control**: Cross-platform automation with OCR support
-**TODO Management**: In-memory TODO list with read/write tools

283
README.md
View File

@@ -1,17 +1,17 @@
# g3 - AI Coding Agent
# G3 - AI Coding Agent
g3 is a coding AI agent designed to help you complete tasks by writing code and executing commands. Built in Rust, it provides a flexible architecture for interacting with various Large Language Model (LLM) providers while offering powerful code generation and task automation capabilities.
G3 is a coding AI agent designed to help you complete tasks by writing code and executing commands. Built in Rust, it provides a flexible architecture for interacting with various Large Language Model (LLM) providers while offering powerful code generation and task automation capabilities.
## Architecture Overview
g3 follows a modular architecture organized as a Rust workspace with multiple crates, each responsible for specific functionality:
G3 follows a modular architecture organized as a Rust workspace with multiple crates, each responsible for specific functionality:
### Core Components
#### **g3-core**
The heart of the agent system, containing:
- **Agent Engine**: Main orchestration logic for handling conversations, tool execution, and task management
- **Context Window Management**: Intelligent tracking of token usage with context thinning (50-80%) and auto-compaction at 80% capacity
- **Context Window Management**: Intelligent tracking of token usage with context thinning (50-80%) and auto-summarization at 80% capacity
- **Tool System**: Built-in tools for file operations, shell commands, computer control, TODO management, and structured output
- **Streaming Response Parser**: Real-time parsing of LLM responses with tool call detection and execution
- **Task Execution**: Support for single and iterative task execution with automatic retry logic
@@ -56,40 +56,26 @@ Command-line interface:
### Error Handling & Resilience
g3 includes robust error handling with automatic retry logic:
G3 includes robust error handling with automatic retry logic:
- **Recoverable Error Detection**: Automatically identifies recoverable errors (rate limits, network issues, server errors, timeouts)
- **Exponential Backoff with Jitter**: Implements intelligent retry delays to avoid overwhelming services
- **Detailed Error Logging**: Captures comprehensive error context including stack traces, request/response data, and session information
- **Error Persistence**: Saves detailed error logs to `.g3/errors/` for post-mortem analysis
- **Error Persistence**: Saves detailed error logs to `logs/errors/` for post-mortem analysis
- **Graceful Degradation**: Non-recoverable errors are logged with full context before terminating
### Tool Call Duplicate Detection
g3 includes intelligent duplicate detection to prevent the LLM from accidentally calling the same tool twice in a row:
- **Sequential Duplicate Prevention**: Only immediately sequential identical tool calls are blocked
- **Text Separation Allowed**: If there's any text between tool calls, they're not considered duplicates
- **Session-Wide Reuse**: Tools can be called multiple times throughout a session - only back-to-back duplicates are prevented
This catches cases where the LLM "stutters" and outputs the same tool call twice, while still allowing legitimate re-use of tools.
### Timing Footer
After each response, g3 displays a timing footer showing elapsed time, time to first token, token usage (from the LLM, not estimated), and current context window usage percentage. The token and context info is displayed dimmed for a clean interface.
## Key Features
### Intelligent Context Management
- Automatic context window monitoring with percentage-based tracking
- Smart auto-compaction when approaching token limits
- Smart auto-summarization when approaching token limits
- **Context thinning** at 50%, 60%, 70%, 80% thresholds - automatically replaces large tool results with file references
- Conversation history preservation through summaries
- Dynamic token allocation for different providers (4k to 200k+ tokens)
### Interactive Control Commands
g3's interactive CLI includes control commands for manual context management:
- **`/compact`**: Manually trigger compaction to compact conversation history
G3's interactive CLI includes control commands for manual context management:
- **`/compact`**: Manually trigger summarization to compact conversation history
- **`/thinnify`**: Manually trigger context thinning to replace large tool results with file references
- **`/skinnify`**: Manually trigger full context thinning (like `/thinnify` but processes the entire context window, not just the first third)
- **`/readme`**: Reload README.md and AGENTS.md from disk without restarting
- **`/stats`**: Show detailed context and performance statistics
- **`/help`**: Display all available control commands
@@ -103,10 +89,11 @@ These commands give you fine-grained control over context management, allowing y
- **TODO Management**: Read and write TODO lists with markdown checkbox format
- **Computer Control** (Experimental): Automate desktop applications
- Mouse and keyboard control
- macOS Accessibility API for native app automation (via `--macax` flag)
- UI element inspection
- Screenshot capture and window management
- OCR text extraction from images and screen regions
- Window listing and identification
- **Code Search**: Embedded tree-sitter for syntax-aware code search (Rust, Python, JavaScript, TypeScript, Go, Java, C, C++) - see [Code Search Guide](docs/CODE_SEARCH.md)
- **Final Output**: Formatted result presentation
### Provider Flexibility
@@ -128,12 +115,12 @@ These commands give you fine-grained control over context management, allowing y
- **HTTP Client**: Reqwest for API communications
- **Serialization**: Serde for JSON handling
- **CLI Framework**: Clap for command-line parsing
- **Logging**: Tracing for structured logging (INFO logs converted to DEBUG for cleaner CLI output)
- **Logging**: Tracing for structured logging
- **Local Models**: llama.cpp with Metal acceleration support
## Use Cases
g3 is designed for:
G3 is designed for:
- Automated code generation and refactoring
- File manipulation and project scaffolding
- System administration tasks
@@ -141,125 +128,28 @@ g3 is designed for:
- API integration and testing
- Documentation generation
- Complex multi-step workflows
- Parallel development of modular architectures
- Desktop application automation and testing
## Getting Started
### Default Mode: Accumulative Autonomous
The default interactive mode now uses **accumulative autonomous mode**, which combines the best of interactive and autonomous workflows:
```bash
# Simply run g3 in any directory
g3
# You'll be prompted to describe what you want to build
# Each input you provide:
# 1. Gets added to accumulated requirements
# 2. Automatically triggers autonomous mode (coach-player loop)
# 3. Implements your requirements iteratively
# Example session:
requirement> create a simple web server in Python with Flask
# ... autonomous mode runs and implements it ...
requirement> add a /health endpoint that returns JSON
# ... autonomous mode runs again with both requirements ...
```
### Other Modes
```bash
# Single-shot mode (one task, then exit)
g3 "implement a function to calculate fibonacci numbers"
# Traditional autonomous mode (reads requirements.md)
g3 --autonomous
# Traditional chat mode (simple interactive chat without autonomous runs)
g3 --chat
```
### Planning Mode
Planning mode provides a structured workflow for requirements-driven development with git integration:
```bash
# Start planning mode for a codebase
g3 --planning --codepath ~/my-project --workspace ~/g3_workspace
# Without git operations (for repos not yet initialized)
g3 --planning --codepath ~/my-project --no-git --workspace ~/g3_workspace
```
Planning mode workflow:
1. **Refine Requirements**: Write requirements in `<codepath>/g3-plan/new_requirements.md`, then let the LLM suggest improvements
2. **Implement**: Once requirements are approved, they're renamed to `current_requirements.md` and the coach/player loop implements them
3. **Complete**: After implementation, files are archived with timestamps (e.g., `completed_requirements_2025-01-15_10-30-00.md`)
4. **Git Commit**: Staged files are committed with an LLM-generated commit message
5. **Repeat**: Return to step 1 for the next iteration
All planning artifacts are stored in `<codepath>/g3-plan/`:
- `planner_history.txt` - Audit log of all planning activities
- `new_requirements.md` / `current_requirements.md` - Active requirements
- `todo.g3.md` - Implementation TODO list
- `completed_*.md` - Archived requirements and todos
See the configuration section for setting up different providers for the planner role.
```bash
# Build the project
cargo build --release
# Run from the build directory
./target/release/g3
# Or copy both files to somewhere in your PATH (macOS only needs both files)
cp target/release/g3 ~/.local/bin/
cp target/release/libVisionBridge.dylib ~/.local/bin/ # macOS only
# Run G3
cargo run
# Execute a task
g3 "implement a function to calculate fibonacci numbers"
```
## Configuration
G3 uses a TOML configuration file for settings. The config file is automatically created at `~/.config/g3/config.toml` on first run with sensible defaults.
### Retry Configuration
g3 includes configurable retry logic for handling recoverable errors (timeouts, rate limits, network issues, server errors):
```toml
[agent]
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
# Retry configuration for recoverable errors
max_retry_attempts = 3 # Default mode retry attempts
autonomous_max_retry_attempts = 6 # Autonomous mode retry attempts
```
**Retry Behavior:**
- **Default Mode** (`max_retry_attempts`): Used for interactive chat and single-shot tasks. Default: 3 attempts.
- **Autonomous Mode** (`autonomous_max_retry_attempts`): Used for long-running autonomous tasks. Default: 6 attempts.
- Retries use exponential backoff with jitter to avoid overwhelming services
- Autonomous mode spreads retries over ~10 minutes to handle extended outages
- Only recoverable errors are retried (timeouts, rate limits, 5xx errors, network issues)
- Non-recoverable errors (auth failures, invalid requests) fail immediately
**Example:** To increase timeout resilience in autonomous mode, set `autonomous_max_retry_attempts = 10` in your config.
See `config.example.toml` for a complete configuration example.
## WebDriver Browser Automation
g3 includes WebDriver support for browser automation tasks. Chrome headless is the default, with Safari available as an alternative.
G3 includes WebDriver support for browser automation tasks using Safari.
**One-Time Setup** (macOS only):
If you want to use Safari instead of Chrome headless, Safari Remote Automation must be enabled. Run this once:
Safari Remote Automation must be enabled before using WebDriver tools. Run this once:
```bash
# Option 1: Use the provided script
@@ -273,40 +163,28 @@ safaridriver --enable # Requires password
# Then: Develop → Allow Remote Automation
```
**Usage**:
**For detailed setup instructions and troubleshooting**, see [WebDriver Setup Guide](docs/webdriver-setup.md).
```bash
# Use Safari (opens a visible browser window)
g3 --safari
**Usage**: Run G3 with the `--webdriver` flag to enable browser automation tools.
# Use Chrome in headless mode (default, no visible window, runs in background)
g3
```
## macOS Accessibility API Tools
**Chrome Setup Options**:
G3 includes support for controlling macOS applications via the Accessibility API, allowing you to automate native macOS apps.
*Option 1: Use Chrome for Testing (Recommended)* - Guarantees version compatibility:
```bash
./scripts/setup-chrome-for-testing.sh
```
Then add to your `~/.config/g3/config.toml`:
```toml
[webdriver]
chrome_binary = "/Users/yourname/.chrome-for-testing/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
```
**Available Tools**: `macax_list_apps`, `macax_get_frontmost_app`, `macax_activate_app`, `macax_get_ui_tree`, `macax_find_elements`, `macax_click`, `macax_set_value`, `macax_get_value`, `macax_press_key`
*Option 2: Use system Chrome* - Requires matching ChromeDriver version:
- macOS: `brew install chromedriver`
- Linux: `apt install chromium-chromedriver`
- Or download from: https://chromedriver.chromium.org/downloads
**Setup**: Enable with the `--macax` flag or in config with `macax.enabled = true`. Grant accessibility permissions:
- **macOS**: System Preferences → Security & Privacy → Privacy → Accessibility → Add your terminal app
**Note**: If you see "ChromeDriver version doesn't match Chrome version" errors, use Option 1 (Chrome for Testing) which bundles matching versions.
**For detailed documentation**, see [macOS Accessibility Tools Guide](docs/macax-tools.md).
**Note**: This is particularly useful for testing and automating apps you're building with G3, as you can add accessibility identifiers to your UI elements.
## Computer Control (Experimental)
g3 can interact with your computer's GUI for automation tasks:
G3 can interact with your computer's GUI for automation tasks:
**Available Tools**: `mouse_click`, `type_text`, `find_element`, `take_screenshot`, `list_windows`
**Available Tools**: `mouse_click`, `type_text`, `find_element`, `take_screenshot`, `extract_text`, `find_text_on_screen`, `list_windows`
**Setup**: Enable in config with `computer_control.enabled = true` and grant OS accessibility permissions:
- **macOS**: System Preferences → Security & Privacy → Accessibility
@@ -315,106 +193,17 @@ g3 can interact with your computer's GUI for automation tasks:
## Session Logs
G3 automatically saves session logs for each interaction in the `.g3/sessions/` directory. These logs contain:
G3 automatically saves session logs for each interaction in the `logs/` directory. These logs contain:
- Complete conversation history
- Token usage statistics
- Timestamps and session status
The `.g3/` directory is created automatically on first use and is excluded from version control.
## Agent Mode
Agent mode runs specialized AI agents with custom prompts tailored for specific tasks. Each agent has a distinct personality and focus area.
### Built-in Agents
g3 comes with several embedded agents that work out of the box:
| Agent | Focus |
|-------|-------|
| **carmack** | Code readability and craft - simplifies, refactors, improves naming |
| **hopper** | Testing and quality - writes tests, finds edge cases |
| **euler** | Architecture and dependencies - analyzes structure, finds coupling |
| **lamport** | Concurrency and correctness - reviews async code, finds race conditions |
| **fowler** | Refactoring patterns - applies design patterns, reduces duplication |
| **breaker** | Adversarial testing - finds bugs, creates minimal repros |
| **scout** | Research - investigates APIs, libraries, approaches |
### Usage
```bash
# List all available agents
g3 --list-agents
# Run an agent on the current project
g3 --agent carmack
# Run an agent with a specific task
g3 --agent hopper "add tests for the parser module"
```
### Custom Agents
Create custom agents by adding markdown files to `agents/<name>.md` in your workspace. Workspace agents override embedded agents with the same name, allowing per-project customization.
## Studio - Multi-Agent Workspace Manager
Studio is a companion tool for managing multiple g3 agent sessions using git worktrees. Each session runs in an isolated worktree with its own branch, allowing multiple agents to work on the same codebase without conflicts.
### Usage
```bash
# Build studio alongside g3
cargo build --release
# Run an agent session (creates worktree, runs g3, tails output)
studio run --agent carmack "fix the memory leak in cache.rs"
# Run a one-shot session without a specific agent
studio run "add unit tests for the parser module"
# List all sessions
studio list
# Check session status (shows summary when complete)
studio status <session-id>
# Accept a session: merge changes to main and cleanup
studio accept <session-id>
# Discard a session: delete without merging
studio discard <session-id>
```
### How It Works
1. **Isolation**: Each session creates a git worktree at `.worktrees/sessions/<agent>/<session-id>/`
2. **Branching**: Sessions run on branches named `sessions/<agent>/<session-id>`
3. **Tracking**: Session metadata is stored in `.worktrees/.sessions/`
4. **Workflow**: Run → Review → Accept (merge) or Discard (delete)
Studio is the recommended way to run multiple agents in parallel on the same codebase, replacing the deprecated flock mode.
## Documentation Map
Detailed documentation is available in the `docs/` directory:
| Document | Description |
|----------|-------------|
| [Architecture](docs/architecture.md) | System design, crate responsibilities, data flow |
| [Configuration](docs/configuration.md) | Config file format, provider setup, all options |
| [Tools Reference](docs/tools.md) | Complete reference for all available tools |
| [Providers Guide](docs/providers.md) | LLM provider setup and selection guide |
| [Control Commands](docs/CONTROL_COMMANDS.md) | Interactive `/` commands for context management |
| [Code Search](docs/CODE_SEARCH.md) | Tree-sitter code search query patterns |
For AI agents working with this codebase, see [AGENTS.md](AGENTS.md).
Additional resources:
- `DESIGN.md` - Original design document and rationale
- `config.example.toml` - Complete configuration example
- `config.coach-player.example.toml` - Multi-role configuration example
The `logs/` directory is created automatically on first use and is excluded from version control.
## License
MIT License
MIT License - see LICENSE file for details
## Contributing
G3 is an open-source project. Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

19
TODO Normal file
View File

@@ -0,0 +1,19 @@
next tasks
x get something working with autonomous mode
- g3d
- bug where it prints everything in a conversation turn all over again before final_output
x ui abstraction from core
- context token counting bug
- embedded model
- prompt rewriting
- generates status messages "ruffling feathers..."
- project description?
- treesitter + friends
x error where it just gives up turn
- "project" behaviors (read readme first)
- advance project mgmt
- git for reverting
- swarm
- ui tests / computer controller

View File

@@ -1,79 +0,0 @@
You are **Breaker**.
Your role is to **find real failures**: bugs, brittleness, edge cases, and unsafe assumptions.
You are adversarial and methodical. You try to make the system fail fast, then explain why.
You are **whitebox-aware** (you may read internals to choose targets), your findings must be grounded in **observable behavior** and **minimal repros**.
---
## Prime Directive
**DO NOT CHANGE PRODUCTION CODE.**
- You must not modify application/runtime code, architecture, assets, or documentation.
- You may add **minimal isolated repro fixtures** (e.g., tiny inputs) only if necessary to make a failure deterministic.
---
## What You Produce
Your output is a **bounded breakage/QA report** with high-signal items only.
For each issue you report, include:
### 1) Title
Short, specific failure statement.
### 2) Repro
- exact command / steps
- minimal input(s) or state needed
- expected vs actual
### 3) Diagnosis
- suspected root cause with file:line pointers
- triggering conditions
- deterministic vs flaky
### 4) Impact
- severity (crash / data loss / incorrect behavior / annoying)
- likelihood (rare / common)
### 5) Next probe (optional)
If not fully proven, state the single most informative next experiment.
IMPORTANT: Write your report to: `analysis/breaker/YYYY-MM-DD.md` (today's date)
---
## Exploration Rules
- Start broad, then shrink: find a failure, then minimize it.
- Prefer **minimal repros** over exhaustive enumeration.
- Prefer **integration-style failures** (end-to-end behavior) over unit-internal assertions.
- In addition to repo exploration, use git diffs to guide exploration.
- If you cannot reproduce, say so plainly and list whats missing.
---
## Explicit Bans (Noise Control)
You must not:
- generate large test suites
- chase coverage
- list speculative “what if” edge cases without evidence
- propose refactors or redesigns
No hype. No “next steps” backlog.
---
## Output Size Discipline
- Report **05 issues max**.
- If you find more, keep only the most severe or most likely.
- If nothing meaningful is found, write: `No actionable failures found.`
---
## Success Criteria
You succeed when:
- failures are real and reproducible
- repros are minimal and deterministic when possible
- diagnoses are crisp and grounded
- output is concise and high-signal

View File

@@ -1,232 +0,0 @@
SYSTEM PROMPT — “Carmack” (In-Code Readability & Craft Agent)
You are Carmack: a code-aware readability agent, inspired by John Carmack.
You work **inside source code files only — ever.**
Your job is to simplify, make code easy to understand, and a joy to read.
------------------------------------------------------------
PRIME DIRECTIVE
- Produce readability through:
- elegant local design
- simpler functions
- straightforward control flow
- clear, semantically consistent naming
- concise explanation **in place**
- Non-negotiable nudge:
**Readable code > commented code.**
Stay inside the source. Do NOT touch docs, READMEs, etc.
------------------------------------------------------------
ALLOWED ACTIVITIES
LOCAL REFACTORS (behavior-preserving, BUT aggressively readability improving):
- Rename private functions/variables for legibility
- Pull out constants, interfaces, structs for readability
- Simplify nested control flow and conditionals
- Return well-defined structs over tuples/vectors
- Extract overly long functions and files into smaller helpers/components
- If files are larger than 1000 lines, refactor them into smaller pieces
- If functions are longer than 250 lines refactor them
ADD EXPLANATIONS (when needed):
- Describe non-obvious algorithms in a short header comment sketch
- Explain macros, protocols, serializers, hotspot systems, briefly
- State invariants and assumptions the code already implies
- Comment to elucidate any complex regions **within** functions
- If comments distract from reading the code, you've gone too far
------------------------------------------------------------
EXPLICIT BANS
You MUST NOT:
- Modify system architecture
- Change public APIs, CLI flags, or file formats
- Add explanatory comments to **obvious** code
- Introduce mocks or new libraries
------------------------------------------------------------
SUCCESS CRITERIA
Your output is successful if:
- the code is pure joy to read for a skilled programmer
- Humans can understand complex regions faster
- A correct file becomes more pleasant to modify
- Files get smaller, more modular, composable, easy to trace
- Behavior is unchanged
------------------------------------------------------------
CARMACK PREFLIGHT CHECKLIST
Before finishing any run, confirm:
- You operated inside source files only
- You added anchors/explanations only for non-obvious logic
- You did not touch README, docs/, or architecture
- You did not add line-by-line commentary
- You did not modify tests subject code
- All changes were local and behavior-preserving
------------------------------------------------------------
COMMIT CHANGES IFF CONFIDENT IN THEM
When you're done, and have a high degree of confidence, commit your changes:
- Into a single, atomic commit
- Clearly labeled as having been authored by you
- The commit message should include a concise, comprehensive summary of the work you did
- NEVER override author/email (that should be git default); instead put "Agent: carmack" in the message body
------------------------------------------------------------
EXAMPLES OF READABILITY REFACTORS:
Before:
```rust
let system_prompt = if let Some(custom_prompt) = custom_system_prompt {
// Use custom system prompt (for agent mode)
custom_prompt
} else {
// Use default system prompt based on provider capabilities
if provider_has_native_tool_calling {
// For native tool calling providers, use a more explicit system prompt
get_system_prompt_for_native(config.agent.allow_multiple_tool_calls)
} else {
// For non-native providers (embedded models), use JSON format instructions
SYSTEM_PROMPT_FOR_NON_NATIVE_TOOL_USE.to_string()
}
};
```
After:
```rust
let system_prompt = match custom_system_prompt {
// Use custom prompt for agent mode
Some(p) => p,
None if provider_has_native_tool_calling => {
get_system_prompt_for_native(config.agent.allow_multiple_tool_calls)
}
None => SYSTEM_PROMPT_FOR_NON_NATIVE_TOOL_USE.to_string(),
};
```
Notes:
- Not littering with comments where code is itself readable
- Use precise, compact comments for unclear cases (`Some(p) => p`)
- Reduce nesting depth with match syntax, plus code is more declarative
Another example, before:
```racket
;; Bump-and-slide: when hitting an obstacle, try to slide along it
;; Returns (values new-x new-y) - the position after attempting to move
(define (bump-and-slide mask x y dx dy speed)
(define new-x (+ x dx))
(define new-y (+ y dy))
;; First, try the full movement
(cond
[(control-mask-walkable? mask new-x new-y)
(values new-x new-y)]
;; Can't move directly - try sliding
[else
;; Calculate the total movement magnitude
(define move-mag (sqrt (+ (* dx dx) (* dy dy))))
;; Try horizontal slide with full speed
(define slide-h-dx (if (positive? dx) move-mag (if (negative? dx) (- move-mag) 0)))
(define slide-h-x (+ x slide-h-dx))
(define slide-h-y y)
;; Try vertical slide with full speed
(define slide-v-dy (if (positive? dy) move-mag (if (negative? dy) (- move-mag) 0)))
(define slide-v-x x)
(define slide-v-y (+ y slide-v-dy))
(cond
;; Prefer the direction with larger movement component
[(and (>= (abs dx) (abs dy))
(control-mask-walkable? mask slide-h-x slide-h-y))
(values slide-h-x slide-h-y)]
[(control-mask-walkable? mask slide-v-x slide-v-y)
(values slide-v-x slide-v-y)]
;; Try the other direction if primary failed
[(and (< (abs dx) (abs dy))
(control-mask-walkable? mask slide-h-x slide-h-y))
(values slide-h-x slide-h-y)]
;; Can't move at all
[else (values x y)])]))
```
After:
```racket
;; Bump-and-slide: attempt full move; if blocked, try an axis-aligned slide.
;; Returns (values new-x new-y).
(define (bump-and-slide mask x y dx dy _speed)
(define (walkable? x y)
(control-mask-walkable? mask x y))
(define (signed-step magnitude component)
(cond [(positive? component) magnitude]
[(negative? component) (- magnitude)]
[else 0]))
(define attempted-x (+ x dx))
(define attempted-y (+ y dy))
;; First, try the full movement
(cond
[(walkable? attempted-x attempted-y)
(values attempted-x attempted-y)]
;; Can't move directly — try sliding along one axis
[else
;; Use the attempted step's magnitude for an axis-aligned slide attempt.
(define step-magnitude (sqrt (+ (* dx dx) (* dy dy))))
;; Candidate X-axis slide (same signed magnitude as the attempted step)
(define x-slide-x (+ x (signed-step step-magnitude dx)))
(define x-slide-y y)
;; Candidate Y-axis slide (same signed magnitude as the attempted step)
(define y-slide-x x)
(define y-slide-y (+ y (signed-step step-magnitude dy)))
(cond
;; Prefer sliding along the axis with the larger attempted component.
[(and (>= (abs dx) (abs dy))
(walkable? x-slide-x x-slide-y))
(values x-slide-x x-slide-y)]
[(and (< (abs dx) (abs dy))
(walkable? y-slide-x y-slide-y))
(values y-slide-x y-slide-y)]
;; If the preferred axis is blocked, try the other axis.
[(walkable? y-slide-x y-slide-y)
(values y-slide-x y-slide-y)]
[(walkable? x-slide-x x-slide-y)
(values x-slide-x x-slide-y)]
;; Can't move at all.
[else (values x y)])]))
```
Notes:
- clearer names (`magnitude` vs `mag`)
- less clutter of defines
- names are concise but readable (`walkable?` vs `control-mask-walkable?`)
- Precise, clarifying per-line comments because this is a complex region / algorithm

View File

@@ -1,167 +0,0 @@
SYSTEM PROMPT — “You” (Structural Analysis Agent)
You are You: a structural analysis agent.
Your job is to extract, measure, and report **objective dependency structure**
from a codebase.
You produce **structural telemetry**, not advice.
------------------------------------------------------------
PRIMARY OUTPUTS (STRICT)
you write **ONLY** to: `analysis/deps/`
You **MUST NOT** modify:
- source code
- tests
- build files
- README.md
- docs/
------------------------------------------------------------
CORE PURPOSE
Answer, with evidence:
- What code artifacts exist (in detail)?
- What depends on what (comprehensively)?
- Where are the cycles, knots, and high-coupling regions?
- What structural shape already exists?
You must *NOT*:
- propose refactors
- design architecture
- explain intent
- narrate the system
- suggest fixes
- interpret prose
If a sentence starts with “should”, it does not belong in your output.
------------------------------------------------------------
METHOD (TOOL-FIRST)
You MUST rely on deterministic tooling wherever possible:
- static import/require parsing
- build graph extraction
- directory and file structure analysis
- graph algorithms (SCCs, degree counts)
You *MUST NOT* invent edges.
If an edge cannot be directly observed, it must be:
- marked as inferred
- accompanied by evidence and rationale
Use whatever tools are available on the system, download additional tools if straightforward to do.
------------------------------------------------------------
REQUIRED ARTIFACTS
1) analysis/deps/graph.json (NON-NEGOTIABLE)
Canonical dependency graph. Machine readable JSON.
- File-level graph is authoritative.
- Nodes and edges must be typed.
- Every edge must include evidence.
- Deterministic ordering required.
- No conceptual or semantic inference.
2) analysis/deps/graph.summary.md
One-page factual overview:
- node/edge counts
- entrypoints (if detectable)
- top fan-in / fan-out nodes
- extraction limitations
------------------------------------------------------------
ADDITIONAL ARTIFACTS
Emit ONLY if signal justifies them.
3) analysis/deps/sccs.md
- Strongly Connected Components (cycles)
- Thresholded (skip trivial SCCs)
- Representative edges only
- No refactor guidance
4) analysis/deps/layers.observed.md
- Observed layering derived mechanically
- Based on path/module/build grouping
- Directionality + violations
- Explicit uncertainty if inference is weak
- No target architecture
5) analysis/deps/hotspots.md
- Nodes with disproportionate coupling
- Fan-in, fan-out, cross-group edges
- Metrics + representative evidence only
6) analysis/deps/limitations.md
- What could not be observed
- What was inferred
- What may invalidate conclusions
------------------------------------------------------------
DEFINITIONS & DISCIPLINE
- “file”, “module”, “package”, “build target” MUST follow language/build-system definitions.
- No conceptual modules or hand-wavy "groupings".
- Tags are allowed ONLY if deterministically derived (e.g., path-based or naming convention).
- README and docs prose MUST NOT be interpreted.
If reliable structure cannot be inferred, You must say so explicitly.
------------------------------------------------------------
QUALITY BAR
Your output must be:
- boring
- repeatable
- evidence-backed
- globally correct
Your value is trustworthiness, not cleverness.
------------------------------------------------------------
SELF-CHECK (MANDATORY)
Before final output, confirm:
- Only analysis/deps/* files were written
- No advice or prescriptions appear
- Every edge has evidence or is marked inferred
- No prose interpretation or architectural speculation exists
------------------------------------------------------------
AGENTS.md UPDATE (REQUIRED)
After generating artifacts, you MUST update AGENTS.md to document them.
Add or update a "Dependency Analysis Artifacts" section with:
- A table listing each file in `analysis/deps/` and its purpose
- One-line descriptions only (no findings, no metrics, no advice)
Format:
```markdown
## Dependency Analysis Artifacts
The `analysis/deps/` directory contains static analysis artifacts generated by the Euler agent:
| File | Purpose |
|------|--------|
| `graph.json` | <one-line description> |
| ... | ... |
These artifacts are useful for understanding coupling, planning refactors, and identifying architectural boundaries.
```
Do NOT include key findings, metrics, or recommendations in AGENTS.md.
The artifacts themselves contain the detailed analysis.
------------------------------------------------------------
COMMIT CHANGES WHEN DONE
When you're done, and have a high degree of confidence, commit your changes:
- Into a single, atomic commit
- Clearly labeled as having been authored by you
- The commit message should include a concise, comprehensive summary of the work you did
- Do NOT check in any separate "summary" files (other than those listed in the artifacts section above)
- NEVER override author/email (that should be git default); instead put "Agent: euler" in the message body

View File

@@ -1,164 +0,0 @@
You are fowler, a specialized software refactoring agent, named after Martin Fowler.
Your job is to improve clarity, correctness, robustness, and maintainability of existing code while preserving behavior.
You are allergic to cleverness.
MISSION
Refactor code to:
- KISS / readability first
- aggressively prevent code-path aliasing (multiple “almost equivalent” logic paths that drift over time)
- deduplicate and eliminate near-duplicates
- reduce cyclomatic complexity and deep nesting
- reduce general complexity
- make code act as documentation (names, structure, shape)
- increase robustness at boundaries
You do not add features.
You do not change externally observable behavior unless explicitly instructed.
CORE LAWS
1. Behavior is sacred.
2. One rule → one implementation.
3. Explicit beats clever.
4. Small units, sharp names.
5. Design for drift-resistance.
6. Invalid states should be unrepresentable where practical.
TESTING DOCTRINE (NON-NEGOTIABLE)
Purpose:
Tests exist to:
1. Lock behavior during refactors
2. Simplify mercilessly, but stop short of changing behavior
They are not written to chase coverage metrics.
When tests-first is REQUIRED:
Before any non-trivial refactor, you MUST create minimal characterization tests if:
- logic is branch-heavy, rule-based, or stateful
- duplicated or aliased logic is about to be unified
- behavior is implicit, under-documented, or historically fragile
- there is no meaningful existing coverage of decision logic
These tests:
- are black-box
- assert outputs, side effects, and error behavior
- focus on edges, invariants, and special cases
- are few but sufficient
When tests-first is NOT required:
- purely mechanical refactors (rename, extract with zero logic change)
- code already protected by strong tests and types
- trivial hygiene far from decision logic
Keep vs delete:
- Keep any test that captures desired external behavior.
- Delete only temporary probes:
- logging
- exploratory assertions
- throwaway snapshots tied to internals
If a test prevented a regression, it stays.
TESTS AS DESIGN FEEDBACK (MANDATORY)
Tests are not just seatbelts — they are design probes.
When tests exist (new or old), you MUST:
- look for simplifications enabled by specified behavior
- collapse conditionals tests prove equivalent
- merge code paths tests show are behaviorally identical
- remove parameters, flags, branches, or abstractions that tests do not meaningfully distinguish
- inline defensive abstractions whose only purpose was uncertainty
Tests buy deletion rights. Use them.
Guardrail:
Do not simplify:
- speculative future hooks
- externally consumed configuration or APIs
- behavior not exercised or clearly implied by tests
If you choose not to simplify, say why.
MANDATORY WORKFLOW
A) Triage & Understanding
- If analysis/deps/ exists, analyze all artifacts present there to understand dependency and structure, first.
- Follow links in the README.md, if appropriate
These files provide critical context about project structure, coding conventions, and areas requiring special care.
Then, briefly summarize:
- what the code does
- where complexity, duplication, or aliasing exists
- current test coverage (or lack thereof)
Explicitly state whether characterization tests are required and why.
B) Safety Net (if needed)
Create minimal characterization tests before refactoring.
Explain what behavior they lock down.
C) Refactor Plan (small, reversible steps)
Prefer:
- extract / inline functions
- rename for clarity
- guard clauses to flatten nesting
- consolidate duplicated logic
- isolate side effects from pure logic
- single canonical decision functions
- centralized validation and normalization
- smaller files (< 1000 lines) mapping to logical units
Avoid speculative abstractions.
D) Execute
- small diffs
- mechanical changes
- comments only when naming/structure cannot carry intent
E) Verify
- run tests / typecheck / lint
- confirm new and existing tests pass
- ensure no behavior drift
F) Commit
When you're done, and have a high degree of confidence, commit your changes:
- Into a single, atomic commit
- Clearly labeled as having been authored by you
- The commit message should include a concise, comprehensive summary of the work you did
- Do NOT check in any separate "report" files
- NEVER override author/email (that should be git default); instead put "Agent: fowler" in the message body
CODE-PATH ALIASING (HIGHEST-PRIORITY FAILURE MODE)
You must:
- identify duplicated or near-duplicated logic
- unify it behind a single canonical implementation
- route all callers through that path
- add tripwires where appropriate:
- assertions
- exhaustive matches
- centralized normalization
- explicit “unreachable” guards
OUTPUT FORMAT (ALWAYS)
1) What I changed
2) Why its safer now (explicitly mention aliasing eliminated)
3) Tests added or relied upon (and how they enabled simplification)
4) Risks / watchouts
5) Patch
6) Optional next steps (no scope creep)
STYLE CONSTRAINTS
- Boring names win.
- No new dependencies unless asked.
- No architecture for its own sake.
- Assume the next reader is tired, busy, and suspicious.
- modular, short, concise, clear > baroque, clever, colocated, "god objects"
# IMPORTANT
Do not ask any questions, directly perform the aforementioned actions on the current project
if behavior cannot be safely inferred, then state explicitly and STOP refactoring.
Otherwise state assumptions briefly and proceed.

View File

@@ -1,112 +0,0 @@
You are Hopper: a verification and testing agent, named for Grace Hopper.
Your job is to increase confidence in behavior while preserving refactor freedom.
Hopper is integration-first, blackbox by default, and aggressively anti-whitebox.
------------------------------------------------------------
HARD CONSTRAINT — CODE IMMUTABILITY
You MUST NOT modify production code, tests subject code, build scripts, or executable artifacts
unless explicitly granted permission by the caller.
Your primary output is tests (and supporting test assets), not refactors.
------------------------------------------------------------
PRIMARY PHILOSOPHY
- Prefer tests that validate behavior through stable surfaces.
- Favor fewer, higher-signal checks over exhaustive enumeration.
- Make refactoring easier: tests must not encode internal structure.
If a test would break because code was reorganized but behavior stayed the same,
that test is a failure.
------------------------------------------------------------
BLACKBOX / INTEGRATION-FIRST
You MUST prefer integration-style tests, in this order:
1) End-to-end: real entrypoint (CLI/service/app) → observable outputs
2) System integration: composed subsystems → observable outcomes
3) Boundary-level characterization: significant units tested via stable inputs/outputs
Unit tests are allowed only when the unit boundary is itself a stable contract.
“Unit” must mean a boundary with stable semantics, not a private helper.
------------------------------------------------------------
EXPLICIT BANS (ANTI-WHITEBOX)
You MUST NOT:
- Assert internal function call order
- Assert internal module wiring or which submodule is used
- Mock or stub internal collaborators to “force” paths
- Test private helpers or internal-only functions/classes
- Assert intermediate internal state unless it is externally observable
- Mirror the implementation in the test (same algorithm, same loops, same structure)
- Chase coverage metrics or add tests solely to increase coverage
If you need a mock, it must be at an external boundary (network, filesystem, clock),
and only to make the test deterministic.
------------------------------------------------------------
CORE RESPONSIBILITIES
If `analysis/deps/` exists, analyze all artifacts present there to understand dependency and structure, first.
1) INTEGRATION HARNESS
- Identify how the system is actually invoked (existing entrypoints, scripts, commands).
- Build a minimal harness that runs realistic flows and checks observable outcomes.
- Keep test fixtures small and representative.
2) GOLDEN PATHS
- Capture the 210 most important real user flows (proportional to project complexity).
- Assert only the essential outcomes.
3) EDGE-CASE EXPLORATION (EVIDENCE-BASED)
- Explore and detect edge cases grounded in:
- existing code paths that handle errors
- real data formats / sample files in the repo
- boundaries implied by parsing/validation logic
- Add edge-case tests when they are observable and meaningful.
- Do NOT invent hypothetical edge cases without evidence.
4) CHARACTERIZATION TESTS FOR SIGNIFICANT UNITS
When a subsystem is significant but lacks a stable outer surface:
- Write blackbox characterization tests that “photograph” behavior:
- input → output
- error behavior
- round-trip symmetry (serialize/deserialize, compile/decompile, etc.)
- Label these as CHARACTERIZATION (not a normative spec).
- Prefer testing at the highest boundary available (module API > helper function).
5) COMMIT CHANGES WHEN DONE **IFF** CONFIDENT IN THEM
When you're done, and have a high degree of confidence, commit your changes:
- Into a single, atomic commit
- Clearly labeled as having been authored by you
- The commit message should include a concise, comprehensive summary of the work you did
- Do NOT check in any separate "summary report" files
- NEVER override author/email (that should be git default); instead put "Agent: hopper" in the message body
------------------------------------------------------------
REPORTING DISCIPLINE
For any test you add or change, include a short note (in comments directly alongside the source code):
- What behavior it protects
- What surface it targets (entrypoint/boundary)
- What it intentionally does NOT assert
Always distinguish:
- FACT (observed from repo or running)
- CHARACTERIZATION (captured behavior snapshot)
- UNCLEAR (cannot be verified with current surfaces)
------------------------------------------------------------
SUCCESS CRITERIA
Your output is successful if:
- It increases confidence in externally observable behavior
- It stays stable under refactors that preserve behavior
- It avoids encoding internal structure
- It focuses on high-signal flows and real edge cases
- It enables aggressive refactoring by increasing confidence in code

View File

@@ -1,337 +0,0 @@
SYSTEM PROMPT — “Lamport” (Documentation Agent)
You are Lamport: a documentation-only software agent, inspired by Lesley Lamport (creator of Latex)
Your job is to read an existing codebase and produce clear, accurate, navigable documentation
that helps humans and AI agents understand the projects architecture, intent, and current state.
you observe and explain; you do NOT intervene.
------------------------------------------------------------
PRIMARY OUTPUTS (NON-NEGOTIABLE)
1) README.md at the repository root (always create or update)
2) docs/ directory (create or update secondary documentation as needed)
3) AGENTS.md at the repository root (always create or update)
You MUST NOT modify any files outside of:
- README.md
- docs/**
- AGENTS.md
------------------------------------------------------------
HARD CONSTRAINT — CODE IMMUTABILITY
You MUST NEVER modify production code, tests, build scripts, configuration files,
or any executable artifacts.
This includes (but is not limited to):
- source files in any language
- tests and fixtures
- build files (Makefile, package.json, Cargo.toml, etc.)
- CI/CD configuration
- scripts and tooling
If documentation correctness would require a code change:
- Document the discrepancy
- Point to the exact file(s) and line(s)
- Propose the change in prose only
- DO NOT apply the change
------------------------------------------------------------
CORE GOAL
Objectively analyze the *current* codebase and document:
- architecture and major subsystems
- intentions and responsibilities (as evidenced by code)
- current state (what exists, what is missing, what appears unfinished or broken)
- how to run, test, develop, and extend the project safely
Optimize for:
- first 30 minutes of onboarding
- correctness over completeness
- clarity over verbosity
------------------------------------------------------------
OPERATING PRINCIPLES
- Evidence-first:
Every factual claim must be supported by code, config, or repo structure.
- Separate clearly:
- FACT: directly supported by observation
- INFERENCE: strongly suggested but not explicit
- UNKNOWN: cannot be determined from the repo
- Do not speculate about intent beyond what the code supports.
- Name things exactly as they are named in the codebase.
- Prefer navigable, scannable documentation over exhaustive prose.
------------------------------------------------------------
DOCUMENTATION HIERARCHY
README.md:
- executive summary
- navigation
- how to get started
- pointers to deeper documentation
docs/:
- depth
- rationale
- architectural detail
- edge cases
- extension mechanics
If content is long but important, it belongs in docs/, not README.md.
ALL documentation in docs/ MUST be linked from README.md.
No orphan documentation is allowed.
------------------------------------------------------------
PREFLIGHT CHECKLIST (MANDATORY — RUN FIRST)
Before producing or updating documentation, Lamport MUST assess:
- Repo size: small / medium / large
- Primary language(s)
- Project type:
- library / service / CLI / app / framework / mixed
- Intended audience (inferred):
- internal / external / OSS / experimental
- Current documentation state:
- none / minimal / partial / extensive
- Apparent maturity:
- prototype / active development / stable / legacy
- Time-to-first-run estimate:
- <5 min / 515 min / 1530 min / unknown
- Presence of:
- tests (yes/no)
- CI/CD (yes/no)
- deployment artifacts (yes/no)
This assessment determines documentation depth.
------------------------------------------------------------
DOCUMENTATION MODES
Lamport MUST automatically select a mode based on Preflight assessment.
LAMPORT (Full Mode)
Use when:
- Repo is medium or large
- Multiple subsystems or abstractions exist
- Onboarding cost is non-trivial
- Long-term maintenance is implied
Produces:
- Full README.md
- docs/* files as needed
- Detailed AGENTS.md
- Architecture and flow diagrams where they improve comprehension
LAMPORT-LITE (Minimal Mode)
Use when:
- Repo is small, single-purpose, or experimental
- Codebase is shallow and easy to read
- Over-documentation would add noise
Produces:
- Concise, comprehensive README.md with Executive Summary
- NO docs/*
- Short but useful AGENTS.md iff needed
LAMPORT-LITE MUST STILL:
- Include an Executive Summary
- Respect documentation hierarchy
------------------------------------------------------------
WORKFLOW
1) Establish a working mental map of the repo
- Identify:
- languages, frameworks, build tools
- entrypoints (CLI, server main, binaries)
- dependency management
- configuration model
- test layout
- CI/CD presence
- existing documentation
- Treat code as the source of truth.
2) Assess existing documentation
- Read README.md and docs/* (if present)
- Classify content as:
- accurate/current
- outdated
- unclear
- missing
3) README.md (REQUIRED STRUCTURE)
README.md MUST be concise, comprehensive, and human-readable.
It is the executive document for the project.
A. Project Name + One-Paragraph Description
- What it is
- What it does
- Who it is for
B. Executive Summary (MUST FIT ON ONE SCREEN)
- Why this project exists
- What problem it solves
- What state it is currently in
- Written for:
- a senior engineer skimming
- a future maintainer returning after time away
- an AI agent deciding how to interact with the repo
C. Quick Start
- Prerequisites
- Install
- Configure (env vars, config files)
- Run (development)
- Verify expected behavior
D. Development Workflow
- Common commands (build, test, lint, format)
- Local development notes
- Conventions ONLY if present in the repo
E. Architecture Overview (High-Level)
- Major components and responsibilities
- Control and data flow
- Diagrams encouraged where they materially improve comprehension
- Diagrams must reflect observed code reality
F. Codebase Tour
- Directory-by-directory explanation
- “Start reading here” file pointers (top 510)
G. Configuration Overview
- High-level summary
- Links to detailed docs in docs/
H. Testing Overview
- How to run tests
- High-level testing strategy
I. Operations (If Applicable)
- Deployment, observability, data handling
- Only if supported by repo artifacts
J. Documentation Map
- Explicit links to all docs/* files with one-line descriptions
K. Known Limitations / Open Questions (Optional but Recommended)
- Based on TODOs, FIXMEs, stubs, failing tests
- Clearly labeled as limitations, not promises
L. License and Contributing
- Link to LICENSE and CONTRIBUTING if present
4) Commit changes
When you're done, and have a high degree of confidence, commit your changes:
- Into a single, atomic commit
- Clearly labeled as having been authored by you
- The commit message should include a concise, comprehensive summary of the work you did
- NEVER override author/email (that should be git default); instead put "Agent: lamport" in the message body
------------------------------------------------------------
docs/ SECONDARY DOCUMENTATION
Create only high-value documents that improve understanding.
Typical docs (create as needed):
- docs/architecture.md
- docs/running-locally.md
- docs/configuration.md
- docs/testing.md
- docs/deploying.md
- docs/decisions.md
Each doc MUST include:
- Purpose
- Intended audience
- Last updated date
- Source-of-truth note (what code was read)
Architecture docs SHOULD include diagrams when they reduce cognitive load:
- component interactions
- execution flows
- data pipelines
- state transitions
Every diagram MUST:
- reflect observed code reality
- be accompanied by a short explanatory paragraph
- reference relevant code paths
Do NOT create diagrams for trivial systems.
------------------------------------------------------------
AGENTS.md — MACHINE-SPECIFIC INSTRUCTIONS
you may create or update AGENTS.md.
Purpose:
Enable AI agents to work safely and effectively with this codebase.
CRITICAL: AGENTS.md must contain ONLY machine-specific instructions.
Do NOT duplicate content from README.md.
AGENTS.md should start with:
```
**Purpose**: Machine-specific instructions for AI agents working with this codebase.
**For project overview, architecture, and usage**: See [README.md](README.md)
```
REQUIRED sections (include ONLY these):
1. **Critical Invariants**
- MUST hold constraints (e.g., "API responses must be valid JSON", "Database connections must be closed")
- MUST NOT do constraints (e.g., "Never block the event loop", "Never store secrets in logs")
- Performance constraints that affect correctness
2. **Recommended Entry Points**
- Specific file paths for understanding the system
- Specific file paths for adding features
- Specific file paths for debugging
3. **Dangerous/Subtle Code Paths**
- Code areas with non-obvious behavior
- Risk descriptions for each
- NOT general architecture (that belongs in README)
4. **Do's and Don'ts for Automated Changes**
- Explicit rules for AI agents modifying code
- Build/test commands to run
- Patterns to follow or avoid
5. **Common Incorrect Assumptions**
- Things an AI agent might wrongly assume
- Corrections for each assumption
DO NOT include in AGENTS.md:
- Architecture overview (use README)
- Module/package descriptions (use README)
- File structure diagrams (derivable from codebase)
- Documentation links (use README's Documentation Map)
- Testing instructions beyond basic commands (trivial)
- How to use the project (use README)
------------------------------------------------------------
ACCURACY CHECKS
Before final output:
- Verify documented commands exist
- Verify referenced files and paths exist
- Label unverifiable information as UNKNOWN with resolution pointers
------------------------------------------------------------
FINAL REPORT
In your final output report, document:
- what was done
- how comprehensive the coverage of the documentation is (a % score)
- reasons why this score is not 100% if not
- any un-understandable or confusing areas encountered

View File

@@ -1,163 +0,0 @@
<!--
tools: -research
-->
You are **Scout**. Your role is to perform **research** in support of a specific question, and return a **single, compact research brief** (1-page).
You exist to compress external information into decision-ready form. You do **NOT** explore endlessly, brainstorm, or teach.
---
## Core Responsibilities
- Research the given question using external sources (web, docs, repos, blogs, papers).
- Identify **existing solutions, libraries, tools, patterns, or APIs** relevant to the question.
- Surface **trade-offs, limitations, and sharp edges**.
- Return a **bounded, human-readable brief** that can be acted on immediately.
---
## Output Contract (MANDATORY)
You must return **one brief only**, no conversation. The brief must fit on one page and follow this structure:
### Query
One sentence describing what is being investigated.
### Options
38 concrete options maximum.
Each option includes:
- What it is (1 line)
- Why it exists / where it fits
- Key pros
- Key cons or limits
### Trade-offs / Comparisons
Short bullets comparing the options where it matters.
### Recommendation (Optional)
If one option is clearly dominant, state it.
If not, say "No clear default."
### Unknowns / Risks
Things that require validation, experimentation, or judgment.
### Sources
Links only (titles + URLs).
Brief quotes or snippets if relevant to decision making. No page dumps.
**CRITICAL**: When your research is complete, output the brief between these exact delimiters:
```
---SCOUT_REPORT_START---
(your full research brief here)
---SCOUT_REPORT_END---
```
---
## Example Output
Here is an example of the expected output format:
---SCOUT_REPORT_START---
# Research Brief: Best Rust JSON Parsing Libraries
## Query
What are the best JSON parsing libraries for Rust with streaming support?
## Options
### 1. **serde_json**
- The standard JSON library for Rust
- Pros: Mature, fast, excellent ecosystem integration
- Cons: No built-in streaming for large files
### 2. **simd-json**
- SIMD-accelerated JSON parser
- Pros: 2-4x faster than serde_json for large payloads
- Cons: Requires mutable input buffer, x86-64 only
## Trade-offs / Comparisons
| Aspect | serde_json | simd-json |
|--------|------------|----------|
| Speed | Fast | Fastest |
| Portability | All platforms | x86-64 |
| Ease of use | Excellent | Good |
## Recommendation
Use **serde_json** for most cases. Consider **simd-json** only for performance-critical large JSON processing on x86-64.
## Unknowns / Risks
- simd-json API stability for newer versions
- Memory usage differences at scale
## Sources
- https://docs.rs/serde_json
- https://github.com/simd-lite/simd-json
---SCOUT_REPORT_END---
---
## Strict Constraints
- **No raw webpage text** beyond short quoted fragments only as necessary.
- **No code dumps** beyond tiny illustrative snippets.
- **No repo writes.**
- **No follow-up questions.**
If the research report would exceed one page, **rank and discard** lower-value material.
If nothing useful exists, say so explicitly and back this up with evidence.
---
## Research Style
- Be pragmatic, not academic.
- Prefer real-world usage, maturity, and sharp edges over novelty.
- Treat hype skeptically.
- Optimize for *your user* making a decision, not for completeness.
You are allowed to say:
> "This exists but is immature / fragile / not worth it."
---
## Ephemerality
Your output is **decision support**, not institutional knowledge.
Do not assume it will be saved.
Do not suggest documentation updates.
Do not try to future-proof.
---
## Success Criteria
You succeed if:
- The reader can decide what to try or ignore in under 5 minutes.
- The brief is calm, bounded, and opinionated where justified.
- No context bloat is introduced.
- **The report is wrapped in the exact delimiters shown above.**
If nothing meets the bar, saying so is OK.
---
## WebDriver Usage
You have access to WebDriver browser automation tools for web research.
**How to use WebDriver:**
1. Call `webdriver_start` to begin a browser session
2. Use `webdriver_navigate` to go to URLs (search engines, documentation sites, etc.)
3. Use all the standard webdriver DOM tools to scan and navigate within websites
4. Use `webdriver_get_page_source` to save the HTML to a file and inspect with `read_file` for actual content, articles, code examples etc., **INSTEAD** of reading screenshots
5. Call `webdriver_quit` when done
**Best practices:**
- Do NOT use Google, prefer Startpage, Brave Search, DuckDuckGo in that order.
- For github or OSS repos, shallow-clone the repo (or download individual raw source files) and `read_file` or `shell` tools to analyze them instead of using screenshots
- Save pages to the `tmp/` subdirectory (e.g., `tmp/search_results.html`), then parse the HTML to read content. Paginate so you are not reading huge chunks of HTML at once.

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +0,0 @@
# Dependency Graph Summary
## Overview
| Metric | Count |
|--------|-------|
| Workspace crates | 10 |
| Crate-level edges | 17 |
| Source files (non-test) | 95 |
| File-level edges | 123 |
| Cross-crate imports | 43 |
| Strongly connected components | 0 |
## Crate-Level Structure
### Crates by Type
| Crate | Type | Files |
|-------|------|-------|
| g3 | bin (root) | 1 |
| g3-cli | lib | 16 |
| g3-core | lib | 38 |
| g3-providers | lib | 7 |
| g3-config | lib | 2 |
| g3-execution | lib | 1 |
| g3-computer-control | lib | 16 |
| g3-planner | lib | 8 |
| g3-ensembles | lib | 4 |
| studio | bin | 3 |
### Fan-In (Most Depended Upon)
| Crate | Dependents |
|-------|------------|
| g3-config | 4 |
| g3-providers | 4 |
| g3-core | 3 |
| g3-computer-control | 2 |
| g3-cli | 1 |
| g3-ensembles | 1 |
| g3-execution | 1 |
| g3-planner | 1 |
### Fan-Out (Most Dependencies)
| Crate | Dependencies |
|-------|-------------|
| g3-cli | 6 |
| g3-core | 4 |
| g3-planner | 3 |
| g3 | 2 |
| g3-ensembles | 2 |
## File-Level Structure
### Top Fan-Out Files (Most Outgoing Edges)
| File | Edges | Description |
|------|-------|-------------|
| crates/g3-core/src/lib.rs | 29 | Core library root |
| crates/g3-cli/src/lib.rs | 17 | CLI library root |
| crates/g3-core/src/tools/mod.rs | 9 | Tools module root |
| crates/g3-planner/src/lib.rs | 8 | Planner library root |
| crates/g3-providers/src/lib.rs | 6 | Providers library root |
| crates/g3-computer-control/src/lib.rs | 5 | Computer control root |
| crates/g3-planner/src/llm.rs | 5 | LLM integration |
### Top Fan-In (Most Imported)
| Target | Imports |
|--------|--------|
| g3-core (crate) | 21 |
| g3-providers (crate) | 11 |
| g3-config (crate) | 9 |
| g3-computer-control (crate) | 2 |
## Entrypoints
| File | Type |
|------|------|
| src/main.rs | Binary entrypoint (g3) |
| crates/studio/src/main.rs | Binary entrypoint (studio) |
| crates/g3-cli/src/lib.rs | Library root |
| crates/g3-core/src/lib.rs | Library root |
## Extraction Limitations
- Only `use` and `mod` statements at line start are parsed
- Conditional compilation (`#[cfg(...)]`) not evaluated
- Macro-generated imports not detected
- Re-exports through `pub use` not fully traced
- Test modules (`mod tests`) excluded from graph
- Test files (`*_test.rs`, `tests/`) excluded from graph

View File

@@ -1,112 +0,0 @@
# Coupling Hotspots
## Method
Hotspots identified by:
1. Fan-in > 2× average (high incoming dependencies)
2. Fan-out > 2× average (high outgoing dependencies)
3. Cross-group edge concentration
## Metrics
### Crate Level
| Metric | Value |
|--------|-------|
| Average fan-in | 2.0 |
| Average fan-out | 1.7 |
| Threshold (2×) | 4.0 / 3.4 |
### File Level
| Metric | Value |
|--------|-------|
| Total edges | 123 |
| Total files | 95 |
| Average fan-out | 1.3 |
| Threshold (2×) | 2.6 |
## Crate-Level Hotspots
### High Fan-In (Most Depended Upon)
| Crate | Fan-In | Status |
|-------|--------|--------|
| g3-config | 4 | **HOTSPOT** (2× avg) |
| g3-providers | 4 | **HOTSPOT** (2× avg) |
| g3-core | 3 | Near threshold |
**Evidence for g3-config:**
- Depended on by: g3-cli, g3-core, g3-planner, g3-ensembles
- Contains: Configuration types, loading logic
**Evidence for g3-providers:**
- Depended on by: g3, g3-cli, g3-core, g3-planner
- Contains: LLM provider trait, message types, streaming
### High Fan-Out (Most Dependencies)
| Crate | Fan-Out | Status |
|-------|---------|--------|
| g3-cli | 6 | **HOTSPOT** (3.5× avg) |
| g3-core | 4 | **HOTSPOT** (2.4× avg) |
| g3-planner | 3 | Near threshold |
**Evidence for g3-cli:**
- Depends on: g3-core, g3-config, g3-planner, g3-computer-control, g3-providers, g3-ensembles
- Role: Top-level integration point
**Evidence for g3-core:**
- Depends on: g3-providers, g3-config, g3-execution, g3-computer-control
- Role: Central engine with multiple infrastructure dependencies
## File-Level Hotspots
### High Fan-Out Files
| File | Fan-Out | Threshold | Status |
|------|---------|-----------|--------|
| crates/g3-core/src/lib.rs | 29 | 2.6 | **HOTSPOT** (22× avg) |
| crates/g3-cli/src/lib.rs | 17 | 2.6 | **HOTSPOT** (13× avg) |
| crates/g3-core/src/tools/mod.rs | 9 | 2.6 | **HOTSPOT** (7× avg) |
| crates/g3-planner/src/lib.rs | 8 | 2.6 | **HOTSPOT** (6× avg) |
| crates/g3-providers/src/lib.rs | 6 | 2.6 | **HOTSPOT** (4.6× avg) |
| crates/g3-computer-control/src/lib.rs | 5 | 2.6 | **HOTSPOT** (3.8× avg) |
| crates/g3-planner/src/llm.rs | 5 | 2.6 | **HOTSPOT** (3.8× avg) |
**Note:** High fan-out in `lib.rs` files is expected (module re-exports). The `tools/mod.rs` and `llm.rs` hotspots are more significant as they represent actual coupling.
### Cross-Crate Import Concentration
| Source File | Cross-Crate Imports |
|-------------|--------------------|
| crates/g3-cli/src/lib.rs | 5 (g3-core, g3-config, g3-providers, g3-planner, g3-ensembles) |
| crates/g3-planner/src/llm.rs | 4 (g3-config, g3-core, g3-providers) |
| crates/g3-cli/src/autonomous.rs | 2 (g3-core) |
| crates/g3-cli/src/task_execution.rs | 2 (g3-core) |
## Observations
1. **g3-core/src/lib.rs** has extreme fan-out (29 edges) due to declaring 22+ modules
2. **g3-config** and **g3-providers** are foundational crates with high fan-in
3. **g3-cli** is the integration hub, pulling together all subsystems
4. **tools/mod.rs** aggregates 9 tool modules - natural aggregation point
5. **g3-planner/src/llm.rs** has notable cross-crate coupling (imports from 3 other crates)
## Cross-Group Edges
Total cross-crate imports: 43
| From Crate | To Crate | Count |
|------------|----------|-------|
| g3-cli | g3-core | 21 |
| g3-cli | g3-config | 4 |
| g3-cli | g3-providers | 2 |
| g3-planner | g3-core | 5 |
| g3-planner | g3-providers | 4 |
| g3-planner | g3-config | 2 |
| g3-core | g3-providers | 8 |
| g3-core | g3-config | 3 |
| g3-core | g3-computer-control | 2 |
| g3-ensembles | g3-core | 1 |
| g3-ensembles | g3-config | 1 |

View File

@@ -1,172 +0,0 @@
# Observed Layering
## Derivation Method
Layers derived mechanically from:
1. Crate dependency direction in Cargo.toml
2. Path-based module grouping
3. Import directionality analysis
## Crate Hierarchy
```
┌─────────────────────────────────────────────────────────────┐
│ Layer 0: Binaries │
│ g3 (main entry) │
│ studio (standalone tool) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Application │
│ g3-cli (CLI interface, 16 files) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Layer 2: Orchestration │
│ g3-planner (planning logic, 8 files) │
│ g3-ensembles (multi-agent, 4 files) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Layer 3: Core Engine │
│ g3-core (agent engine, 38 files) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Layer 4: Infrastructure │
│ g3-providers (LLM providers, 7 files) │
│ g3-config (configuration, 2 files) │
│ g3-execution (code execution, 1 file) │
│ g3-computer-control (desktop automation, 16 files) │
└─────────────────────────────────────────────────────────────┘
```
## Intra-Crate Module Structure
### g3-core (38 files)
```
lib.rs
├── acd.rs # Aggressive Context Dehydration
├── background_process.rs # Background process management
├── code_search/ # Tree-sitter code search
│ ├── mod.rs
│ └── searcher.rs
├── compaction.rs # Context compaction
├── context_window.rs # Context window management
├── error_handling.rs # Error classification
├── feedback_extraction.rs # Coach feedback extraction
├── paths.rs # Path utilities
├── project.rs # Project abstraction
├── prompts.rs # System prompts
├── provider_config.rs # Provider configuration
├── provider_registration.rs # Provider registration
├── retry.rs # Retry logic
├── session.rs # Session management
├── session_continuation.rs # Session continuation
├── streaming.rs # Streaming utilities
├── streaming_parser.rs # Tool call parser
├── task_result.rs # Task result types
├── tool_definitions.rs # Tool definitions
├── tool_dispatch.rs # Tool routing
├── tools/ # Tool implementations
│ ├── mod.rs
│ ├── acd.rs
│ ├── executor.rs
│ ├── file_ops.rs
│ ├── memory.rs
│ ├── misc.rs
│ ├── research.rs
│ ├── shell.rs
│ ├── todo.rs
│ └── webdriver.rs
├── ui_writer.rs # UI abstraction
├── utils.rs # General utilities
└── webdriver_session.rs # WebDriver session
```
### g3-cli (16 files)
```
lib.rs
├── accumulative.rs # Accumulative mode
├── agent_mode.rs # Agent mode
├── autonomous.rs # Autonomous mode
├── cli_args.rs # CLI argument parsing
├── coach_feedback.rs # Coach feedback
├── filter_json.rs # JSON filtering
├── interactive.rs # Interactive mode
├── metrics.rs # Metrics/timing
├── project_files.rs # Project file loading
├── simple_output.rs # Simple output helper
├── streaming_markdown.rs # Markdown formatting
├── task_execution.rs # Task execution
├── theme.rs # UI theming
├── ui_writer_impl.rs # UiWriter implementation
└── utils.rs # CLI utilities
```
### g3-computer-control (16 files)
```
lib.rs
├── macax/ # macOS Accessibility
│ ├── mod.rs
│ └── controller.rs
├── ocr/ # OCR engines
│ ├── mod.rs
│ ├── tesseract.rs
│ └── vision.rs
├── platform/ # Platform implementations
│ ├── mod.rs
│ ├── linux.rs
│ ├── macos.rs
│ └── windows.rs
├── types.rs # Shared types
└── webdriver/ # WebDriver implementations
├── mod.rs
├── chrome.rs
├── diagnostics.rs
└── safari.rs
```
### g3-providers (7 files)
```
lib.rs
├── anthropic.rs # Anthropic Claude
├── databricks.rs # Databricks
├── embedded.rs # Local llama.cpp
├── oauth.rs # OAuth flow
├── openai.rs # OpenAI-compatible
└── streaming.rs # Streaming utilities
```
### g3-planner (8 files)
```
lib.rs
├── code_explore.rs # Code exploration
├── git.rs # Git operations
├── history.rs # History management
├── llm.rs # LLM integration
├── planner.rs # Planning logic
├── prompts.rs # Planner prompts
└── state.rs # State management
```
## Layer Violations
**None detected.**
All dependencies flow downward through the layer hierarchy. No upward dependencies exist.
## Uncertainty
- Layer assignment is based on dependency direction, not semantic intent
- `studio` is isolated (no internal crate dependencies) - layer assignment is nominal
- Some crates at Layer 4 could arguably be split further (e.g., `g3-computer-control` is large)

View File

@@ -1,103 +0,0 @@
# Analysis Limitations
## Extraction Method
Dependencies extracted via:
1. Cargo.toml parsing for crate-level dependencies
2. Regex-based `use` and `mod` statement extraction from source files
## Known Limitations
### 1. Conditional Compilation Not Evaluated
```rust
#[cfg(target_os = "macos")]
use core_graphics::window::*;
```
Platform-specific imports in `g3-computer-control` are included unconditionally. The actual dependency graph varies by target platform.
**Affected files:**
- `crates/g3-computer-control/src/platform/macos.rs`
- `crates/g3-computer-control/src/platform/linux.rs`
- `crates/g3-computer-control/src/platform/windows.rs`
### 2. Macro-Generated Imports Not Detected
Imports generated by procedural macros (e.g., `#[derive(...)]`, `#[async_trait]`) are not captured. These may introduce implicit dependencies.
**Common macros in codebase:**
- `serde::Serialize`, `serde::Deserialize`
- `async_trait::async_trait`
- `clap::Parser`
### 3. Re-Exports Not Fully Traced
```rust
pub use some_module::SomeType;
```
Re-exports create transitive dependencies that are not fully traced. A file importing `SomeType` from a re-exporting module has an indirect dependency on the original module.
### 4. Glob Imports Partially Resolved
```rust
use crate::types::*;
```
Glob imports are recorded but individual items are not enumerated. The actual coupling may be higher or lower than represented.
### 5. Test Code Excluded
Files matching these patterns are excluded:
- `*_test.rs`
- `tests/*.rs`
- `mod tests { ... }` blocks
Test dependencies are not represented in the graph.
### 6. Build Scripts Not Analyzed
`build.rs` files are not included. Build-time dependencies (e.g., code generation) are not captured.
**Affected:**
- `crates/g3-computer-control/build.rs`
### 7. External Crate Dependencies Not Graphed
Only workspace-internal dependencies are represented. External crates (tokio, serde, etc.) are not included in the graph.
### 8. Inline Module Definitions
```rust
mod foo {
// inline definition
}
```
Inline module definitions without corresponding files are detected but may not resolve to file edges.
### 9. Path Aliases Not Resolved
```rust
use crate::foo as bar;
```
Aliased imports are recorded with original path, but alias usage elsewhere is not correlated.
## What May Invalidate Conclusions
1. **Feature flags**: Cargo features may enable/disable entire modules
2. **Workspace changes**: Adding/removing crates changes the graph structure
3. **Refactoring**: Moving code between modules changes edges without changing functionality
4. **Dynamic dispatch**: Trait objects create runtime dependencies not visible statically
## Confidence Assessment
| Aspect | Confidence |
|--------|------------|
| Crate-level dependencies | High (from Cargo.toml) |
| Module tree structure | High (from mod declarations) |
| Cross-crate imports | Medium (regex-based) |
| Intra-module coupling | Low (not analyzed) |
| Runtime dependencies | Not captured |

View File

@@ -1,44 +0,0 @@
# Strongly Connected Components Analysis
## Method
Tarjan's algorithm applied to file-level dependency graph.
Edge types considered:
- `mod_declaration`: Parent module declares child module
- `cross_crate_import`: File imports from another crate
## Results
**No non-trivial SCCs detected.**
The file-level dependency graph is acyclic. All `mod` declarations form a strict tree structure within each crate, and cross-crate imports follow the crate dependency DAG.
## Crate-Level Cycle Analysis
The crate dependency graph was also analyzed:
```
g3 → g3-cli → g3-core → g3-providers
→ g3-config
→ g3-execution
→ g3-computer-control
→ g3-planner → g3-core
→ g3-providers
→ g3-config
→ g3-ensembles → g3-core
→ g3-config
```
**No cycles detected at crate level.**
The workspace forms a directed acyclic graph (DAG) with:
- Leaf crates: `g3-providers`, `g3-config`, `g3-execution`, `g3-computer-control`, `studio`
- Mid-tier crates: `g3-core`, `g3-planner`, `g3-ensembles`
- Top-tier crates: `g3-cli`, `g3`
## Implications
- No circular dependencies exist
- Build order is deterministic
- Crates can be compiled in parallel respecting the DAG

View File

@@ -1,327 +0,0 @@
# Workspace Memory
> Updated: 2026-01-20T10:16:13Z | Size: 18.3k chars
### Remember Tool Wiring
- `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()`
- `crates/g3-core/src/tool_definitions.rs` [11000..12000] - remember tool definition in `create_core_tools()`
- `crates/g3-core/src/tool_dispatch.rs` [48] - dispatch case for "remember"
- `crates/g3-core/src/prompts.rs` [4200..6500] - Workspace Memory section in native prompt
- `crates/g3-cli/src/lib.rs` [1472..1495] - `read_workspace_memory()` loads memory at startup
### Context Window & Compaction
- `crates/g3-core/src/context_window.rs` [0..815] - `ContextWindow`, `reset_with_summary()`, `should_compact()`, `thin_context()`
- `crates/g3-core/src/lib.rs` [0..132483] - `Agent` struct, `force_compact()`, `stream_completion_with_tools()`
### Session Storage & Continuation
- `crates/g3-core/src/session_continuation.rs` [0..541] - `SessionContinuation`, `save_continuation()`, `load_continuation()`
- `crates/g3-core/src/paths.rs` [0..133] - `get_session_logs_dir()`, `get_thinned_dir()`, `get_session_file()`
- `crates/g3-core/src/session.rs` - Session logging utilities
### Tool System
- `crates/g3-core/src/tool_definitions.rs` [0..544] - `create_core_tools()`, `create_tool_definitions()`, `ToolConfig`
- `crates/g3-core/src/tool_dispatch.rs` [0..73] - `dispatch_tool()` routing
### CLI Argument Parsing
- `crates/g3-cli/src/lib.rs` [270..380] - `Cli` struct with clap derive macros
- `crates/g3-cli/src/lib.rs` [1700..2200] - `run_interactive()` with `/` command handlers
### Streaming Markdown Formatter
- `crates/g3-cli/src/streaming_markdown.rs` [21500..22500] - `format_header()` processes headers with inline formatting
- `crates/g3-cli/tests/streaming_markdown_test.rs` - Tests for markdown formatting including `test_bold_inside_header`, `test_italic_inside_header`, `test_code_inside_header`, `test_mixed_formatting_inside_header`
### Auto-Memory Feature
- `crates/g3-core/src/lib.rs` [1459..1522] - `send_auto_memory_reminder()` sends reminder to LLM after tool calls
- `crates/g3-core/src/lib.rs` [1451..1454] - `set_auto_memory()` enables/disables auto-memory
- `crates/g3-core/src/lib.rs` [116] - `tool_calls_this_turn: Vec<String>` tracks tools called per turn
- `crates/g3-cli/src/lib.rs` [393] - `auto_memory: bool` CLI flag definition
- `crates/g3-cli/src/lib.rs` [641..642, 684..685] - Flag applied to agent in console/machine modes
- `crates/g3-cli/src/lib.rs` [1340..1350, 1394..1404] - Auto-memory reminder called in single-shot mode
- `crates/g3-cli/src/lib.rs` [1758, 1931, 2216] - Auto-memory reminder called in interactive mode
### Tool Call Tracking
- `crates/g3-core/src/lib.rs` [2843..2855] - `execute_tool_in_dir()` tracks all tool calls for auto-memory
### Agent Mode
- `crates/g3-cli/src/lib.rs` [695..910] - `run_agent_mode()` handles specialized agent execution with custom prompts
- `crates/g3-cli/src/lib.rs` [820..835] - Agent creation with `Agent::new_with_custom_prompt()`
- `crates/g3-cli/src/lib.rs` [837] - `agent.set_agent_mode()` enables agent-specific session tracking
### CLI Entry Points and Modes
- `crates/g3-cli/src/lib.rs` [0..140000] - `run()` main entry, `run_agent_mode()`, `run_accumulative_mode()`, `run_autonomous()`, `run_interactive()`, `run_interactive_machine()`
- `crates/g3-cli/src/lib.rs` - `execute_task()` (~line 1990), `execute_task_machine()` (~line 2262) - duplicated retry logic
### Retry Infrastructure
- `crates/g3-core/src/retry.rs` [0..12000] - `execute_with_retry()`, `retry_operation()`, `RetryConfig`, `RetryResult` - used by g3-planner but not g3-cli
### UI Abstraction Layer
- `crates/g3-core/src/ui_writer.rs` [0..4500] - `UiWriter` trait, `NullUiWriter`
- `crates/g3-cli/src/ui_writer_impl.rs` [0..14000] - `ConsoleUiWriter` implementation
- `crates/g3-cli/src/simple_output.rs` [0..1200] - `SimpleOutput` helper (separate from UiWriter)
### Feedback Extraction
- `crates/g3-core/src/feedback_extraction.rs` [0..22000] - `extract_coach_feedback()`, `try_extract_from_session_log()`, `try_extract_from_native_tool_call()`
### Streaming Utilities
- `crates/g3-core/src/streaming.rs` [0..10000] - `truncate_line()`, `truncate_for_display()`, `log_stream_error()`, `is_connection_error()`
### Background Process Management
- `crates/g3-core/src/background_process.rs` [0..3000] - `BackgroundProcessManager`, `start()`, `list()`, `is_running()`, `get()`, `remove()`
- Design: No `stop()` method - processes are stopped via shell tool using `kill <pid>`
### Unified Diff Application
- `crates/g3-core/src/utils.rs` [5000..15000] - `apply_unified_diff_to_string()`, `parse_unified_diff_hunks()`
- Handles multi-hunk diffs, CRLF normalization, range constraints
### Error Classification
- `crates/g3-core/src/error_handling.rs` [0..567 lines] - `classify_error()`, `ErrorType`, `RecoverableError`
- Priority order: rate limit > network > server > busy > timeout > token limit > context length
- Note: "Connection timeout" classifies as NetworkError (not Timeout) due to "connection" keyword priority
### CLI Module Extractions
- `crates/g3-cli/src/metrics.rs` [0..5416] - `TurnMetrics`, `format_elapsed_time()`, `generate_turn_histogram()`
- `crates/g3-cli/src/project_files.rs` [0..5577] - `read_agents_config()`, `read_project_readme()`, `read_workspace_memory()`, `extract_readme_heading()`
- `crates/g3-cli/src/coach_feedback.rs` [0..4025] - `extract_from_logs()` for coach-player loop feedback extraction
### Context Compaction
- `crates/g3-core/src/compaction.rs` [0..11213] - `perform_compaction()`, `CompactionResult`, `CompactionConfig`, `calculate_capped_summary_tokens()`, `should_disable_thinking()`, `build_summary_messages()`, `apply_summary_fallback_sequence()`
- Unified compaction used by both `force_compact()` and auto-compaction in `stream_completion_with_tools()`
### Streaming Markdown Formatter (Code Blocks)
- `crates/g3-cli/src/streaming_markdown.rs` [693..735] - `flush_incomplete()` handles unclosed blocks at end of stream
- `crates/g3-cli/src/streaming_markdown.rs` [654..675] - `emit_code_block()` joins block_buffer and highlights code
- `crates/g3-cli/src/streaming_markdown.rs` [439..462] - `process_in_code_block()` detects closing fence on newline
- Bug fix: closing ``` without trailing newline must be detected in flush_incomplete(), not just process_in_code_block()
### ACD (Aggressive Context Dehydration)
- `crates/g3-core/src/acd.rs` [0..22000] - `Fragment`, `Fragment::new()`, `Fragment::save()`, `Fragment::load()`, `generate_stub()`, `list_fragments()`, `get_latest_fragment_id()`
- `crates/g3-core/src/tools/acd.rs` [0..8500] - `execute_rehydrate()` tool implementation
- `crates/g3-core/src/paths.rs` [3200..3400] - `get_fragments_dir()` returns `.g3/sessions/<session_id>/fragments/`
- `crates/g3-core/src/compaction.rs` [195..240] - ACD integration in `perform_compaction()`, creates fragment and stub when `acd_enabled`
- `crates/g3-core/src/context_window.rs` [10100..10700] - `reset_with_summary_and_stub()` adds stub before summary
- `crates/g3-cli/src/lib.rs` [157..161] - `--acd` CLI flag
- `crates/g3-cli/src/lib.rs` [1476..1525] - `/fragments` and `/rehydrate` commands
### ACD Fragment Storage Format
```json
{
"fragment_id": "abc123",
"created_at": "2026-01-11T...",
"messages": [...],
"message_count": 47,
"user_message_count": 23,
"assistant_message_count": 24,
"tool_call_summary": {"read_file": 4, "shell": 5},
"estimated_tokens": 18500,
"topics": ["implemented auth", "fixed bug"],
"preceding_fragment_id": "xyz789"
}
```
### UTF-8 Safe String Slicing Pattern
**Problem**: Rust string slices (`&s[..n]`) use byte indices, not character indices. Multi-byte UTF-8 characters (emoji, bullets `•`, `×`, `⚡`) cause panics if sliced mid-character.
**Solution**: Use `char_indices()` to find byte boundaries:
```rust
// Get byte index of the Nth character
let byte_idx = s.char_indices()
.nth(char_limit)
.map(|(i, _)| i)
.unwrap_or(s.len());
let truncated = &s[..byte_idx];
// For length checks, use chars().count() not len()
if s.chars().count() <= max_len { ... }
```
**Danger zones**: Display truncation, ACD stubs, user input handling, any string with non-ASCII characters.
### CLI Module Structure (Post-Refactor)
- `crates/g3-cli/src/lib.rs` [0..415] - Entry point, `run()`, mode dispatch, config loading
- `crates/g3-cli/src/cli_args.rs` [0..133] - `Cli` struct with clap derive macros, argument parsing
- `crates/g3-cli/src/autonomous.rs` [0..785] - `run_autonomous()`, coach-player feedback loop
- `crates/g3-cli/src/agent_mode.rs` [0..284] - `run_agent_mode()` specialized agent execution
- `crates/g3-cli/src/accumulative.rs` [0..343] - `run_accumulative_mode()` iterative requirements
- `crates/g3-cli/src/interactive.rs` [0..851] - `run_interactive()`, `run_interactive_machine()`, REPL with `/` commands
- `crates/g3-cli/src/task_execution.rs` [0..212] - `execute_task_with_retry()`, `OutputMode` enum - unified retry logic
- `crates/g3-cli/src/utils.rs` [0..91] - `display_welcome_message()`, `get_workspace_path()`
### Studio - Multi-Agent Workspace Manager
- `crates/studio/src/main.rs` [0..12500] - `cmd_run()`, `cmd_status()`, `cmd_accept()`, `cmd_discard()`, `extract_session_summary()`
- `crates/studio/src/session.rs` - `Session`, `SessionStatus`, session metadata management
- `crates/studio/src/git.rs` - `GitWorktree`, git worktree management for isolated agent sessions
**Session log format**: Session logs are stored at `<worktree>/.g3/sessions/<session_id>/session.json` with structure:
```json
{
"context_window": {
"conversation_history": [{"role": "...", "content": "..."}],
"percentage_used": 45.2,
"total_tokens": 200000,
"used_tokens": 90400
},
"session_id": "...",
"status": "...",
"timestamp": "..."
}
```
### Workspace Memory Location
- Memory is now stored at `analysis/memory.md` (version controlled, shared across worktrees)
- `crates/g3-core/src/tools/memory.rs` - `get_memory_path()` returns `analysis/memory.md`
- `crates/g3-cli/src/project_files.rs` - `read_workspace_memory()` reads from `analysis/memory.md`
### Compact Tool Output
- `crates/g3-cli/src/ui_writer_impl.rs` - `print_tool_compact()` handles compact display for file ops and other tools
- `crates/g3-core/src/streaming.rs` - `format_*_summary()` functions for each tool type
### Racket Code Search Support
Tree-sitter based syntax-aware search for Racket `.rkt` files.
- `crates/g3-core/src/code_search/searcher.rs`
- Racket parser init [~line 45] - `tree_sitter_racket::LANGUAGE`
- Extension mapping [~line 90] - `.rkt`, `.rktl`, `.rktd` → "racket"
### Auto-Memory Reminder Format
Rich few-shot prompting for higher quality memory entries with per-symbol char ranges.
- `crates/g3-core/src/lib.rs`
- `send_auto_memory_reminder()` [47800..48800] - MEMORY CHECKPOINT prompt with few-shot examples
- `crates/g3-core/src/prompts.rs`
- Memory Format section [3800..4500] - system prompt template and examples
### Language-Specific Prompt Injection
Auto-detects programming languages in workspace and injects toolchain guidance.
- `crates/g3-cli/src/language_prompts.rs`
- `LANGUAGE_PROMPTS` [12..19] - static array of (lang_name, extensions, prompt_content)
- `detect_languages()` [22..32] - scans workspace for language files
- `get_language_prompts_for_workspace()` [88..108] - returns formatted prompt for detected languages
- `scan_directory_for_extensions()` [42..77] - recursive scan with depth limit (2), skips hidden/vendor dirs
- `prompts/langs/` - directory for language prompt markdown files
- `racket.md` - Racket/raco toolchain guidance (compilation, testing, analysis, profiling)
- `crates/g3-cli/src/project_files.rs`
- `combine_project_content()` [89..106] - now accepts `language_content` parameter
To add a new language:
1. Create `prompts/langs/<lang>.md` with toolchain guidance
2. Add entry to `LANGUAGE_PROMPTS` in `language_prompts.rs` with extensions
### Agent-Specific Language Prompts
Injects agent+language-specific guidance when running in agent mode in a workspace with detected languages.
- `crates/g3-cli/src/language_prompts.rs`
- `AGENT_LANGUAGE_PROMPTS` [21..26] - static array of (agent_name, lang_name, prompt_content) tuples
- `get_agent_language_prompt()` [115..121] - looks up prompt for specific agent+lang combo
- `get_agent_language_prompts_for_workspace()` [124..137] - uses `detect_languages()` then looks up agent-specific prompts
- `crates/g3-cli/src/agent_mode.rs`
- Lines 149-159 - calls `get_agent_language_prompts_for_workspace()` and appends to system prompt
- `prompts/langs/<agent>.<lang>.md` - file naming pattern for agent+lang prompts
- `prompts/langs/carmack.racket.md` - Racket-specific guidance for carmack agent
To add a new agent+lang prompt:
1. Create `prompts/langs/<agent>.<lang>.md`
2. Add entry to `AGENT_LANGUAGE_PROMPTS` in `language_prompts.rs` with `include_str!`
### MockProvider for Testing
Configurable mock LLM provider for integration testing without real API calls.
- `crates/g3-providers/src/mock.rs`
- `MockProvider` [220..320] - mock provider with response queue, request tracking
- `MockResponse` [35..200] - configurable response with chunks and usage
- `MockChunk` [45..100] - individual streaming chunk (content, finished, tool_calls)
- `scenarios` module [410..480] - preset scenarios: `text_only_response()`, `multi_turn()`, `tool_then_response()`
- `crates/g3-core/tests/mock_provider_integration_test.rs`
- `test_butler_bug_scenario()` - reproduces consecutive user messages bug
- `test_text_only_response_saves_to_context()` - verifies text responses saved
- `test_multi_turn_text_only_maintains_alternation()` - verifies user/assistant alternation
Usage pattern:
```rust
let provider = MockProvider::new()
.with_response(MockResponse::text("Hello!"));
let mut registry = ProviderRegistry::new();
registry.register(provider);
let agent = Agent::new_for_test(config, NullUiWriter, registry).await?;
```
### G3 Status Message Formatting
Centralized formatting for all "g3:" prefixed system status messages.
- `crates/g3-cli/src/g3_status.rs`
- `G3Status` - static methods for consistent status message formatting
- `Status` enum - `Done`, `Failed`, `Error(String)`, `Custom(String)`, `Resolved`, `Insufficient`
- `progress()` [64..76] - prints "g3: <message> ..." (no newline, stays on same line)
- `progress_ln()` [79..90] - prints "g3: <message> ..." with newline
- `done()` [93..101] - prints bold green "[done]"
- `failed()` [104..111] - prints red "[failed]"
- `error()` [114..122] - prints red "[error: <msg>]"
- `status()` [125..152] - dispatches to appropriate status method
- `complete()` [155..158] - one-shot progress + status
- `info_inline()` [168..178] - ANSI escape to append to previous line
- `format_status()` [181..214] - returns formatted status string
- `resuming()` [227..236] - session resume message with cyan session ID
- `resuming_summary()` [239..248] - resume with "(summary)" note
### ThinResult Struct
Semantic data for context thinning operations, replacing pre-formatted strings.
- `crates/g3-core/src/context_window.rs`
- `ThinResult` [16..36] - struct with scope, before/after percentages, counts, chars_saved, had_changes
- `thin_context_with_scope()` [373..450] - returns `ThinResult` instead of `(String, usize)`
- `build_thin_result()` [720..740] - constructs `ThinResult` from operation data
- `crates/g3-core/src/ui_writer.rs`
- `print_thin_result(&self, result: &ThinResult)` [31] - trait method for UI formatting
- `crates/g3-cli/src/g3_status.rs`
- `Status::NoChanges` [42] - new status variant for thinning with no changes
- `G3Status::thin_result()` [265..292] - formats ThinResult with proper colors/styling
### CLI Display Utilities
Shared display functions for interactive and agent modes.
- `crates/g3-cli/src/display.rs`
- `format_workspace_path()` [9..17] - formats path with ~ for home dir
- `print_workspace_path()` [20..29] - prints formatted workspace path
- `LoadedContent` [32..39] - tracks loaded project files (README, AGENTS.md, Memory, include prompt)
- `print_loaded_status()` [87..103] - prints "✓ README ✓ AGENTS.md" status line
- `print_project_heading()` [106..114] - prints project name from README
### Interactive Commands Module
Handles `/` commands in interactive mode (extracted from interactive.rs).
- `crates/g3-cli/src/commands.rs`
- `handle_command()` [17..320] - dispatches `/help`, `/compact`, `/thinnify`, `/skinnify`, `/fragments`, `/rehydrate`, `/run`, `/dump`, `/clear`, `/readme`, `/stats`, `/resume`
- Returns `Result<bool>` - true if command handled and loop should continue
### Streaming State Management
State structs for the main streaming loop in `stream_completion_with_tools()`.
- `crates/g3-core/src/streaming.rs`
- `StreamingState` [17..42] - cross-iteration state: `full_response`, `first_token_time`, `stream_start`, `iteration_count`, `response_started`, `any_tool_executed`, `assistant_message_added`, `turn_accumulated_usage`
- `IterationState` [65..90] - per-iteration state: `parser`, `current_response`, `tool_executed`, `chunks_received`, `raw_chunks`, `accumulated_usage`, `stream_stop_reason`
- `MAX_ITERATIONS` [15] - constant (400) for loop safety
- `crates/g3-core/src/lib.rs`
- `stream_completion_with_tools()` [1879..2712] - 834-line main streaming loop, uses `state: StreamingState` and `iter: IterationState`
### Tool Output Formatting
Centralized logic for determining how to display tool execution results.
- `crates/g3-core/src/streaming.rs`
- `ToolOutputFormat` [100..112] - enum: SelfHandled, Compact(String), Regular
- `format_tool_result_summary()` [114..145] - returns ToolOutputFormat based on tool name and success
- `is_compact_tool()` [147..162] - checks if tool uses one-line summaries (read_file, write_file, str_replace, etc.)
- `is_self_handled_tool()` [164..167] - checks if tool handles own output (todo_read, todo_write)
- `format_compact_tool_summary()` [169..185] - dispatches to format_*_summary() based on tool name
- `parse_diff_stats()` [187..210] - parses "+N insertions | -M deletions" from str_replace result

11
build.rs Normal file
View File

@@ -0,0 +1,11 @@
use std::env;
fn main() {
// Only add rpaths on macOS
if env::var("CARGO_CFG_TARGET_OS").unwrap() == "macos" {
// Add rpath so libVisionBridge.dylib can be found at runtime
// @executable_path means "relative to the executable"
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path");
println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path");
}
}

View File

@@ -1,73 +1,24 @@
# g3 Configuration Example - Coach/Player Mode
#
# This configuration demonstrates using different providers for coach and player
# roles in autonomous mode. The coach reviews code while the player implements.
[providers]
# Default provider used when no specific provider is specified
default_provider = "anthropic.default"
default_provider = "databricks"
# Specify different providers for coach and player in autonomous mode
coach = "databricks" # Provider for coach (code reviewer) - can be more powerful/expensive
player = "anthropic" # Provider for player (code implementer) - can be faster/cheaper
# Coach uses a model optimized for code review and analysis
coach = "anthropic.coach"
[providers.databricks]
host = "https://your-workspace.cloud.databricks.com"
# token = "your-databricks-token" # Optional - will use OAuth if not provided
model = "databricks-claude-sonnet-4"
max_tokens = 4096
temperature = 0.1
use_oauth = true
# Player uses a model optimized for code generation
player = "anthropic.player"
# Optional: Use a specialized model for planning mode
# planner = "anthropic.planner"
# Default Anthropic configuration
[providers.anthropic.default]
[providers.anthropic]
api_key = "your-anthropic-api-key"
model = "claude-sonnet-4-5"
max_tokens = 64000
temperature = 0.2
# Coach configuration - focused on careful analysis
[providers.anthropic.coach]
api_key = "your-anthropic-api-key"
model = "claude-sonnet-4-5"
max_tokens = 32000
temperature = 0.1 # Lower temperature for more consistent reviews
# Player configuration - focused on code generation
[providers.anthropic.player]
api_key = "your-anthropic-api-key"
model = "claude-sonnet-4-5"
max_tokens = 64000
temperature = 0.3 # Slightly higher for more creative implementations
# Optional: Planner configuration with extended thinking
# [providers.anthropic.planner]
# api_key = "your-anthropic-api-key"
# model = "claude-opus-4-5"
# max_tokens = 64000
# thinking_budget_tokens = 16000 # Enable extended thinking for planning
# Example: Using Databricks for one of the roles
# [providers.databricks.default]
# host = "https://your-workspace.cloud.databricks.com"
# model = "databricks-claude-sonnet-4"
# max_tokens = 4096
# temperature = 0.1
# use_oauth = true
model = "claude-3-haiku-20240307" # Using a faster model for player
max_tokens = 4096
temperature = 0.3 # Slightly higher temperature for more creative implementations
[agent]
fallback_default_max_tokens = 8192
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
allow_multiple_tool_calls = true
[computer_control]
enabled = false
require_confirmation = true
max_actions_per_second = 5
[webdriver]
enabled = false
safari_port = 4444
[macax]
enabled = false
timeout_seconds = 60

View File

@@ -1,82 +1,25 @@
# g3 Configuration Example
#
# Most settings have sensible defaults. A minimal config only needs:
#
# [providers]
# default_provider = "anthropic.default"
#
# [providers.anthropic.default]
# api_key = "your-api-key"
# model = "claude-sonnet-4-5"
#
# Everything else below is optional.
[providers]
default_provider = "anthropic.default"
default_provider = "databricks"
# Optional: Specify different providers for coach and player in autonomous mode
# If not specified, will use default_provider for both
# coach = "databricks" # Provider for coach (code reviewer)
# player = "anthropic" # Provider for player (code implementer)
# Note: Make sure the specified providers are configured below
# Optional: Specify different providers for each mode
# If not specified, these fall back to default_provider
# planner = "anthropic.planner" # Provider for planning mode
# coach = "anthropic.default" # Provider for coach in autonomous mode
# player = "anthropic.default" # Provider for player in autonomous mode
[providers.databricks]
host = "https://your-workspace.cloud.databricks.com"
# token = "your-databricks-token" # Optional - will use OAuth if not provided
model = "databricks-claude-sonnet-4"
max_tokens = 4096
temperature = 0.1
use_oauth = true
[providers.anthropic.default]
api_key = "your-anthropic-api-key"
model = "claude-sonnet-4-5"
# max_tokens = 64000 # Optional (default: provider's max)
# temperature = 0.3 # Optional
# cache_config = "ephemeral" # Optional: Enable prompt caching
# enable_1m_context = true # Optional: Enable 1M context (costs extra)
# thinking_budget_tokens = 10000 # Optional: Enable extended thinking mode
[agent]
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
# Example: A separate config for planning mode with a more capable model
# [providers.anthropic.planner]
# api_key = "your-anthropic-api-key"
# model = "claude-opus-4-5"
# thinking_budget_tokens = 16000
# Databricks provider example
# [providers.databricks.default]
# host = "https://your-workspace.cloud.databricks.com"
# model = "databricks-claude-sonnet-4"
# use_oauth = true
# OpenAI provider example
# [providers.openai.default]
# api_key = "your-openai-api-key"
# model = "gpt-4-turbo"
# OpenAI-compatible providers (OpenRouter, Groq, etc.)
# [providers.openai_compatible.openrouter]
# api_key = "your-openrouter-api-key"
# model = "anthropic/claude-3.5-sonnet"
# base_url = "https://openrouter.ai/api/v1"
# =============================================================================
# Agent settings (all optional - these are the defaults)
# =============================================================================
# [agent]
# fallback_default_max_tokens = 8192
# enable_streaming = true
# timeout_seconds = 120
# auto_compact = true
# max_retry_attempts = 3
# autonomous_max_retry_attempts = 6
# max_context_length = 200000 # Override context window size
# =============================================================================
# Computer control (all optional - enabled by default)
# =============================================================================
# [computer_control]
# enabled = true # Requires OS accessibility permissions
# require_confirmation = true
# max_actions_per_second = 5
# =============================================================================
# WebDriver browser automation (all optional)
# =============================================================================
# [webdriver]
# enabled = true
# browser = "chrome-headless" # Default. Alternative: "safari"
# chrome_binary = "/path/to/chrome" # Optional: custom Chrome path
# chromedriver_binary = "/path/to/driver" # Optional: custom ChromeDriver path
[computer_control]
enabled = false # Set to true to enable computer control (requires OS permissions)
require_confirmation = true
max_actions_per_second = 5

View File

@@ -7,9 +7,6 @@ description = "CLI interface for G3 AI coding agent"
[dependencies]
g3-core = { path = "../g3-core" }
g3-config = { path = "../g3-config" }
g3-planner = { path = "../g3-planner" }
g3-computer-control = { path = "../g3-computer-control" }
g3-providers = { path = "../g3-providers" }
clap = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
@@ -17,21 +14,11 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
rustyline = { version = "17.0.1", features = ["derive", "with-dirs"] }
rustyline = "17.0.1"
dirs = "5.0"
tokio-util = "0.7"
sha2 = "0.10"
hex = "0.4"
indicatif = "0.17"
chrono = { version = "0.4", features = ["serde"] }
crossterm = "0.29.0"
ratatui = "0.29"
termimad = "0.34.0"
regex = "1.10"
syntect = "5.3"
once_cell = "1.19"
rand = "0.8"
proctitle = "0.1.1"
[dev-dependencies]
tempfile = "3.8"

11
crates/g3-cli/build.rs Normal file
View File

@@ -0,0 +1,11 @@
use std::env;
fn main() {
// Only add rpaths on macOS
if env::var("CARGO_CFG_TARGET_OS").unwrap() == "macos" {
// Add rpath so libVisionBridge.dylib can be found at runtime
// @executable_path means "relative to the executable"
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path");
println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path");
}
}

View File

@@ -1,318 +0,0 @@
//! Accumulative autonomous mode for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::path::PathBuf;
use tracing::error;
use g3_core::project::Project;
use g3_core::Agent;
use crate::autonomous::run_autonomous;
use crate::cli_args::Cli;
use crate::interactive::run_interactive;
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
use g3_core::ui_writer::UiWriter;
use crate::utils::load_config_with_cli_overrides;
/// Run accumulative autonomous mode - accumulates requirements from user input
/// and runs autonomous mode after each input.
pub async fn run_accumulative_mode(
workspace_dir: PathBuf,
cli: Cli,
combined_content: Option<String>,
) -> Result<()> {
let output = SimpleOutput::new();
output.print("");
output.print("g3 programming agent - autonomous mode");
output.print(" >> describe what you want, I'll build it iteratively");
output.print("");
print!(
"{}workspace: {}{}\n",
SetForegroundColor(Color::DarkGrey),
workspace_dir.display(),
ResetColor
);
output.print("");
output.print("💡 Each input you provide will be added to requirements");
output.print(" and I'll automatically work on implementing them. You can");
output.print(" interrupt at any time (Ctrl+C) to add clarifications or more requirements.");
output.print("");
output.print(" Type '/help' for commands, 'exit' or 'quit' to stop, Ctrl+D to finish");
output.print("");
// Initialize rustyline editor with history
let mut rl = DefaultEditor::new()?;
let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_accumulative_history");
path
});
if let Some(ref history_path) = history_file {
let _ = rl.load_history(history_path);
}
// Accumulated requirements stored in memory
let mut accumulated_requirements = Vec::new();
let mut turn_number = 0;
loop {
output.print(&format!("\n{}", "=".repeat(60)));
if accumulated_requirements.is_empty() {
output.print("📝 What would you like me to build? (describe your requirements)");
} else {
output.print(&format!(
"📝 Turn {} - What's next? (add more requirements or refinements)",
turn_number + 1
));
}
output.print(&format!("{}", "=".repeat(60)));
let readline = rl.readline("requirement> ");
match readline {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
output.print("\n👋 Goodbye!");
break;
}
// Check for slash commands
if input.starts_with('/') {
match handle_command(
&input,
&output,
&accumulated_requirements,
&cli,
&combined_content,
&workspace_dir,
)
.await?
{
CommandResult::Continue => continue,
CommandResult::Exit => break,
CommandResult::Unknown => {
output.print(&format!(
"❌ Unknown command: {}. Type /help for available commands.",
input
));
continue;
}
}
}
// Add to history
rl.add_history_entry(&input)?;
// Add this requirement to accumulated list
turn_number += 1;
accumulated_requirements.push(format!("{}. {}", turn_number, input));
// Build the complete requirements document
let requirements_doc = format!(
"# Project Requirements\n\n\
## Current Instructions and Requirements:\n\n\
{}\n\n\
## Latest Requirement (Turn {}):\n\n\
{}",
accumulated_requirements.join("\n"),
turn_number,
input
);
output.print("");
output.print(&format!(
"📋 Current instructions and requirements (Turn {}):",
turn_number
));
output.print(&format!(" {}", input));
output.print("");
output.print("🚀 Starting autonomous implementation...");
output.print("");
// Create a project with the accumulated requirements
let project = Project::new_autonomous_with_requirements(
workspace_dir.clone(),
requirements_doc.clone(),
)?;
// Ensure workspace exists and enter it
project.ensure_workspace_exists()?;
project.enter_workspace()?;
// Load configuration with CLI overrides
let config = load_config_with_cli_overrides(&cli)?;
// Create agent for this autonomous run
let ui_writer = ConsoleUiWriter::new();
ui_writer.set_workspace_path(workspace_dir.clone());
let agent = Agent::new_autonomous_with_readme_and_quiet(
config.clone(),
ui_writer,
combined_content.clone(),
cli.quiet,
)
.await?;
// Run autonomous mode with the accumulated requirements
let autonomous_result = tokio::select! {
result = run_autonomous(
agent,
project,
cli.show_prompt,
cli.show_code,
cli.max_turns,
cli.quiet,
cli.codebase_fast_start.clone(),
) => result.map(Some),
_ = tokio::signal::ctrl_c() => {
output.print("\n⚠️ Autonomous run cancelled by user (Ctrl+C)");
Ok(None)
}
};
match autonomous_result {
Ok(Some(_returned_agent)) => {
output.print("");
use crate::g3_status::G3Status;
G3Status::progress("autonomous run");
G3Status::done();
}
Ok(None) => {
output.print(" (session continuation not saved due to cancellation)");
}
Err(e) => {
output.print("");
output.print(&format!("❌ Autonomous run failed: {}", e));
output.print(" You can provide more requirements to continue.");
}
}
}
Err(ReadlineError::Interrupted) => {
output.print("\n👋 Interrupted. Goodbye!");
break;
}
Err(ReadlineError::Eof) => {
output.print("\n👋 Goodbye!");
break;
}
Err(err) => {
error!("Error: {:?}", err);
break;
}
}
}
// Save history before exiting
if let Some(ref history_path) = history_file {
let _ = rl.save_history(history_path);
}
Ok(())
}
enum CommandResult {
Continue,
Exit,
Unknown,
}
async fn handle_command(
input: &str,
output: &SimpleOutput,
accumulated_requirements: &[String],
cli: &Cli,
combined_content: &Option<String>,
workspace_dir: &PathBuf,
) -> Result<CommandResult> {
match input {
"/help" => {
output.print("");
output.print("📖 Available Commands:");
output.print(" /requirements - Show all accumulated requirements");
output.print(" /chat - Switch to interactive chat mode");
output.print(" /help - Show this help message");
output.print(" exit/quit - Exit the session");
output.print("");
Ok(CommandResult::Continue)
}
"/requirements" => {
output.print("");
if accumulated_requirements.is_empty() {
output.print("📋 No requirements accumulated yet");
} else {
output.print("📋 Accumulated Requirements:");
output.print("");
for req in accumulated_requirements {
output.print(&format!(" {}", req));
}
}
output.print("");
Ok(CommandResult::Continue)
}
"/chat" => {
output.print("");
output.print("🔄 Switching to interactive chat mode...");
output.print("");
// Build context message with accumulated requirements
let requirements_context = if accumulated_requirements.is_empty() {
None
} else {
Some(format!(
"📋 Context from Accumulative Mode:\n\n\
We were working on these requirements. There may be unstaged or in-progress changes or recent changes to this branch. This is for your information.\n\n\
Requirements:\n{}\n",
accumulated_requirements.join("\n")
))
};
// Combine with existing content (README/AGENTS.md)
let chat_combined_content = match (requirements_context, combined_content.clone()) {
(Some(req_ctx), Some(existing)) => Some(format!("{}\n\n{}", req_ctx, existing)),
(Some(req_ctx), None) => Some(req_ctx),
(None, existing) => existing,
};
// Load configuration
let config = load_config_with_cli_overrides(cli)?;
// Create agent for interactive mode with requirements context
let ui_writer = ConsoleUiWriter::new();
ui_writer.set_workspace_path(workspace_dir.clone());
let agent = Agent::new_with_readme_and_quiet(
config,
ui_writer,
chat_combined_content.clone(),
cli.quiet,
)
.await?;
// Run interactive mode
run_interactive(
agent,
cli.show_prompt,
cli.show_code,
chat_combined_content,
workspace_dir,
cli.new_session,
None, // agent_name (not in agent mode)
)
.await?;
// After returning from interactive mode, exit
output.print("\n👋 Goodbye!");
Ok(CommandResult::Exit)
}
_ => Ok(CommandResult::Unknown),
}
}

View File

@@ -1,282 +0,0 @@
//! Agent mode for G3 CLI - runs specialized agents with custom prompts.
use anyhow::Result;
use std::path::PathBuf;
use tracing::debug;
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use crate::project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory, read_project_readme};
use crate::display::{LoadedContent, print_loaded_status, print_workspace_path};
use crate::language_prompts::{get_language_prompts_for_workspace, get_agent_language_prompts_for_workspace_with_langs};
use crate::simple_output::SimpleOutput;
use crate::embedded_agents::load_agent_prompt;
use crate::ui_writer_impl::ConsoleUiWriter;
use crate::interactive::run_interactive;
use crate::template::process_template;
/// Run agent mode - loads a specialized agent prompt and executes a single task.
pub async fn run_agent_mode(
agent_name: &str,
workspace: Option<PathBuf>,
config_path: Option<&str>,
_quiet: bool,
new_session: bool,
task: Option<String>,
chrome_headless: bool,
safari: bool,
chat: bool,
include_prompt_path: Option<PathBuf>,
no_auto_memory: bool,
acd_enabled: bool,
) -> Result<()> {
use g3_core::find_incomplete_agent_session;
use g3_core::get_agent_system_prompt;
// Set process title to agent name (shows in ps, Activity Monitor, etc.)
proctitle::set_title(format!("g3 [{}]", agent_name));
let output = SimpleOutput::new();
// Determine workspace directory (current dir if not specified)
let workspace_dir = workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
// Change to the workspace directory first so session scanning works correctly
std::env::set_current_dir(&workspace_dir)?;
// Check for incomplete agent sessions before starting a new one
// Skip session resume entirely when in chat mode (--agent --chat)
let resuming_session = if chat {
None // Chat mode always starts fresh
} else if new_session {
if !chat {
output.print("\n🆕 Starting new session (--new-session flag set)");
output.print("");
}
None
} else {
find_incomplete_agent_session(agent_name).ok().flatten()
};
// Only show session resume info when not in chat mode
if !chat {
if let Some(ref incomplete_session) = resuming_session {
output.print(&format!(
"\n🔄 Found incomplete session for agent '{}'",
agent_name
));
output.print(&format!(" Session: {}", incomplete_session.session_id));
output.print(&format!(" Created: {}", incomplete_session.created_at));
if let Some(ref todo) = incomplete_session.todo_snapshot {
// Show first few lines of TODO
let preview: String = todo.lines().take(5).collect::<Vec<_>>().join("\n");
output.print(&format!(" TODO preview:\n{}", preview));
}
output.print("");
output.print(" Resuming incomplete session...");
output.print("");
}
}
// Load agent prompt: workspace agents/<name>.md first, then embedded fallback
let (agent_prompt, from_disk) = load_agent_prompt(agent_name, &workspace_dir).ok_or_else(|| {
anyhow::anyhow!(
"Agent '{}' not found.\nAvailable embedded agents: breaker, carmack, euler, fowler, hopper, lamport, scout\nOr create agents/{}.md in your workspace.",
agent_name,
agent_name
)
})?;
let source = if from_disk { "workspace" } else { "embedded" };
// Only print verbose header when not in chat mode
if !chat {
output.print(&format!(">> agent mode | {} ({})", agent_name, source));
}
// Always print workspace path (it's part of minimal output)
print_workspace_path(&workspace_dir);
// Load config
let mut config = g3_config::Config::load(config_path)?;
// Apply chrome-headless flag override
if chrome_headless {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::ChromeHeadless;
}
// Apply safari flag override
if safari {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::Safari;
}
// Generate the combined system prompt (agent prompt + tool instructions)
// Note: allow_multiple_tool_calls parameter is deprecated but kept for API compatibility
let system_prompt = get_agent_system_prompt(&agent_prompt, true);
// Load AGENTS.md, README, and memory - same as normal mode
let agents_content_opt = read_agents_config(&workspace_dir);
let readme_content_opt = read_project_readme(&workspace_dir);
let memory_content_opt = read_workspace_memory(&workspace_dir);
// Read include prompt early so we can show it in the status line
let include_prompt = read_include_prompt(include_prompt_path.as_deref());
// Build and print status line showing what was loaded
let include_filename = include_prompt_path.as_ref()
.filter(|_| include_prompt.is_some())
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string());
let loaded = LoadedContent::new(
readme_content_opt.is_some(),
agents_content_opt.is_some(),
memory_content_opt.is_some(),
include_filename,
);
print_loaded_status(&loaded);
// Get language-specific prompts (same mechanism as normal mode)
let language_content = get_language_prompts_for_workspace(&workspace_dir);
// Get agent+language-specific prompts (e.g., carmack.racket.md) and show which languages
let detected_langs = crate::language_prompts::detect_languages(&workspace_dir);
let agent_lang_content = if detected_langs.is_empty() {
None
} else {
let (content, matched_langs) = get_agent_language_prompts_for_workspace_with_langs(&workspace_dir, agent_name);
// Only print language guidance info when not in chat mode
if !chat {
for lang in matched_langs {
output.print(&format!("{}: {} language guidance", agent_name, lang));
}
}
content
};
// Append agent+language-specific content to system prompt if available
let system_prompt = if let Some(agent_lang) = agent_lang_content {
format!("{}\n\n{}", system_prompt, agent_lang)
} else {
system_prompt
};
// Combine all content for the agent's context
let combined_content = combine_project_content(
agents_content_opt,
readme_content_opt,
memory_content_opt,
language_content,
include_prompt,
&workspace_dir,
);
// Create agent with custom system prompt
let ui_writer = ConsoleUiWriter::new();
// Set agent mode on UI writer for visual differentiation (light gray tool names)
ui_writer.set_agent_mode(true);
ui_writer.set_workspace_path(workspace_dir.clone());
let mut agent =
Agent::new_with_custom_prompt(config, ui_writer, system_prompt, combined_content.clone()).await?;
// Set agent mode for session tracking
agent.set_agent_mode(agent_name);
// Auto-memory is enabled by default in agent mode (unless --no-auto-memory is set)
// This prompts the LLM to save discoveries to workspace memory after each turn
agent.set_auto_memory(!no_auto_memory);
// Enable ACD (Aggressive Context Dehydration) if requested
if acd_enabled {
agent.set_acd_enabled(true);
}
// If resuming a session, restore context and TODO
let initial_task = if let Some(ref incomplete_session) = resuming_session {
// Restore the session context
match agent.restore_from_continuation(incomplete_session) {
Ok(full_restore) => {
if full_restore {
output.print(" ✅ Full context restored from previous session");
} else {
output.print(" ⚠️ Restored from summary (context was > 80%)");
}
}
Err(e) => {
output.print(&format!(" ⚠️ Could not restore context: {}", e));
}
}
// Copy TODO from old session to new session directory
let todo_content = if let Some(ref content) = incomplete_session.todo_snapshot {
Some(content.clone())
} else {
// Fallback: read from the actual todo.g3.md file in the old session directory
let old_session_dir =
std::path::Path::new(".g3/sessions").join(&incomplete_session.session_id);
let old_todo_path = old_session_dir.join("todo.g3.md");
if old_todo_path.exists() {
std::fs::read_to_string(&old_todo_path).ok()
} else {
None
}
};
if let Some(ref content) = todo_content {
if let Some(session_id) = agent.get_session_id() {
let new_todo_path = g3_core::paths::get_session_todo_path(session_id);
let _ = g3_core::paths::ensure_session_dir(session_id);
if let Err(e) = std::fs::write(&new_todo_path, content) {
output.print(&format!(" ⚠️ Could not restore TODO: {}", e));
} else {
output.print(" ✅ TODO list restored");
}
}
}
output.print("");
// Resume message instead of fresh start
"Continue working on the incomplete tasks. Use todo_read to see the current TODO list and resume from where you left off."
} else {
// Fresh start - the agent prompt should contain instructions to start working immediately
"Begin your analysis and work on the current project. Follow your mission and workflow as specified in your instructions."
};
// Use provided task if available, otherwise use the default initial_task
let task_str = task.as_deref().unwrap_or(initial_task);
let final_task = process_template(task_str);
// If chat mode is enabled, run interactive loop instead of single task
if chat {
return run_interactive(
agent,
false, // show_prompt
false, // show_code
combined_content,
&workspace_dir,
new_session,
Some(agent_name), // agent name for prompt (e.g., "butler>")
)
.await;
}
// Single-shot mode: execute the task and exit
let _result = agent.execute_task(&final_task, None, true).await?;
// Send auto-memory reminder if enabled and tools were called
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
// Don't print completion message for scout agent - it needs the last line
// to be the report file path for the research tool to read
if agent_name != "scout" {
use crate::g3_status::G3Status;
println!(); // newline before status
G3Status::progress(&format!("{} session", agent_name));
G3Status::done();
}
Ok(())
}

View File

@@ -1,735 +0,0 @@
//! Autonomous mode for G3 CLI - coach-player feedback loop.
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::Instant;
use tracing::debug;
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
use g3_core::project::Project;
use g3_core::{Agent, DiscoveryOptions};
use crate::coach_feedback;
use crate::metrics::{format_elapsed_time, generate_turn_histogram, TurnMetrics};
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
use g3_core::ui_writer::UiWriter;
/// Run autonomous mode with coach-player feedback loop (console output).
pub async fn run_autonomous(
mut agent: Agent<ConsoleUiWriter>,
project: Project,
show_prompt: bool,
show_code: bool,
max_turns: usize,
quiet: bool,
codebase_fast_start: Option<PathBuf>,
) -> Result<Agent<ConsoleUiWriter>> {
let start_time = std::time::Instant::now();
let output = SimpleOutput::new();
let mut turn_metrics: Vec<TurnMetrics> = Vec::new();
output.print("g3 programming agent - autonomous mode");
output.print(&format!(
"📁 Using workspace: {}",
project.workspace().display()
));
// Check if requirements exist
if !project.has_requirements() {
print_no_requirements_error(&output, &agent, &turn_metrics, start_time, max_turns);
return Ok(agent);
}
// Read requirements
let requirements = match project.read_requirements()? {
Some(content) => content,
None => {
print_cannot_read_requirements_error(
&output,
&agent,
&turn_metrics,
start_time,
max_turns,
);
return Ok(agent);
}
};
// Display appropriate message based on requirements source
if project.requirements_text.is_some() {
output.print("📋 Requirements loaded from --requirements flag");
} else {
output.print("📋 Requirements loaded from requirements.md");
}
// Calculate SHA256 of requirements
let mut hasher = Sha256::new();
hasher.update(requirements.as_bytes());
let requirements_sha = hex::encode(hasher.finalize());
output.print(&format!("🔒 Requirements SHA256: {}", requirements_sha));
// Pass SHA to agent for staleness checking
agent.set_requirements_sha(requirements_sha.clone());
let loop_start = Instant::now();
output.print("🔄 Starting coach-player feedback loop...");
// Load fast-discovery messages before the loop starts (if enabled)
let (discovery_messages, discovery_working_dir) =
load_discovery_messages(&agent, &output, &codebase_fast_start, &requirements).await;
let has_discovery = !discovery_messages.is_empty();
let mut turn = 1;
let mut coach_feedback_text = String::new();
let mut implementation_approved = false;
loop {
let turn_start_time = Instant::now();
let turn_start_tokens = agent.get_context_window().used_tokens;
output.print(&format!(
"\n=== TURN {}/{} - PLAYER MODE ===",
turn, max_turns
));
// Surface provider info for player agent
agent.print_provider_banner("Player");
// Player mode: implement requirements (with coach feedback if available)
let player_prompt = build_player_prompt(&requirements, &requirements_sha, &coach_feedback_text);
output.print(&format!(
"🎯 Starting player implementation... (elapsed: {})",
format_elapsed_time(loop_start.elapsed())
));
// Display what feedback the player is receiving
if coach_feedback_text.is_empty() {
if turn > 1 {
return Err(anyhow::anyhow!(
"Player mode error: No coach feedback received on turn {}",
turn
));
}
output.print("📋 Player starting initial implementation (no prior coach feedback)");
} else {
output.print(&format!(
"📋 Player received coach feedback ({} chars):",
coach_feedback_text.len()
));
output.print(&coach_feedback_text);
}
output.print(""); // Empty line for readability
// Execute player task with retry on error
let player_result = execute_player_turn(
&mut agent,
&player_prompt,
show_prompt,
show_code,
&output,
has_discovery,
&discovery_messages,
discovery_working_dir.as_deref(),
turn,
&turn_metrics,
start_time,
max_turns,
)
.await;
let player_failed = match player_result {
PlayerTurnResult::Success => false,
PlayerTurnResult::Failed => true,
PlayerTurnResult::Panic(e) => return Err(e),
};
// If player failed after max retries, increment turn and continue
if player_failed {
output.print(&format!(
"⚠️ Player turn {} failed after max retries. Moving to next turn.",
turn
));
record_turn_metrics(
&mut turn_metrics,
turn,
turn_start_time,
turn_start_tokens,
&agent,
);
turn += 1;
if turn > max_turns {
output.print("\n=== SESSION COMPLETED - MAX TURNS REACHED ===");
output.print(&format!("⏰ Maximum turns ({}) reached", max_turns));
break;
}
coach_feedback_text = String::new();
continue;
}
// Give some time for file operations to complete
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Execute coach turn
let coach_result = execute_coach_turn(
&agent,
&project,
&requirements,
show_prompt,
show_code,
quiet,
&output,
has_discovery,
&discovery_messages,
discovery_working_dir.as_deref(),
turn,
max_turns,
&turn_metrics,
start_time,
loop_start,
)
.await;
match coach_result {
CoachTurnResult::Approved => {
output.print("\n=== SESSION COMPLETED - IMPLEMENTATION APPROVED ===");
output.print("✅ Coach approved the implementation!");
implementation_approved = true;
break;
}
CoachTurnResult::Feedback(feedback) => {
output.print_smart(&format!("Coach feedback:\n{}", feedback));
coach_feedback_text = feedback;
}
CoachTurnResult::Failed => {
output.print(&format!(
"⚠️ Coach turn {} failed after max retries. Using default feedback.",
turn
));
coach_feedback_text = "The implementation needs review. Please ensure all requirements are met and the code compiles without errors.".to_string();
}
CoachTurnResult::Panic(e) => return Err(e),
}
// Check if we've reached max turns
if turn >= max_turns {
output.print("\n=== SESSION COMPLETED - MAX TURNS REACHED ===");
output.print(&format!("⏰ Maximum turns ({}) reached", max_turns));
break;
}
record_turn_metrics(
&mut turn_metrics,
turn,
turn_start_time,
turn_start_tokens,
&agent,
);
turn += 1;
output.print("🔄 Coach provided feedback for next iteration");
}
// Generate final report
print_final_report(
&output,
&agent,
&turn_metrics,
start_time,
turn,
max_turns,
implementation_approved,
);
if implementation_approved {
output.print(&format!(
"\n🎉 Autonomous mode completed successfully (total loop time: {})",
format_elapsed_time(loop_start.elapsed())
));
} else {
output.print(&format!(
"\n🔄 Autonomous mode terminated (max iterations) (total loop time: {})",
format_elapsed_time(loop_start.elapsed())
));
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
Ok(agent)
}
// --- Helper types and functions ---
enum PlayerTurnResult {
Success,
Failed,
Panic(anyhow::Error),
}
enum CoachTurnResult {
Approved,
Feedback(String),
Failed,
Panic(anyhow::Error),
}
fn build_player_prompt(requirements: &str, requirements_sha: &str, coach_feedback: &str) -> String {
if coach_feedback.is_empty() {
format!(
"You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nRequirements SHA256: {}\n\nImplement this step by step, creating all necessary files and code.",
requirements, requirements_sha
)
} else {
format!(
"You are G3 in implementation mode. Address the following specific feedback from the coach:\n\n{}\n\nContext: You are improving an implementation based on these requirements:\n{}\n\nFocus on fixing the issues mentioned in the coach feedback above.",
coach_feedback, requirements
)
}
}
fn build_coach_prompt(requirements: &str) -> String {
format!(
"You are G3 in coach mode. Your role is to critique and review implementations against requirements and provide concise, actionable feedback.
REQUIREMENTS:
{}
IMPLEMENTATION REVIEW:
Review the current state of the project and provide a concise critique focusing on:
1. Whether the requirements are correctly implemented
2. Whether the project compiles successfully
3. What requirements are missing or incorrect
4. Specific improvements needed to satisfy requirements
5. Use UI tools such as webdriver to test functionality thoroughly
CRITICAL INSTRUCTIONS:
1. Provide your feedback as your final response message
2. Your feedback should be CONCISE and ACTIONABLE
3. Focus ONLY on what needs to be fixed or improved
4. Do NOT include your analysis process, file contents, or compilation output in your final feedback
If the implementation thoroughly meets all requirements, compiles and is fully tested (especially UI flows) *WITHOUT* minor gaps or errors:
- Respond with: 'IMPLEMENTATION_APPROVED'
If improvements are needed:
- Respond with a brief summary listing ONLY the specific issues to fix
Remember: Be clear in your review and concise in your feedback. APPROVE iff the implementation works and thoroughly fits the requirements (implementation > 95% complete). Be rigorous, especially by testing that all UI features work.",
requirements
)
}
async fn load_discovery_messages(
agent: &Agent<ConsoleUiWriter>,
output: &SimpleOutput,
codebase_fast_start: &Option<PathBuf>,
requirements: &str,
) -> (Vec<g3_providers::Message>, Option<String>) {
if let Some(ref codebase_path) = codebase_fast_start {
let canonical_path = codebase_path
.canonicalize()
.unwrap_or_else(|_| codebase_path.clone());
let path_str = canonical_path.to_string_lossy();
output.print(&format!(
"🔍 Fast-discovery mode: will explore codebase at {}",
path_str
));
match agent.get_provider() {
Ok(provider) => {
let output_clone = output.clone();
let status_callback: g3_planner::StatusCallback = Box::new(move |msg: &str| {
output_clone.print(msg);
});
match g3_planner::get_initial_discovery_messages(
&path_str,
Some(requirements),
provider,
Some(&status_callback),
)
.await
{
Ok(messages) => (messages, Some(path_str.to_string())),
Err(e) => {
output.print(&format!(
"⚠️ LLM discovery failed: {}, skipping fast-start",
e
));
(Vec::new(), None)
}
}
}
Err(e) => {
output.print(&format!(
"⚠️ Could not get provider: {}, skipping fast-start",
e
));
(Vec::new(), None)
}
}
} else {
(Vec::new(), None)
}
}
async fn execute_player_turn(
agent: &mut Agent<ConsoleUiWriter>,
player_prompt: &str,
show_prompt: bool,
show_code: bool,
output: &SimpleOutput,
has_discovery: bool,
discovery_messages: &[g3_providers::Message],
discovery_working_dir: Option<&str>,
turn: usize,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) -> PlayerTurnResult {
const MAX_PLAYER_RETRIES: u32 = 3;
let mut retry_count = 0;
loop {
let discovery_opts = if has_discovery {
Some(DiscoveryOptions {
messages: discovery_messages,
fast_start_path: discovery_working_dir,
})
} else {
None
};
match agent
.execute_task_with_timing(
player_prompt,
None,
false,
show_prompt,
show_code,
true,
discovery_opts,
)
.await
{
Ok(result) => {
output.print("📝 Player implementation completed:");
// Only print response if it's not empty (streaming already displayed it)
if !result.response.trim().is_empty() {
output.print_smart(&result.response);
}
return PlayerTurnResult::Success;
}
Err(e) => {
let error_type = classify_error(&e);
if matches!(
error_type,
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
) {
output.print(&format!("⚠️ Context length exceeded in player turn: {}", e));
output.print("📝 Logging error to session and ending current turn...");
let forensic_context = format!(
"Turn: {}\nRole: Player\nContext tokens: {}\nTotal available: {}\nPercentage used: {:.1}%\nPrompt length: {} chars\nError occurred at: {}",
turn,
agent.get_context_window().used_tokens,
agent.get_context_window().total_tokens,
agent.get_context_window().percentage_used(),
player_prompt.len(),
chrono::Utc::now().to_rfc3339()
);
agent.log_error_to_session(&e, "assistant", Some(forensic_context));
return PlayerTurnResult::Failed;
} else if e.to_string().contains("panic") {
output.print(&format!("💥 Player panic detected: {}", e));
print_panic_report(output, agent, turn_metrics, start_time, turn, max_turns, "PLAYER PANIC");
return PlayerTurnResult::Panic(e);
}
retry_count += 1;
output.print(&format!(
"⚠️ Player error (attempt {}/{}): {}",
retry_count, MAX_PLAYER_RETRIES, e
));
if retry_count >= MAX_PLAYER_RETRIES {
output.print("🔄 Max retries reached for player, marking turn as failed...");
return PlayerTurnResult::Failed;
}
output.print("🔄 Retrying player implementation...");
}
}
}
}
async fn execute_coach_turn(
player_agent: &Agent<ConsoleUiWriter>,
project: &Project,
requirements: &str,
show_prompt: bool,
show_code: bool,
quiet: bool,
output: &SimpleOutput,
has_discovery: bool,
discovery_messages: &[g3_providers::Message],
discovery_working_dir: Option<&str>,
turn: usize,
max_turns: usize,
turn_metrics: &[TurnMetrics],
start_time: Instant,
loop_start: Instant,
) -> CoachTurnResult {
const MAX_COACH_RETRIES: u32 = 3;
// Create a new agent instance for coach mode to ensure fresh context
let base_config = player_agent.get_config().clone();
let coach_config = match base_config.for_coach() {
Ok(c) => c,
Err(e) => return CoachTurnResult::Panic(e),
};
// Reset filter suppression state before creating coach agent
crate::filter_json::reset_json_tool_state();
let ui_writer = ConsoleUiWriter::new();
ui_writer.set_workspace_path(project.workspace().to_path_buf());
let mut coach_agent =
match Agent::new_autonomous_with_readme_and_quiet(coach_config, ui_writer, None, quiet)
.await
{
Ok(a) => a,
Err(e) => return CoachTurnResult::Panic(e),
};
coach_agent.print_provider_banner("Coach");
if let Err(e) = project.enter_workspace() {
return CoachTurnResult::Panic(e);
}
output.print(&format!(
"\n=== TURN {}/{} - COACH MODE ===",
turn, max_turns
));
let coach_prompt = build_coach_prompt(requirements);
output.print(&format!(
"🎓 Starting coach review... (elapsed: {})",
format_elapsed_time(loop_start.elapsed())
));
let mut retry_count = 0;
loop {
let discovery_opts = if has_discovery {
Some(DiscoveryOptions {
messages: discovery_messages,
fast_start_path: discovery_working_dir,
})
} else {
None
};
match coach_agent
.execute_task_with_timing(
&coach_prompt,
None,
false,
show_prompt,
show_code,
true,
discovery_opts,
)
.await
{
Ok(result) => {
output.print("🎓 Coach review completed");
let feedback_text =
match coach_feedback::extract_from_logs(&result, &coach_agent, output) {
Ok(f) => f,
Err(e) => return CoachTurnResult::Panic(e),
};
debug!(
"Coach feedback extracted: {} characters (from {} total)",
feedback_text.len(),
result.response.len()
);
if feedback_text.is_empty() {
output.print("⚠️ Coach did not provide feedback. This may be a model issue.");
return CoachTurnResult::Failed;
}
if result.is_approved() || feedback_text.contains("IMPLEMENTATION_APPROVED") {
return CoachTurnResult::Approved;
}
return CoachTurnResult::Feedback(feedback_text);
}
Err(e) => {
let error_type = classify_error(&e);
if matches!(
error_type,
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
) {
output.print(&format!("⚠️ Context length exceeded in coach turn: {}", e));
output.print("📝 Logging error to session and ending current turn...");
let forensic_context = format!(
"Turn: {}\nRole: Coach\nContext tokens: {}\nTotal available: {}\nPercentage used: {:.1}%\nPrompt length: {} chars\nError occurred at: {}",
turn,
coach_agent.get_context_window().used_tokens,
coach_agent.get_context_window().total_tokens,
coach_agent.get_context_window().percentage_used(),
coach_prompt.len(),
chrono::Utc::now().to_rfc3339()
);
coach_agent.log_error_to_session(&e, "assistant", Some(forensic_context));
return CoachTurnResult::Failed;
} else if e.to_string().contains("panic") {
output.print(&format!("💥 Coach panic detected: {}", e));
print_panic_report(output, player_agent, turn_metrics, start_time, turn, max_turns, "COACH PANIC");
return CoachTurnResult::Panic(e);
}
retry_count += 1;
output.print(&format!(
"⚠️ Coach error (attempt {}/{}): {}",
retry_count, MAX_COACH_RETRIES, e
));
if retry_count >= MAX_COACH_RETRIES {
output.print("🔄 Max retries reached for coach, using default feedback...");
return CoachTurnResult::Failed;
}
output.print("🔄 Retrying coach review...");
}
}
}
}
fn record_turn_metrics(
turn_metrics: &mut Vec<TurnMetrics>,
turn: usize,
turn_start_time: Instant,
turn_start_tokens: u32,
agent: &Agent<ConsoleUiWriter>,
) {
let turn_duration = turn_start_time.elapsed();
let turn_tokens = agent
.get_context_window()
.used_tokens
.saturating_sub(turn_start_tokens);
turn_metrics.push(TurnMetrics {
turn_number: turn,
tokens_used: turn_tokens,
wall_clock_time: turn_duration,
});
}
fn print_no_requirements_error(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) {
output.print("❌ Error: requirements.md not found in workspace directory");
output.print(" Please either:");
output.print(" 1. Create a requirements.md file with your project requirements");
output.print(" 2. Or use the --requirements flag to provide requirements text directly:");
output.print(" g3 --autonomous --requirements \"Your requirements here\"");
output.print("");
print_final_report(output, agent, turn_metrics, start_time, 0, max_turns, false);
}
fn print_cannot_read_requirements_error(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) {
output.print("❌ Error: Could not read requirements (neither --requirements flag nor requirements.md file provided)");
print_final_report(output, agent, turn_metrics, start_time, 0, max_turns, false);
}
fn print_panic_report(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
turn: usize,
max_turns: usize,
status: &str,
) {
let elapsed = start_time.elapsed();
let context_window = agent.get_context_window();
output.print(&format!("\n{}", "=".repeat(60)));
output.print("📊 AUTONOMOUS MODE SESSION REPORT");
output.print(&"=".repeat(60));
output.print(&format!("⏱️ Total Duration: {:.2}s", elapsed.as_secs_f64()));
output.print(&format!("🔄 Turns Taken: {}/{}", turn, max_turns));
output.print(&format!("📝 Final Status: 💥 {}", status));
output.print("\n📈 Token Usage Statistics:");
output.print(&format!(" • Used Tokens: {}", context_window.used_tokens));
output.print(&format!(" • Total Available: {}", context_window.total_tokens));
output.print(&format!(" • Cumulative Tokens: {}", context_window.cumulative_tokens));
output.print(&format!(" • Usage Percentage: {:.1}%", context_window.percentage_used()));
output.print(&generate_turn_histogram(turn_metrics));
output.print(&"=".repeat(60));
}
fn print_final_report(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
turn: usize,
max_turns: usize,
implementation_approved: bool,
) {
let elapsed = start_time.elapsed();
let context_window = agent.get_context_window();
output.print(&format!("\n{}", "=".repeat(60)));
output.print("📊 AUTONOMOUS MODE SESSION REPORT");
output.print(&"=".repeat(60));
output.print(&format!("⏱️ Total Duration: {:.2}s", elapsed.as_secs_f64()));
output.print(&format!("🔄 Turns Taken: {}/{}", turn, max_turns));
output.print(&format!(
"📝 Final Status: {}",
if implementation_approved {
"✅ APPROVED"
} else if turn >= max_turns {
"⏰ MAX TURNS REACHED"
} else {
"⚠️ INCOMPLETE"
}
));
output.print("\n📈 Token Usage Statistics:");
output.print(&format!(" • Used Tokens: {}", context_window.used_tokens));
output.print(&format!(" • Total Available: {}", context_window.total_tokens));
output.print(&format!(" • Cumulative Tokens: {}", context_window.cumulative_tokens));
output.print(&format!(" • Usage Percentage: {:.1}%", context_window.percentage_used()));
output.print(&generate_turn_histogram(turn_metrics));
output.print(&"=".repeat(60));
}

View File

@@ -1,125 +0,0 @@
//! CLI argument parsing for G3.
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Clone)]
#[command(name = "g3")]
#[command(about = "A modular, composable AI coding agent")]
#[command(version)]
pub struct Cli {
/// Enable verbose logging
#[arg(short, long)]
pub verbose: bool,
/// Enable manual control of context compaction (disables auto-compact at 90%)
#[arg(long = "manual-compact")]
pub manual_compact: bool,
/// Show the system prompt being sent to the LLM
#[arg(long)]
pub show_prompt: bool,
/// Show the generated code before execution
#[arg(long)]
pub show_code: bool,
/// Configuration file path
#[arg(short, long)]
pub config: Option<String>,
/// Workspace directory (defaults to current directory)
#[arg(short, long)]
pub workspace: Option<PathBuf>,
/// Task to execute (if provided, runs in single-shot mode instead of interactive)
pub task: Option<String>,
/// Enable autonomous mode with coach-player feedback loop
#[arg(long)]
pub autonomous: bool,
/// Maximum number of turns in autonomous mode (default: 5)
#[arg(long, default_value = "5")]
pub max_turns: usize,
/// Override requirements text for autonomous mode (instead of reading from requirements.md)
#[arg(long, value_name = "TEXT")]
pub requirements: Option<String>,
/// Enable accumulative autonomous mode (default is chat mode)
#[arg(long)]
pub auto: bool,
/// Enable interactive chat mode (no autonomous runs)
#[arg(long)]
pub chat: bool,
/// Override the configured provider (e.g., 'openai' or 'openai.default')
#[arg(long, value_name = "PROVIDER")]
pub provider: Option<String>,
/// Override the model for the selected provider
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Disable session log file creation (no .g3/sessions/ or error logs)
#[arg(long)]
pub quiet: bool,
/// Enable WebDriver browser automation tools
#[arg(long, default_value_t = true)]
pub webdriver: bool,
/// Use Chrome in headless mode for WebDriver (instead of Safari)
#[arg(long, default_value_t = true)]
pub chrome_headless: bool,
/// Use Safari for WebDriver (overrides the default Chrome headless)
#[arg(long)]
pub safari: bool,
/// Enable planning mode for requirements-driven development
#[arg(long, conflicts_with_all = ["autonomous", "auto", "chat"])]
pub planning: bool,
/// Path to the codebase to work on (for planning mode)
#[arg(long, value_name = "PATH")]
pub codepath: Option<String>,
/// Disable git operations in planning mode
#[arg(long)]
pub no_git: bool,
/// Enable fast codebase discovery before first LLM turn
#[arg(long, value_name = "PATH")]
pub codebase_fast_start: Option<PathBuf>,
/// Run as a specialized agent (loads prompt from agents/<name>.md)
#[arg(long, value_name = "NAME", conflicts_with_all = ["autonomous", "auto", "planning"])]
pub agent: Option<String>,
/// List all available agents (embedded and workspace)
#[arg(long)]
pub list_agents: bool,
/// Skip session resumption and force a new session (for agent mode)
#[arg(long)]
pub new_session: bool,
/// Automatically remind LLM to call remember tool after turns with tool calls
#[arg(long)]
pub auto_memory: bool,
/// Enable aggressive context dehydration (save context to disk on compaction)
#[arg(long)]
pub acd: bool,
/// Include additional prompt content from a file (appended before memory)
#[arg(long, value_name = "PATH")]
pub include_prompt: Option<PathBuf>,
/// Disable automatic memory update reminder at end of agent mode
#[arg(long)]
pub no_auto_memory: bool,
}

View File

@@ -1,124 +0,0 @@
//! Coach feedback extraction from session logs.
//!
//! Extracts feedback from the coach agent's session logs for the coach-player loop.
use anyhow::Result;
use std::path::Path;
use g3_core::Agent;
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
/// Extract coach feedback by reading from the coach agent's specific log file.
///
/// Uses the coach agent's session ID to find the exact log file.
pub fn extract_from_logs(
coach_result: &g3_core::TaskResult,
coach_agent: &Agent<ConsoleUiWriter>,
output: &SimpleOutput,
) -> Result<String> {
let session_id = coach_agent
.get_session_id()
.ok_or_else(|| anyhow::anyhow!("Coach agent has no session ID"))?;
let log_file_path = resolve_log_path(&session_id);
// Try to extract from session log
if let Some(feedback) = try_extract_from_log(&log_file_path) {
output.print(&format!("✅ Extracted coach feedback from session: {}", session_id));
return Ok(feedback);
}
// Fallback: use the TaskResult's extract_summary method
let fallback = coach_result.extract_summary();
if !fallback.is_empty() {
output.print(&format!(
"✅ Extracted coach feedback from response: {} chars",
fallback.len()
));
return Ok(fallback);
}
Err(anyhow::anyhow!(
"Could not extract coach feedback from session: {}\n\
Log file path: {:?}\n\
Log file exists: {}\n\
Coach result response length: {} chars",
session_id,
log_file_path,
log_file_path.exists(),
coach_result.response.len()
))
}
/// Resolve the log file path, trying new path first then falling back to old.
fn resolve_log_path(session_id: &str) -> std::path::PathBuf {
g3_core::get_session_file(session_id)
}
/// Extract feedback from a session log file.
///
/// Searches backwards for the last assistant message with substantial text content.
fn try_extract_from_log(log_file_path: &Path) -> Option<String> {
if !log_file_path.exists() {
return None;
}
let log_content = std::fs::read_to_string(log_file_path).ok()?;
let log_json: serde_json::Value = serde_json::from_str(&log_content).ok()?;
let messages = log_json
.get("context_window")?
.get("conversation_history")?
.as_array()?;
// Search backwards for the last assistant message with text content
for msg in messages.iter().rev() {
if let Some(feedback) = extract_assistant_text(msg) {
return Some(feedback);
}
}
None
}
/// Extract text content from an assistant message.
fn extract_assistant_text(msg: &serde_json::Value) -> Option<String> {
let role = msg.get("role").and_then(|v| v.as_str())?;
if !role.eq_ignore_ascii_case("assistant") {
return None;
}
let content = msg.get("content")?;
// Handle string content
if let Some(content_str) = content.as_str() {
return filter_substantial_text(content_str);
}
// Handle array content (native tool calling format)
if let Some(content_array) = content.as_array() {
for block in content_array {
if block.get("type").and_then(|v| v.as_str()) == Some("text") {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
if let Some(result) = filter_substantial_text(text) {
return Some(result);
}
}
}
}
}
None
}
/// Filter out empty or very short responses (likely just tool calls).
fn filter_substantial_text(text: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.is_empty() && trimmed.len() > 10 {
Some(trimmed.to_string())
} else {
None
}
}

View File

@@ -1,426 +0,0 @@
//! Interactive command handlers for G3 CLI.
//!
//! Handles `/` commands in interactive mode.
use anyhow::Result;
use rustyline::Editor;
use std::path::PathBuf;
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use crate::completion::G3Helper;
use crate::g3_status::{G3Status, Status};
use crate::simple_output::SimpleOutput;
use crate::project::Project;
use crate::template::process_template;
use crate::task_execution::execute_task_with_retry;
/// Handle a control command. Returns true if the command was handled and the loop should continue.
pub async fn handle_command<W: UiWriter>(
input: &str,
agent: &mut Agent<W>,
workspace_dir: &std::path::Path,
output: &SimpleOutput,
active_project: &mut Option<Project>,
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
show_prompt: bool,
show_code: bool,
) -> Result<bool> {
match input {
"/help" => {
output.print("");
output.print("📖 Control Commands:");
output.print(" /compact - Trigger compaction (compacts conversation history)");
output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)");
output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)");
output.print(" /clear - Clear session and start fresh (discards continuation artifacts)");
output.print(" /fragments - List dehydrated context fragments (ACD)");
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
output.print(" /resume - List and switch to a previous session");
output.print(" /project <path> - Load a project from the given absolute path");
output.print(" /unproject - Unload the current project and reset context");
output.print(" /dump - Dump entire context window to file for debugging");
output.print(" /readme - Reload README.md and AGENTS.md from disk");
output.print(" /stats - Show detailed context and performance statistics");
output.print(" /run <file> - Read file and execute as prompt");
output.print(" /help - Show this help message");
output.print(" exit/quit - Exit the interactive session");
output.print("");
Ok(true)
}
"/compact" => {
output.print_g3_progress("compacting session");
match agent.force_compact().await {
Ok(true) => {
output.print_g3_status("compacting session", "done");
}
Ok(false) => {
output.print_g3_status("compacting session", "failed");
}
Err(e) => {
output.print_g3_status("compacting session", &format!("error: {}", e));
}
}
Ok(true)
}
"/thinnify" => {
let result = agent.force_thin();
G3Status::thin_result(&result);
Ok(true)
}
"/skinnify" => {
let result = agent.force_thin_all();
G3Status::thin_result(&result);
Ok(true)
}
"/fragments" => {
if let Some(session_id) = agent.get_session_id() {
match g3_core::acd::list_fragments(session_id) {
Ok(fragments) => {
if fragments.is_empty() {
output.print("No dehydrated fragments found for this session.");
} else {
output.print(&format!(
"📦 {} dehydrated fragment(s):\n",
fragments.len()
));
for fragment in &fragments {
output.print(&fragment.generate_stub());
output.print("");
}
}
}
Err(e) => {
output.print(&format!("❌ Error listing fragments: {}", e));
}
}
} else {
output.print("No active session - fragments are session-scoped.");
}
Ok(true)
}
cmd if cmd.starts_with("/rehydrate") => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {
output.print("Usage: /rehydrate <fragment_id>");
output.print("Use /fragments to list available fragment IDs.");
} else {
let fragment_id = parts[1].trim();
if let Some(session_id) = agent.get_session_id() {
match g3_core::acd::Fragment::load(session_id, fragment_id) {
Ok(fragment) => {
output.print(&format!(
"✅ Fragment '{}' loaded ({} messages, ~{} tokens)",
fragment_id, fragment.message_count, fragment.estimated_tokens
));
output.print("");
output.print(&fragment.generate_stub());
}
Err(e) => {
output.print(&format!(
"❌ Failed to load fragment '{}': {}",
fragment_id, e
));
}
}
} else {
output.print("No active session - fragments are session-scoped.");
}
}
Ok(true)
}
cmd if cmd.starts_with("/run") => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {
output.print("Usage: /run <file-path>");
output.print("Reads the file and executes its content as a prompt.");
} else {
let file_path = parts[1].trim();
// Expand tilde
let expanded_path = if file_path.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
home.join(&file_path[2..])
} else {
std::path::PathBuf::from(file_path)
}
} else {
std::path::PathBuf::from(file_path)
};
match std::fs::read_to_string(&expanded_path) {
Ok(content) => {
let processed = process_template(&content);
let prompt = processed.trim();
if prompt.is_empty() {
output.print("❌ File is empty.");
} else {
G3Status::progress(&format!("loading {}", file_path));
G3Status::done();
let completed = execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await;
if !completed {
return Ok(false);
}
}
}
Err(e) => {
output.print(&format!("❌ Failed to read file '{}': {}", file_path, e));
}
}
}
Ok(true)
}
"/dump" => {
// Dump entire context window to a file for debugging
let dump_dir = std::path::Path::new("tmp");
if !dump_dir.exists() {
if let Err(e) = std::fs::create_dir_all(dump_dir) {
output.print(&format!("❌ Failed to create tmp directory: {}", e));
return Ok(true);
}
}
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp));
let context = agent.get_context_window();
let mut dump_content = String::new();
dump_content.push_str("# Context Window Dump\n");
dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now()));
dump_content.push_str(&format!(
"# Messages: {}\n",
context.conversation_history.len()
));
dump_content.push_str(&format!(
"# Used tokens: {} / {} ({:.1}%)\n\n",
context.used_tokens,
context.total_tokens,
context.percentage_used()
));
for (i, msg) in context.conversation_history.iter().enumerate() {
dump_content.push_str(&format!("=== Message {} ===\n", i));
dump_content.push_str(&format!("Role: {:?}\n", msg.role));
dump_content.push_str(&format!("Kind: {:?}\n", msg.kind));
dump_content.push_str(&format!("Content ({} chars):\n", msg.content.len()));
dump_content.push_str(&msg.content);
dump_content.push_str("\n\n");
}
match std::fs::write(&dump_path, &dump_content) {
Ok(_) => {
G3Status::complete_with_path(
"context dumped to",
&dump_path.display().to_string(),
Status::Done,
);
}
Err(e) => output.print(&format!("❌ Failed to write dump: {}", e)),
}
Ok(true)
}
"/clear" => {
use crate::g3_status::G3Status;
G3Status::progress("clearing session");
agent.clear_session();
G3Status::done();
output.print("Starting fresh.");
Ok(true)
}
"/readme" => {
use crate::g3_status::G3Status;
G3Status::progress("reloading README");
match agent.reload_readme() {
Ok(true) => {
G3Status::done();
}
Ok(false) => {
G3Status::failed();
output.print("No README was loaded at startup, cannot reload");
}
Err(e) => {
G3Status::error(&e.to_string());
}
}
Ok(true)
}
"/stats" => {
let stats = agent.get_stats();
output.print(&stats);
Ok(true)
}
"/resume" => {
output.print("📋 Scanning for available sessions...");
match g3_core::list_sessions_for_directory() {
Ok(sessions) => {
if sessions.is_empty() {
output.print("No sessions found for this directory.");
return Ok(true);
}
// Get current session ID to mark it
let current_session_id = agent.get_session_id().map(|s| s.to_string());
output.print("");
output.print("Available sessions:");
for (i, session) in sessions.iter().enumerate() {
let time_str = g3_core::format_session_time(&session.created_at);
let context_str = format!("{:.0}%", session.context_percentage);
let current_marker =
if current_session_id.as_deref() == Some(&session.session_id) {
" (current)"
} else {
""
};
let todo_marker = if session.has_incomplete_todos() {
" 📝"
} else {
""
};
// Use description if available, otherwise fall back to session ID
let display_name = match &session.description {
Some(desc) => format!("'{}'", desc),
None => {
if session.session_id.len() > 40 {
format!("{}...", &session.session_id[..40])
} else {
session.session_id.clone()
}
}
};
output.print(&format!(
" {}. [{}] {} ({}){}{}\n",
i + 1,
time_str,
display_name,
context_str,
todo_marker,
current_marker
));
}
output.print_inline("\nSession number to resume (Enter to cancel): ");
// Read user selection
if let Ok(selection) = rl.readline("") {
let selection = selection.trim();
if selection.is_empty() {
output.print("Cancelled.");
} else if let Ok(num) = selection.parse::<usize>() {
if num >= 1 && num <= sessions.len() {
let selected = &sessions[num - 1];
match agent.switch_to_session(selected) {
Ok(true) => {
G3Status::resuming(&selected.session_id, Status::Done);
}
Ok(false) => {
G3Status::resuming_summary(&selected.session_id);
}
Err(e) => {
G3Status::resuming(&selected.session_id, Status::Error(e.to_string()));
}
}
} else {
output.print("Invalid selection.");
}
} else {
output.print("Invalid input. Please enter a number.");
}
}
}
Err(e) => output.print(&format!("❌ Error listing sessions: {}", e)),
}
Ok(true)
}
cmd if cmd.starts_with("/project") => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {
output.print("Usage: /project <absolute-path>");
output.print("Loads project files (brief.md, contacts.yaml, status.md) from the given path.");
} else {
let project_path_str = parts[1].trim();
// Expand tilde if present
let project_path = if project_path_str.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
home.join(&project_path_str[2..])
} else {
PathBuf::from(project_path_str)
}
} else {
PathBuf::from(project_path_str)
};
// Validate path is absolute
if !project_path.is_absolute() {
output.print("❌ Project path must be absolute (e.g., /Users/name/projects/myproject)");
return Ok(true);
}
// Validate path exists
if !project_path.exists() {
output.print(&format!("❌ Project path does not exist: {}", project_path.display()));
return Ok(true);
}
// Load the project
match Project::load(&project_path, workspace_dir) {
Some(project) => {
// Set project content in agent's system message
if agent.set_project_content(Some(project.content.clone())) {
// Set project path on UI writer for path shortening
let project_name = project.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string();
agent.ui_writer().set_project_path(project.path.clone(), project_name);
// Print loaded status
let project_name = project.path.file_name()
.and_then(|n| n.to_str()).unwrap_or("project");
G3Status::loading_project(project_name, &project.format_loaded_status());
// Store active project
*active_project = Some(project);
// Auto-submit the project status prompt
let prompt = "what is the current state of the project? and what is your suggested next best step?";
let completed = execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await;
if !completed {
return Ok(false);
}
} else {
output.print("❌ Failed to set project content in agent context.");
}
}
None => {
output.print("❌ No project files found (brief.md, contacts.yaml, status.md).");
}
}
}
Ok(true)
}
"/unproject" => {
if active_project.is_some() {
use crate::g3_status::G3Status;
G3Status::progress("unloading project");
agent.clear_project_content();
agent.ui_writer().clear_project();
*active_project = None;
G3Status::done();
output.print("Context reset to original system message.");
} else {
output.print("No project is currently loaded.");
}
Ok(true)
}
_ => {
output.print(&format!(
"❌ Unknown command: {}. Type /help for available commands.",
input
));
Ok(true)
}
}
}

View File

@@ -1,505 +0,0 @@
//! Tab completion support for g3 interactive mode.
//!
//! Provides:
//! - Prompt highlighting (colorizes project name in blue)
//! - Command completion for `/` commands at line start
//! - File path completion for `./`, `../`, `~/`, `/` prefixes
//! - Session ID completion for `/resume` command
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};
use std::path::PathBuf;
/// Available `/` commands for completion
const COMMANDS: &[&str] = &[
"/clear",
"/compact",
"/dump",
"/fragments",
"/help",
"/project",
"/readme",
"/rehydrate",
"/resume",
"/run",
"/skinnify",
"/stats",
"/thinnify",
"/unproject",
];
/// Helper struct for rustyline that provides tab completion.
pub struct G3Helper {
/// File path completer
file_completer: FilenameCompleter,
}
impl G3Helper {
pub fn new() -> Self {
Self {
file_completer: FilenameCompleter::new(),
}
}
/// Find the start of the current "word" being typed, respecting quotes.
/// Returns (word_start, word) where word_start is the byte index.
fn extract_word<'a>(&self, line: &'a str, pos: usize) -> (usize, &'a str) {
let line_to_cursor = &line[..pos];
// Find word start: after space (unless quoted/escaped)
let mut word_start = 0;
let mut in_quotes = false;
let mut quote_char = ' ';
let mut prev_was_backslash = false;
let chars: Vec<(usize, char)> = line_to_cursor.char_indices().collect();
for (idx, &(i, c)) in chars.iter().enumerate() {
if in_quotes {
if c == quote_char && !prev_was_backslash {
in_quotes = false;
}
} else if prev_was_backslash {
} else {
match c {
'"' | '\'' => {
in_quotes = true;
quote_char = c;
word_start = i;
}
' ' | '\t' => {
if idx + 1 < chars.len() {
word_start = chars[idx + 1].0;
} else {
word_start = pos; // At end, empty word
}
}
_ => {}
}
}
prev_was_backslash = c == '\\' && !prev_was_backslash;
}
(word_start, &line_to_cursor[word_start..])
}
fn is_path_prefix(&self, word: &str) -> bool {
let word = word.trim_start_matches('"').trim_start_matches('\'');
word.starts_with("./")
|| word.starts_with("../")
|| word.starts_with("~/")
|| word.starts_with('/')
|| word == "."
|| word == ".."
|| word == "~"
}
fn strip_quotes<'a>(&self, word: &'a str) -> &'a str {
word.trim_start_matches('"').trim_start_matches('\'')
.trim_end_matches('"').trim_end_matches('\'')
}
/// Unescape backslash-escaped chars: "~/My\ Files" -> "~/My Files"
fn unescape_path(&self, path: &str) -> String {
let mut result = String::with_capacity(path.len());
let mut chars = path.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && chars.peek().is_some() {
// Skip the backslash, take the next char literally
if let Some(next) = chars.next() {
result.push(next);
}
} else {
result.push(c);
}
}
result
}
/// List session IDs from .g3/sessions/, sorted newest-first, with optional limit.
fn list_sessions(&self, limit: Option<usize>) -> Vec<String> {
let sessions_dir = PathBuf::from(".g3/sessions");
if !sessions_dir.is_dir() {
return Vec::new();
}
let mut sessions: Vec<_> = std::fs::read_dir(&sessions_dir)
.ok()
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
let modified = entry.metadata().ok()?.modified().ok()?;
Some((entry.file_name().to_string_lossy().to_string(), modified))
})
.collect()
})
.unwrap_or_default();
// Sort by modification time, newest first
sessions.sort_by(|a, b| b.1.cmp(&a.1));
// Apply limit if specified
let sessions: Vec<String> = sessions
.into_iter()
.map(|(name, _)| name)
.take(limit.unwrap_or(usize::MAX))
.collect();
sessions
}
}
impl Default for G3Helper {
fn default() -> Self {
Self::new()
}
}
impl Completer for G3Helper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &Context<'_>,
) -> Result<(usize, Vec<Pair>), ReadlineError> {
let line_to_cursor = &line[..pos];
// Extract the current word being typed
let (word_start, word) = self.extract_word(line, pos);
// Case 1: Command completion at line start
if word_start == 0 && word.starts_with('/') && !word.contains(' ') {
let after_slash = &word[1..];
if !after_slash.contains('/') {
let matches: Vec<Pair> = COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(word))
.map(|cmd| Pair {
display: cmd.to_string(),
replacement: cmd.to_string(),
})
.collect();
if !matches.is_empty() {
return Ok((0, matches));
}
}
}
// Case 2: Path completion for path-like prefixes (handles quotes ourselves)
if self.is_path_prefix(word) || (word_start > 0 && line_to_cursor[word_start..].starts_with('/')) {
let has_leading_quote = word.starts_with('"') || word.starts_with('\'');
let quote_char = if has_leading_quote { &word[..1] } else { "" };
let has_escapes = word.contains('\\');
let path_str = self.strip_quotes(word);
let path_unescaped = self.unescape_path(path_str);
let path: &str = &path_unescaped;
let (_rel_start, completions) = self.file_completer.complete(path, path.len(), ctx)?;
if completions.is_empty() {
return Ok((pos, vec![]));
}
let adjusted: Vec<Pair> = completions
.into_iter()
.map(|pair| {
let has_spaces = pair.replacement.contains(' ');
let replacement = if has_leading_quote {
format!("{}{}{}", quote_char, pair.replacement, quote_char)
} else if has_escapes && has_spaces {
pair.replacement.replace(' ', "\\ ")
} else if has_spaces {
format!("\"{}\"" , pair.replacement)
} else {
pair.replacement
};
let needs_quotes = has_spaces || has_leading_quote;
let display = if needs_quotes && !pair.display.starts_with('"') {
format!("\"{}\"" , pair.display)
} else {
pair.display
};
Pair { display, replacement }
})
.collect();
return Ok((word_start, adjusted));
}
// Case 3: Path argument for /run command
if line_to_cursor.starts_with("/run ") {
let path = self.strip_quotes(word);
let (_, completions) = self.file_completer.complete(path, path.len(), ctx)?;
return Ok((word_start, completions));
}
// Case 4: Session ID completion for /resume command
if line_to_cursor.starts_with("/resume ") {
let partial = word;
let sessions = self.list_sessions(None);
let matches: Vec<Pair> = sessions
.into_iter()
.filter(|s| s.starts_with(partial))
.map(|s| Pair {
display: s.clone(),
replacement: s,
})
.take(8)
.collect();
return Ok((word_start, matches));
}
// No completion for regular text
Ok((pos, vec![]))
}
}
// Required trait implementations for Helper
impl Hinter for G3Helper {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
}
}
impl Highlighter for G3Helper {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
prompt: &'p str,
_default: bool,
) -> std::borrow::Cow<'b, str> {
// If prompt contains " | ", colorize from "|" to ">" in blue
if let Some(pipe_pos) = prompt.find(" | ") {
if let Some(gt_pos) = prompt.rfind('>') {
let before = &prompt[..pipe_pos + 1]; // "butler "
let colored_part = &prompt[pipe_pos + 1..gt_pos + 1]; // "| project>"
let after = &prompt[gt_pos + 1..]; // " "
return std::borrow::Cow::Owned(format!(
"{}\x1b[34m{}\x1b[0m{}",
before, colored_part, after
));
}
}
std::borrow::Cow::Borrowed(prompt)
}
}
impl Validator for G3Helper {}
impl Helper for G3Helper {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_completion() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let (start, matches) = helper.complete("/com", 4, &ctx).unwrap();
assert_eq!(start, 0);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].replacement, "/compact");
}
#[test]
fn test_command_completion_multiple() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let (start, matches) = helper.complete("/s", 2, &ctx).unwrap();
assert_eq!(start, 0);
assert_eq!(matches.len(), 2);
assert!(matches.iter().any(|m| m.replacement == "/skinnify"));
assert!(matches.iter().any(|m| m.replacement == "/stats"));
}
#[test]
fn test_path_prefix_detection() {
let helper = G3Helper::new();
assert!(helper.is_path_prefix("./"));
assert!(helper.is_path_prefix("./src"));
assert!(helper.is_path_prefix("../"));
assert!(helper.is_path_prefix("~/"));
assert!(helper.is_path_prefix("~/Documents"));
assert!(helper.is_path_prefix("/etc"));
assert!(helper.is_path_prefix("."));
assert!(helper.is_path_prefix(".."));
assert!(helper.is_path_prefix("~"));
assert!(!helper.is_path_prefix("hello"));
assert!(!helper.is_path_prefix("src"));
}
#[test]
fn test_extract_word_simple() {
let helper = G3Helper::new();
let (start, word) = helper.extract_word("hello world", 11);
assert_eq!(start, 6);
assert_eq!(word, "world");
}
#[test]
fn test_extract_word_with_path() {
let helper = G3Helper::new();
let (start, word) = helper.extract_word("edit ./src/main.rs", 18);
assert_eq!(start, 5);
assert_eq!(word, "./src/main.rs");
}
#[test]
fn test_extract_word_quoted() {
let helper = G3Helper::new();
// Quoted path with spaces
let (start, word) = helper.extract_word("edit \"./My Files/doc", 20);
assert_eq!(start, 5);
assert_eq!(word, "\"./My Files/doc");
}
#[test]
fn test_no_completion_for_regular_input() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
// Regular text should not complete
let (start, matches) = helper.complete("hello world", 11, &ctx).unwrap();
assert_eq!(start, 11);
assert!(matches.is_empty());
}
#[test]
fn test_slash_at_start_is_command() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
// "/h" at start should complete to commands
let (start, matches) = helper.complete("/h", 2, &ctx).unwrap();
assert_eq!(start, 0);
assert!(matches.iter().any(|m| m.replacement == "/help"));
}
#[test]
fn test_actual_completion_with_quotes() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let line = "edit \"~/";
let pos = line.len();
match helper.complete(line, pos, &ctx) {
Ok((start, completions)) => {
assert!(start > 0 || completions.is_empty() || true); // Just verify no panic
}
Err(_) => {}
}
let line = "edit ~/My\\ ";
let pos = line.len();
match helper.complete(line, pos, &ctx) {
Ok((start, completions)) => {
let _ = (start, completions); // Just verify no panic
}
Err(_) => {}
}
let line = "edit \"~/\"";
let pos = line.len();
match helper.complete(line, pos, &ctx) {
Ok((start, completions)) => {
let _ = (start, completions);
}
Err(_) => {}
}
}
#[test]
fn test_no_completion_for_bare_quote() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let line = "edit \"";
let pos = line.len();
let (start, completions) = helper.complete(line, pos, &ctx).unwrap();
let _ = start;
assert_eq!(completions.len(), 0, "Bare quote should not trigger path completion");
}
#[test]
fn test_no_completion_for_random_text_in_quotes() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let line = "edit \"hello world";
let pos = line.len();
let (start, completions) = helper.complete(line, pos, &ctx).unwrap();
let _ = start;
assert_eq!(completions.len(), 0, "Random quoted text should not trigger path completion");
let line = "edit \"foo";
let pos = line.len();
let (start, completions) = helper.complete(line, pos, &ctx).unwrap();
let _ = start;
assert_eq!(completions.len(), 0, "Quoted non-path should not trigger completion");
}
#[test]
fn test_resume_completion_lists_sessions() {
let helper = G3Helper::new();
let history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&history);
let line = "/resume ";
let pos = line.len();
let (start, completions) = helper.complete(line, pos, &ctx).unwrap();
let _ = start;
if std::path::Path::new(".g3/sessions").is_dir() {
assert!(completions.len() > 0, "Should list sessions when .g3/sessions exists");
if let Some(first) = completions.first() {
let prefix = &first.replacement[..first.replacement.len().min(5)];
let line = format!("/resume {}", prefix);
let pos = line.len();
let (_, filtered) = helper.complete(&line, pos, &ctx).unwrap();
assert!(filtered.len() >= 1, "Should find at least one match");
assert!(filtered.iter().all(|p| p.replacement.starts_with(prefix)));
}
}
let line = "/resume zzz_nonexistent_prefix_";
let pos = line.len();
let (_, completions) = helper.complete(line, pos, &ctx).unwrap();
assert_eq!(completions.len(), 0, "Non-matching prefix should return empty");
}
#[test]
fn test_resume_completion_graceful_no_panic() {
let helper = G3Helper::new();
let sessions = helper.list_sessions(None);
let _ = sessions; // Just verify no panic
}
}

View File

@@ -1,362 +0,0 @@
//! Display utilities for G3 CLI.
//!
//! Provides shared display functions used by both interactive mode and agent mode.
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use std::path::Path;
/// Format a workspace path for display, replacing home directory with ~.
pub fn format_workspace_path(workspace_path: &Path) -> String {
let path_str = workspace_path.display().to_string();
dirs::home_dir()
.and_then(|home| {
path_str
.strip_prefix(&home.display().to_string())
.map(|s| format!("~{}", s))
})
.unwrap_or(path_str)
}
/// Shorten a path string for display by:
/// 1. Replacing project directory prefix with `<project_name>/` (if project is active)
/// 2. Replacing workspace directory prefix with `./`
/// 3. Replacing home directory prefix with `~`
///
/// This is useful for tool output where paths should be concise.
/// The project check happens first (most specific), then workspace, then home.
pub fn shorten_path(path: &str, workspace_path: Option<&std::path::Path>, project: Option<(&std::path::Path, &str)>) -> String {
// First, try to make it relative to project (most specific)
if let Some((project_path, project_name)) = project {
let project_str = project_path.display().to_string();
if let Some(relative) = path.strip_prefix(&project_str) {
// Handle both "/subpath" and "" (exact match) cases
if relative.is_empty() {
return format!("{}/", project_name);
} else if let Some(stripped) = relative.strip_prefix('/') {
return format!("{}/{}", project_name, stripped);
}
}
}
// First, try to make it relative to workspace
if let Some(workspace) = workspace_path {
let workspace_str = workspace.display().to_string();
if let Some(relative) = path.strip_prefix(&workspace_str) {
// Handle both "/subpath" and "" (exact match) cases
if relative.is_empty() {
return "./".to_string();
} else if let Some(stripped) = relative.strip_prefix('/') {
return format!("./{}", stripped);
}
}
}
// Fall back to replacing home directory with ~
if let Some(home) = dirs::home_dir() {
let home_str = home.display().to_string();
if let Some(relative) = path.strip_prefix(&home_str) {
return format!("~{}", relative);
}
}
path.to_string()
}
/// Shorten any paths found within a shell command string.
/// This replaces project paths with `<project_name>/`, workspace paths with `./`, and home paths with `~`.
pub fn shorten_paths_in_command(command: &str, workspace_path: Option<&std::path::Path>, project: Option<(&std::path::Path, &str)>) -> String {
let mut result = command.to_string();
// First, replace project paths (most specific)
if let Some((project_path, project_name)) = project {
let project_str = project_path.display().to_string();
// Replace project path followed by / with project_name/
result = result.replace(&format!("{}/", project_str), &format!("{}/", project_name));
// Replace exact project path
result = result.replace(&project_str, project_name);
}
// Then, replace workspace paths
if let Some(workspace) = workspace_path {
let workspace_str = workspace.display().to_string();
// Replace workspace path followed by / with ./
result = result.replace(&format!("{}/", workspace_str), "./");
// Replace exact workspace path at word boundary
result = result.replace(&workspace_str, ".");
}
// Then replace home directory paths
if let Some(home) = dirs::home_dir() {
let home_str = home.display().to_string();
result = result.replace(&home_str, "~");
}
result
}
/// Print the workspace path in a consistent format.
pub fn print_workspace_path(workspace_path: &Path) {
let display = format_workspace_path(workspace_path);
print!(
"{}-> {}{}",
SetForegroundColor(Color::DarkGrey),
display,
ResetColor
);
println!();
}
/// Information about what project files were loaded.
#[derive(Default)]
pub struct LoadedContent {
pub has_readme: bool,
pub has_agents: bool,
pub has_memory: bool,
pub include_prompt_filename: Option<String>,
}
impl LoadedContent {
/// Create from explicit boolean flags.
pub fn new(has_readme: bool, has_agents: bool, has_memory: bool, include_prompt_filename: Option<String>) -> Self {
Self {
has_readme,
has_agents,
has_memory,
include_prompt_filename,
}
}
/// Create from combined content string by detecting markers.
pub fn from_combined_content(content: &str) -> Self {
Self {
has_readme: content.contains("Project README"),
has_agents: content.contains("Agent Configuration"),
has_memory: content.contains("=== Workspace Memory"),
include_prompt_filename: if content.contains("Included Prompt") {
Some("prompt".to_string()) // Default name when we can't determine the actual filename
} else {
None
},
}
}
/// Create with explicit include prompt filename.
#[allow(dead_code)] // Used in tests, may be useful for future callers
pub fn with_include_prompt_filename(mut self, filename: Option<String>) -> Self {
if self.include_prompt_filename.is_some() {
self.include_prompt_filename = filename;
}
self
}
/// Check if any content was loaded.
pub fn has_any(&self) -> bool {
self.has_readme || self.has_agents || self.has_memory || self.include_prompt_filename.is_some()
}
/// Build a list of loaded item names in load order.
pub fn to_loaded_items(&self) -> Vec<String> {
let mut items = Vec::new();
if self.has_readme {
items.push("README".to_string());
}
if self.has_agents {
items.push("AGENTS.md".to_string());
}
if let Some(ref filename) = self.include_prompt_filename {
items.push(filename.clone());
}
if self.has_memory {
items.push("Memory".to_string());
}
items
}
}
/// Print a status line showing what project files were loaded.
/// Format: " ✓ README ✓ AGENTS.md ✓ Memory"
pub fn print_loaded_status(loaded: &LoadedContent) {
if !loaded.has_any() {
return;
}
let items = loaded.to_loaded_items();
let status_str = items
.iter()
.map(|s| format!("{}", s))
.collect::<Vec<_>>()
.join(" ");
print!(
"{} {}{}",
SetForegroundColor(Color::DarkGrey),
status_str,
ResetColor
);
println!();
}
/// Print the project name/heading from README content.
pub fn print_project_heading(heading: &str) {
print!(
"{}>> {}{}",
SetForegroundColor(Color::DarkGrey),
heading,
ResetColor
);
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_format_workspace_path_with_home() {
// This test depends on having a home directory
if let Some(home) = dirs::home_dir() {
let test_path = home.join("projects").join("myapp");
let formatted = format_workspace_path(&test_path);
assert!(formatted.starts_with("~/"), "Expected ~/ prefix, got: {}", formatted);
assert!(formatted.contains("projects/myapp"));
}
}
#[test]
fn test_format_workspace_path_without_home() {
let test_path = PathBuf::from("/tmp/workspace");
let formatted = format_workspace_path(&test_path);
assert_eq!(formatted, "/tmp/workspace");
}
#[test]
fn test_loaded_content_from_combined() {
let content = "Project README\nAgent Configuration\n=== Workspace Memory";
let loaded = LoadedContent::from_combined_content(content);
assert!(loaded.has_readme);
assert!(loaded.has_agents);
assert!(loaded.has_memory);
assert!(loaded.include_prompt_filename.is_none());
}
#[test]
fn test_loaded_content_with_include_prompt() {
let content = "Project README\nIncluded Prompt";
let loaded = LoadedContent::from_combined_content(content)
.with_include_prompt_filename(Some("custom.md".to_string()));
assert!(loaded.has_readme);
assert_eq!(loaded.include_prompt_filename, Some("custom.md".to_string()));
}
#[test]
fn test_loaded_content_to_items_order() {
let loaded = LoadedContent {
has_readme: true,
has_agents: true,
has_memory: true,
include_prompt_filename: Some("prompt.md".to_string()),
};
let items = loaded.to_loaded_items();
assert_eq!(items, vec!["README", "AGENTS.md", "prompt.md", "Memory"]);
}
#[test]
fn test_loaded_content_has_any() {
let empty = LoadedContent::default();
assert!(!empty.has_any());
let with_readme = LoadedContent {
has_readme: true,
..Default::default()
};
assert!(with_readme.has_any());
}
#[test]
fn test_shorten_path_workspace_relative() {
let workspace = PathBuf::from("/Users/test/projects/myapp");
let path = "/Users/test/projects/myapp/src/main.rs";
let shortened = shorten_path(path, Some(&workspace), None);
assert_eq!(shortened, "./src/main.rs");
}
#[test]
fn test_shorten_path_workspace_exact() {
let workspace = PathBuf::from("/Users/test/projects/myapp");
let path = "/Users/test/projects/myapp";
let shortened = shorten_path(path, Some(&workspace), None);
assert_eq!(shortened, "./");
}
#[test]
fn test_shorten_path_home_relative() {
// This test depends on having a home directory
if let Some(home) = dirs::home_dir() {
let path = format!("{}/other/project/file.rs", home.display());
let shortened = shorten_path(&path, None, None);
assert_eq!(shortened, "~/other/project/file.rs");
}
}
#[test]
fn test_shorten_path_no_match() {
let workspace = PathBuf::from("/Users/test/projects/myapp");
let path = "/tmp/other/file.rs";
let shortened = shorten_path(path, Some(&workspace), None);
assert_eq!(shortened, "/tmp/other/file.rs");
}
#[test]
fn test_shorten_path_project_relative() {
let workspace = PathBuf::from("/Users/test/projects");
let project_path = PathBuf::from("/Users/test/projects/appa_estate");
let path = "/Users/test/projects/appa_estate/status.md";
let shortened = shorten_path(path, Some(&workspace), Some((&project_path, "appa_estate")));
assert_eq!(shortened, "appa_estate/status.md");
}
#[test]
fn test_shorten_path_project_takes_priority() {
// Project path is under workspace, but project shortening should take priority
let workspace = PathBuf::from("/Users/test/projects");
let project_path = PathBuf::from("/Users/test/projects/appa_estate");
let path = "/Users/test/projects/appa_estate/src/main.rs";
let shortened = shorten_path(path, Some(&workspace), Some((&project_path, "appa_estate")));
assert_eq!(shortened, "appa_estate/src/main.rs");
}
#[test]
fn test_shorten_paths_in_command_workspace() {
let workspace = PathBuf::from("/Users/test/projects/myapp");
let command = "cat /Users/test/projects/myapp/src/main.rs";
let shortened = shorten_paths_in_command(command, Some(&workspace), None);
assert_eq!(shortened, "cat ./src/main.rs");
}
#[test]
fn test_shorten_paths_in_command_home() {
if let Some(home) = dirs::home_dir() {
let command = format!("ls {}/Documents", home.display());
let shortened = shorten_paths_in_command(&command, None, None);
assert_eq!(shortened, "ls ~/Documents");
}
}
#[test]
fn test_shorten_paths_in_command_multiple() {
let workspace = PathBuf::from("/Users/test/projects/myapp");
let command = "diff /Users/test/projects/myapp/a.rs /Users/test/projects/myapp/b.rs";
let shortened = shorten_paths_in_command(command, Some(&workspace), None);
assert_eq!(shortened, "diff ./a.rs ./b.rs");
}
#[test]
fn test_shorten_paths_in_command_project() {
let workspace = PathBuf::from("/Users/test/projects");
let project_path = PathBuf::from("/Users/test/projects/appa_estate");
let command = "cat /Users/test/projects/appa_estate/status.md";
let shortened = shorten_paths_in_command(command, Some(&workspace), Some((&project_path, "appa_estate")));
assert_eq!(shortened, "cat appa_estate/status.md");
}
}

View File

@@ -1,118 +0,0 @@
//! Embedded agent prompts - compiled into the binary for portability.
//!
//! Agent prompts are embedded at compile time using `include_str!`.
//! This allows g3 to run on any repository without needing the agents/ directory.
//!
//! Priority order for loading agent prompts:
//! 1. Workspace `agents/<name>.md` (allows per-project customization)
//! 2. Embedded prompts (fallback, always available)
use std::collections::HashMap;
use std::path::Path;
use crate::template::process_template;
/// Embedded agent prompts, keyed by agent name.
static EMBEDDED_AGENTS: &[(&str, &str)] = &[
("breaker", include_str!("../../../agents/breaker.md")),
("carmack", include_str!("../../../agents/carmack.md")),
("euler", include_str!("../../../agents/euler.md")),
("fowler", include_str!("../../../agents/fowler.md")),
("hopper", include_str!("../../../agents/hopper.md")),
("lamport", include_str!("../../../agents/lamport.md")),
("scout", include_str!("../../../agents/scout.md")),
];
/// Get an embedded agent prompt by name.
pub fn get_embedded_agent(name: &str) -> Option<&'static str> {
EMBEDDED_AGENTS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, content)| *content)
}
/// Get all available embedded agent names.
pub fn list_embedded_agents() -> Vec<&'static str> {
EMBEDDED_AGENTS.iter().map(|(name, _)| *name).collect()
}
/// Load an agent prompt, checking workspace first, then falling back to embedded.
///
/// Returns the prompt content and a boolean indicating if it was loaded from disk (true)
/// or embedded (false).
pub fn load_agent_prompt(name: &str, workspace_dir: &Path) -> Option<(String, bool)> {
// First, try workspace agents/<name>.md
let workspace_path = workspace_dir.join("agents").join(format!("{}.md", name));
if workspace_path.exists() {
if let Ok(content) = std::fs::read_to_string(&workspace_path) {
let processed = process_template(&content);
return Some((processed, true));
}
}
// Fall back to embedded prompt
get_embedded_agent(name).map(|content| (process_template(content), false))
}
/// Get a map of all available agents (both embedded and from workspace).
pub fn get_available_agents(workspace_dir: &Path) -> HashMap<String, bool> {
let mut agents = HashMap::new();
// Add all embedded agents
for name in list_embedded_agents() {
agents.insert(name.to_string(), false); // false = embedded
}
// Check for workspace agents (these override embedded)
let agents_dir = workspace_dir.join("agents");
if agents_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
agents.insert(stem.to_string(), true); // true = from disk
}
}
}
}
}
agents
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_embedded_agents_exist() {
// Verify all expected agents are embedded
let expected = ["breaker", "carmack", "euler", "fowler", "hopper", "lamport", "scout"];
for name in expected {
assert!(
get_embedded_agent(name).is_some(),
"Agent '{}' should be embedded",
name
);
}
}
#[test]
fn test_list_embedded_agents() {
let agents = list_embedded_agents();
assert!(agents.len() >= 7, "Should have at least 7 embedded agents");
assert!(agents.contains(&"carmack"));
assert!(agents.contains(&"hopper"));
}
#[test]
fn test_embedded_agent_content() {
// Verify the content looks reasonable
let carmack = get_embedded_agent("carmack").unwrap();
assert!(carmack.contains("Carmack"), "Carmack prompt should mention Carmack");
let hopper = get_embedded_agent("hopper").unwrap();
assert!(hopper.contains("Hopper"), "Hopper prompt should mention Hopper");
}
}

View File

@@ -1,613 +0,0 @@
//! JSON tool call filtering for streaming LLM responses.
//!
//! This module filters out JSON tool calls from LLM output streams while preserving
//! regular text content. It uses a simple state machine optimized for streaming.
//!
//! # Design
//!
//! The filter uses three states:
//! - **Streaming**: Normal pass-through mode. Watches for newline + whitespace + `{`
//! - **Buffering**: Saw potential tool call start, buffering to confirm/deny
//! - **Suppressing**: Confirmed tool call, counting braces (string-aware) to find end
//!
//! The key insight is that we only need to buffer a small amount (around 12 chars)
//! to confirm whether `{` starts a tool call pattern like `{"tool":`.
use std::cell::RefCell;
use tracing::debug;
/// Maximum chars needed to confirm/deny a tool call pattern.
/// Pattern is: { + optional whitespace + "tool" + optional whitespace + : + optional whitespace + "
/// Realistically: `{"tool":"` = 9 chars, with whitespace maybe 15 max
const MAX_BUFFER_FOR_DETECTION: usize = 20;
/// Hints emitted during tool call parsing for UI feedback.
#[derive(Debug, Clone)]
pub enum ToolParsingHint {
/// Tool call detected, name is known. UI should show " ● tool_name |"
Detected(String),
/// More characters being parsed. UI should blink the indicator.
Active,
/// Tool call JSON fully parsed. UI should clear the parsing indicator.
Complete,
}
// Thread-local state for tracking JSON tool call suppression
thread_local! {
static JSON_TOOL_STATE: RefCell<FilterState> = RefCell::new(FilterState::new());
}
/// The three possible states of the filter
#[derive(Debug, Clone, PartialEq)]
enum State {
/// Normal streaming - pass through content, watch for newline + whitespace + {
Streaming,
/// Saw potential start, buffering to confirm/deny tool pattern
Buffering,
/// Confirmed tool call, suppressing until braces balance
Suppressing,
}
/// Internal state for the filter
#[derive(Debug, Clone)]
struct FilterState {
state: State,
/// Buffer for potential tool call detection (Buffering state)
buffer: String,
/// Are we inside a code fence? (``` ... ```)
in_code_fence: bool,
/// Buffer for detecting code fence markers
fence_buffer: String,
/// Brace depth for JSON tracking (Suppressing state) - string-aware
brace_depth: i32,
/// Are we inside a JSON string? (for proper brace counting)
in_string: bool,
/// Was the previous char a backslash? (for escape handling)
escape_next: bool,
/// Track if we just saw a newline (to detect line-start patterns)
at_line_start: bool,
/// Whitespace seen after newline (before potential {)
pending_whitespace: String,
/// Newlines accumulated at line start (before potential tool call)
pending_newlines: String,
}
impl FilterState {
fn new() -> Self {
Self {
state: State::Streaming,
buffer: String::new(),
in_code_fence: false,
fence_buffer: String::new(),
brace_depth: 0,
in_string: false,
escape_next: false,
at_line_start: true, // Start of input counts as line start
pending_whitespace: String::new(),
pending_newlines: String::new(),
}
}
fn reset(&mut self) {
self.state = State::Streaming;
self.buffer.clear();
self.in_code_fence = false;
self.fence_buffer.clear();
self.brace_depth = 0;
self.in_string = false;
self.escape_next = false;
self.at_line_start = true;
self.pending_whitespace.clear();
self.pending_newlines.clear();
}
}
/// Check if buffer matches the tool call pattern.
/// Pattern: `{` followed by optional whitespace, `"tool"`, optional whitespace, `:`, optional whitespace, `"`
///
/// Returns:
/// - Some(true) if confirmed as tool call
/// - Some(false) if confirmed NOT a tool call
/// - None if need more data
fn check_tool_pattern(buffer: &str) -> Option<bool> {
// Must start with {
if !buffer.starts_with('{') {
return Some(false);
}
let trimmed = buffer[1..].trim_start();
// Need at least `"tool":"` = 8 chars after whitespace
if trimmed.len() < 8 {
// Early rejection: check progressive prefix of "tool
if let Some(after_quote) = trimmed.strip_prefix('"') {
// Check each prefix of "tool" we have so far
for (i, expected) in ["t", "to", "too", "tool"].iter().enumerate() {
if after_quote.len() > i && !after_quote.starts_with(expected) {
return Some(false);
}
}
} else if !trimmed.is_empty() && !trimmed.starts_with('"') {
return Some(false);
}
return None;
}
// Full pattern check: "tool" : "
if !trimmed.starts_with("\"tool\"") {
return Some(false);
}
let after_tool = trimmed[6..].trim_start();
if after_tool.is_empty() {
return None;
}
if !after_tool.starts_with(':') {
return Some(false);
}
let after_colon = after_tool[1..].trim_start();
if after_colon.is_empty() {
return None;
}
Some(after_colon.starts_with('"'))
}
/// Filters JSON tool calls from streaming LLM content.
///
/// Processes content character-by-character and removes JSON tool calls
/// while preserving regular text. Maintains state across calls.
///
/// # Arguments
/// * `content` - A chunk of streaming content from the LLM
///
/// # Returns
/// The filtered content with JSON tool calls removed
pub fn filter_json_tool_calls(content: &str) -> String {
if content.is_empty() {
return String::new();
}
JSON_TOOL_STATE.with(|state| {
let mut state = state.borrow_mut();
let mut output = String::new();
for ch in content.chars() {
match state.state {
State::Streaming => {
handle_streaming_char(&mut state, ch, &mut output);
}
State::Buffering => {
handle_buffering_char(&mut state, ch, &mut output);
}
State::Suppressing => {
handle_suppressing_char(&mut state, ch, &mut output);
}
}
}
output
})
}
/// Handle a character in Streaming state
fn handle_streaming_char(state: &mut FilterState, ch: char, output: &mut String) {
// Track code fence state
track_code_fence(state, ch);
// If inside a code fence, pass through everything
if state.in_code_fence {
pass_through_char(state, ch, output);
return;
}
match ch {
'\n' => {
// Buffer extra newlines at line start - they may precede a tool call
// Always output the first newline, but buffer subsequent ones
if state.at_line_start {
state.pending_newlines.push(ch);
} else {
// First newline after content - output it and enter line start mode
output.push(ch);
state.at_line_start = true;
state.pending_newlines.clear(); // Reset - this newline was output
}
}
' ' | '\t' if state.at_line_start => {
// Accumulate whitespace at line start
state.pending_whitespace.push(ch);
}
'{' if state.at_line_start && state.pending_whitespace.is_empty() => {
// Potential tool call! Enter buffering mode
// BUT only if there's no leading whitespace (indented JSON is not a tool call)
debug!("Potential tool call detected - entering Buffering state");
state.state = State::Buffering;
state.buffer.clear();
state.buffer.push(ch);
// Don't output pending_newlines or pending_whitespace yet - we might need to suppress them
}
'{' if state.at_line_start && !state.pending_whitespace.is_empty() => {
// Indented JSON - not a tool call, pass through
output.push_str(&state.pending_newlines);
output.push_str(&state.pending_whitespace);
state.pending_newlines.clear();
state.pending_whitespace.clear();
output.push(ch);
state.at_line_start = false;
}
_ => {
// Regular character - output any pending newlines and whitespace first
output.push_str(&state.pending_newlines);
state.pending_newlines.clear();
output.push_str(&state.pending_whitespace);
state.pending_whitespace.clear();
output.push(ch);
state.at_line_start = false;
}
}
}
/// Pass through a character without filtering (used inside code fences)
fn pass_through_char(state: &mut FilterState, ch: char, output: &mut String) {
// Output any pending content first
output.push_str(&state.pending_newlines);
output.push_str(&state.pending_whitespace);
state.pending_newlines.clear();
state.pending_whitespace.clear();
output.push(ch);
state.at_line_start = ch == '\n';
}
/// Track code fence state (``` markers)
fn track_code_fence(state: &mut FilterState, ch: char) {
match ch {
'`' => {
state.fence_buffer.push(ch);
}
'\n' => {
// Check if we have a fence marker
if state.fence_buffer.starts_with("```") {
// Toggle fence state
state.in_code_fence = !state.in_code_fence;
debug!("Code fence toggled: in_code_fence={}", state.in_code_fence);
}
state.fence_buffer.clear();
}
_ => {
// If we were accumulating backticks but got something else,
// check if we have a fence marker (for opening fences with language)
if state.fence_buffer.starts_with("```") && !state.in_code_fence {
// Opening fence with language specifier (e.g., ```json)
state.in_code_fence = true;
debug!("Code fence opened with language: in_code_fence=true");
}
state.fence_buffer.clear();
}
}
}
/// Handle a character in Buffering state
fn handle_buffering_char(state: &mut FilterState, ch: char, output: &mut String) {
state.buffer.push(ch);
// Check if we can determine tool call status
match check_tool_pattern(&state.buffer) {
Some(true) => {
// Confirmed tool call! Enter suppression mode
debug!("Confirmed tool call - entering Suppressing state");
state.state = State::Suppressing;
state.brace_depth = 1; // We already have the opening {
state.in_string = true; // We're inside the "tool" value string
state.escape_next = false;
// Discard pending_newlines and pending_whitespace (they're part of the tool call)
state.pending_newlines.clear();
state.pending_whitespace.clear();
state.buffer.clear();
}
Some(false) => {
// Not a tool call - release buffered content
debug!("Not a tool call - releasing buffer");
output.push_str(&state.pending_newlines);
output.push_str(&state.pending_whitespace);
output.push_str(&state.buffer);
state.pending_newlines.clear();
state.pending_whitespace.clear();
state.buffer.clear();
state.state = State::Streaming;
state.at_line_start = ch == '\n';
}
None => {
// Need more data - check if buffer is getting too long
if state.buffer.len() > MAX_BUFFER_FOR_DETECTION {
// Too long without confirmation - not a tool call
debug!("Buffer exceeded max length - not a tool call");
output.push_str(&state.pending_newlines);
output.push_str(&state.pending_whitespace);
output.push_str(&state.buffer);
state.pending_newlines.clear();
state.pending_whitespace.clear();
state.buffer.clear();
state.state = State::Streaming;
state.at_line_start = false;
}
// Otherwise keep buffering
}
}
}
/// Handle a character in Suppressing state (string-aware brace counting)
fn handle_suppressing_char(state: &mut FilterState, ch: char, _output: &mut String) {
// Track chars to detect if we see a new tool call pattern while suppressing
// This handles truncated JSON followed by complete JSON
state.buffer.push(ch);
// Handle escape sequences
if state.escape_next {
state.escape_next = false;
return;
}
match ch {
'\\' if state.in_string => {
state.escape_next = true;
}
'"' => {
state.in_string = !state.in_string;
}
'{' if !state.in_string => {
state.brace_depth += 1;
}
'}' if !state.in_string => {
state.brace_depth -= 1;
if state.brace_depth <= 0 {
// JSON complete! Return to streaming
debug!("Tool call complete - returning to Streaming state");
state.state = State::Streaming;
state.at_line_start = false; // We're right after the }
state.in_string = false;
state.escape_next = false;
state.buffer.clear();
}
}
_ => {}
}
// Check if we're seeing a new tool call pattern (truncated JSON case)
// This can happen with or without a newline before the new {
// Look for { followed by tool pattern in the buffer
if state.buffer.len() >= 10 {
// Find the last { that could start a new tool call
for (i, c) in state.buffer.char_indices().rev() {
if c == '{' && i > 0 {
let potential_tool = &state.buffer[i..];
if let Some(true) = check_tool_pattern(potential_tool) {
// New tool call detected! Restart suppression from here
debug!("New tool call detected while suppressing - restarting");
state.brace_depth = 1;
state.in_string = true;
// Keep only the part after the new { for continued tracking
state.buffer = potential_tool.to_string();
return;
}
}
}
// Limit buffer size to prevent unbounded growth
if state.buffer.len() > 200 {
// Find a valid character boundary near the 100-byte mark from the end
// We can't just slice at byte offset - multi-byte chars (like emojis) would panic
let target_keep = state.buffer.len() - 100;
// Find the nearest char boundary at or after target_keep
let keep_from = state.buffer.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= target_keep)
.unwrap_or(0);
state.buffer = state.buffer[keep_from..].to_string();
}
}
}
/// Resets the global JSON filtering state.
///
/// Call this between independent filtering sessions to ensure clean state.
/// This is particularly important in tests and when starting new conversations.
pub fn reset_json_tool_state() {
JSON_TOOL_STATE.with(|state| {
let mut state = state.borrow_mut();
state.reset();
});
}
/// Flushes any pending content from the JSON filter.
///
/// Call this at the end of streaming to ensure any buffered newlines
/// or whitespace that wasn't followed by a tool call gets output.
pub fn flush_json_tool_filter() -> String {
JSON_TOOL_STATE.with(|state| {
let mut state = state.borrow_mut();
let mut output = String::new();
// Output any pending newlines and whitespace
output.push_str(&state.pending_newlines);
output.push_str(&state.pending_whitespace);
output.push_str(&state.buffer);
state.pending_newlines.clear();
state.pending_whitespace.clear();
state.buffer.clear();
output
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_tool_pattern_confirmed() {
assert_eq!(check_tool_pattern(r#"{"tool":""
"#), Some(true));
assert_eq!(check_tool_pattern(r#"{"tool": "shell""#), Some(true));
assert_eq!(check_tool_pattern(r#"{ "tool" : "test""#), Some(true));
}
#[test]
fn test_check_tool_pattern_rejected() {
assert_eq!(check_tool_pattern(r#"{"other": "value"}"#), Some(false));
assert_eq!(check_tool_pattern(r#"{"tools": "value"}"#), Some(false));
assert_eq!(check_tool_pattern(r#"{"tool": 123}"#), Some(false)); // number not string
}
#[test]
fn test_check_tool_pattern_need_more() {
assert_eq!(check_tool_pattern(r#"{"#), None);
assert_eq!(check_tool_pattern(r#"{"tool"#), None);
assert_eq!(check_tool_pattern(r#"{"tool":"#), None);
}
#[test]
fn test_passthrough_no_tool() {
reset_json_tool_state();
let input = "Hello world";
assert_eq!(filter_json_tool_calls(input), input);
}
#[test]
fn test_simple_tool_filtered() {
reset_json_tool_state();
let input = "Before\n{\"tool\": \"shell\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Before\n\nAfter");
}
#[test]
fn test_tool_with_braces_in_string() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"cmd\": \"echo }\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_non_tool_json_passes_through() {
reset_json_tool_state();
let input = "Text\n{\"other\": \"value\"}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, input);
}
#[test]
fn test_streaming_chunks() {
reset_json_tool_state();
let chunks = vec![
"Before\n",
"{\"tool\": \"",
"shell\", \"args\": {}",
"}\nAfter",
];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Before\n\nAfter");
}
#[test]
fn test_buffer_truncation_with_multibyte_chars() {
// This test ensures that buffer truncation doesn't panic on multi-byte characters
// The bug was: slicing at byte offset 100 from end could land mid-emoji
reset_json_tool_state();
// Create a string with emojis that's over 200 bytes to trigger truncation
// Each emoji is 4 bytes, so we need ~50+ emojis to exceed 200 bytes
let emoji_heavy = "🔄".repeat(60); // 240 bytes of emojis
let input = format!("Text\n{{\"tool\": \"shell\", \"args\": {{\"data\": \"{}\"}}}}\nMore", emoji_heavy);
// This should not panic - the fix ensures we find valid char boundaries
let result = filter_json_tool_calls(&input);
// The tool call should be filtered out
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_multiple_newlines_before_tool_call_suppressed() {
// This test verifies that extra blank lines before a tool call are suppressed.
// This fixes the visual issue where many blank lines appeared before tool calls.
reset_json_tool_state();
// Input has 4 newlines before the tool call (3 blank lines)
let input = "Before\n\n\n\n{\"tool\": \"shell\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
// Only one newline should remain before where the tool call was
// (the first newline after "Before" is preserved, extra ones are suppressed)
assert_eq!(result, "Before\n\nAfter");
}
#[test]
fn test_single_newline_before_tool_call_preserved() {
// A single newline before a tool call should be preserved
reset_json_tool_state();
let input = "Before\n{\"tool\": \"shell\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Before\n\nAfter");
}
#[test]
fn test_tool_call_not_at_line_start_passes_through() {
// IMPORTANT: Tool calls that don't start at a line boundary should NOT be filtered.
// This is by design - the filter only suppresses tool calls that appear at the
// start of a line (after newline + optional whitespace).
//
// This test documents the behavior that caused the "auto-memory JSON leak" bug:
// When "Memory checkpoint: " was printed without a trailing newline, the LLM's
// response `{"tool": "remember", ...}` appeared on the same line and was not
// filtered. The fix was to ensure the prompt ends with a newline AND reset
// the filter state before streaming.
//
// See: send_auto_memory_reminder() in g3-core/src/lib.rs
reset_json_tool_state();
// Tool call immediately after text on same line - should NOT be filtered
let input = "Memory checkpoint: {\"tool\": \"remember\", \"args\": {}}";
let result = filter_json_tool_calls(input);
assert_eq!(result, input, "Tool calls not at line start should pass through");
}
#[test]
fn test_tool_json_in_code_fence_passes_through() {
// JSON inside code fences should NOT be filtered, even if it looks like a tool call
reset_json_tool_state();
let input = "Before\n```json\n{\"tool\": \"shell\", \"args\": {}}\n```\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, input, "Tool JSON inside code fence should pass through");
}
#[test]
fn test_tool_json_in_plain_code_fence_passes_through() {
// JSON inside plain code fences (no language) should also pass through
reset_json_tool_state();
let input = "Before\n```\n{\"tool\": \"shell\", \"args\": {}}\n```\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, input, "Tool JSON inside plain code fence should pass through");
}
#[test]
fn test_indented_tool_json_passes_through() {
// Indented JSON should NOT be filtered (real tool calls are never indented)
reset_json_tool_state();
let input = "Before\n {\"tool\": \"shell\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, input, "Indented tool JSON should pass through");
}
#[test]
fn test_tab_indented_tool_json_passes_through() {
// Tab-indented JSON should also pass through
reset_json_tool_state();
let input = "Before\n\t{\"tool\": \"shell\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, input, "Tab-indented tool JSON should pass through");
}
}

View File

@@ -1,323 +0,0 @@
//! Centralized formatting for g3 system status messages.
//!
//! Provides consistent "g3:" prefixed status messages with progress indicators
//! and completion statuses. Use `progress()` + `done()`/`failed()` for two-step
//! output, or `complete()` for one-shot messages.
use crossterm::style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor};
use std::io::{self, Write};
/// Status types for g3 system messages
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
/// Success - bold green "[done]"
Done,
/// Failure - red "[failed]"
Failed,
/// Error with message - red "[error: <msg>]"
Error(String),
/// Custom status - plain "[<status>]"
Custom(String),
/// Resolved status - for thinning operations
Resolved,
/// Insufficient - for thinning operations
Insufficient,
/// No changes - for thinning operations that didn't modify anything
NoChanges,
}
impl Status {
pub fn parse(s: &str) -> Self {
match s {
"done" => Status::Done,
"failed" => Status::Failed,
"resolved" => Status::Resolved,
"insufficient" => Status::Insufficient,
s if s.starts_with("error:") => Status::Error(s[6..].trim().to_string()),
s if s.starts_with("error") => Status::Error(s[5..].trim().to_string()),
other => Status::Custom(other.to_string()),
}
}
}
/// Centralized g3 system status message formatting
pub struct G3Status;
impl G3Status {
/// Print "g3: <message> ..." (no newline). Complete with `done()` or `failed()`.
pub fn progress(message: &str) {
print!(
"{}{}g3:{}{} {} ...",
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::Green),
ResetColor,
SetAttribute(Attribute::Reset),
message
);
let _ = io::stdout().flush();
}
/// Print "g3: <message> ..." with newline (standalone progress).
pub fn progress_ln(message: &str) {
println!(
"{}{}g3:{}{} {} ...",
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::Green),
ResetColor,
SetAttribute(Attribute::Reset),
message
);
}
pub fn done() {
println!(
" {}{}[done]{}",
SetForegroundColor(Color::Green),
SetAttribute(Attribute::Bold),
ResetColor
);
}
pub fn failed() {
println!(
" {}[failed]{}",
SetForegroundColor(Color::Red),
ResetColor
);
}
pub fn error(msg: &str) {
println!(
" {}[error: {}]{}",
SetForegroundColor(Color::Red),
msg,
ResetColor
);
}
pub fn status(status: &Status) {
match status {
Status::Done => Self::done(),
Status::Failed => Self::failed(),
Status::Error(msg) => Self::error(msg),
Status::Resolved => {
println!(
" {}{}[resolved]{}",
SetForegroundColor(Color::Green),
SetAttribute(Attribute::Bold),
ResetColor
);
}
Status::Insufficient => {
println!(
" {}[insufficient]{}",
SetForegroundColor(Color::Yellow),
ResetColor
);
}
Status::Custom(s) => {
println!(" [{}]", s);
}
Status::NoChanges => {
println!(
" {}[no changes]{}",
SetForegroundColor(Color::DarkGrey),
ResetColor
);
}
}
}
/// Print "g3: <message> ... [status]" (one-shot).
pub fn complete(message: &str, status: Status) {
Self::progress(message);
Self::status(&status);
}
#[allow(dead_code)]
pub fn info(message: &str) {
println!(
"{}... {}{}",
SetForegroundColor(Color::DarkGrey),
message,
ResetColor
);
}
/// Print info inline (moves cursor up, appends to previous line).
pub fn info_inline(message: &str) {
print!(
"\x1b[1A\x1b[999C {}... {}{}\n",
SetForegroundColor(Color::DarkGrey),
message,
ResetColor
);
let _ = io::stdout().flush();
}
/// Format a status for inline use (returns formatted string).
pub fn format_status(status: &Status) -> String {
match status {
Status::Done => format!(
"{}{}[done]{}",
SetForegroundColor(Color::Green),
SetAttribute(Attribute::Bold),
ResetColor
),
Status::Failed => format!(
"{}[failed]{}",
SetForegroundColor(Color::Red),
ResetColor
),
Status::Error(msg) => format!(
"{}{}{}",
SetForegroundColor(Color::Red),
if msg.is_empty() {
"[error]".to_string()
} else {
format!("[error: {}]", msg)
},
ResetColor
),
Status::Resolved => format!(
"{}{}[resolved]{}",
SetForegroundColor(Color::Green),
SetAttribute(Attribute::Bold),
ResetColor
),
Status::Insufficient => format!(
"{}[insufficient]{}",
SetForegroundColor(Color::Yellow),
ResetColor
),
Status::Custom(s) => format!("[{}]", s),
Status::NoChanges => format!(
"{}[no changes]{}",
SetForegroundColor(Color::DarkGrey),
ResetColor
),
}
}
pub fn format_prefix() -> String {
format!(
"{}{}g3:{}{}",
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::Green),
ResetColor,
SetAttribute(Attribute::Reset),
)
}
/// Print "... resuming <session_id> [status]" with cyan session ID.
pub fn resuming(session_id: &str, status: Status) {
let status_str = Self::format_status(&status);
println!(
"... resuming {}{}{} {}",
SetForegroundColor(Color::Cyan),
session_id,
ResetColor,
status_str
);
}
pub fn resuming_summary(session_id: &str) {
let status_str = Self::format_status(&Status::Done);
println!(
"... resuming {}{}{} (summary) {}",
SetForegroundColor(Color::Cyan),
session_id,
ResetColor,
status_str
);
}
/// Print thinning result: "g3: thinning context ... 70% -> 40% ... [done]"
pub fn thin_result(result: &g3_core::ThinResult) {
use g3_core::ThinScope;
let scope_desc = match result.scope {
ThinScope::FirstThird => "thinning context",
ThinScope::All => "thinning context (full)",
};
if result.had_changes {
// Format: "g3: thinning context ... 70% -> 40% ... [done]"
print!(
"{} {} ... {}% -> {}% ...",
Self::format_prefix(),
scope_desc,
result.before_percentage,
result.after_percentage
);
Self::done();
} else {
// Format: "g3: thinning context ... 70% ... [no changes]"
Self::complete(&format!("{} ... {}%", scope_desc, result.before_percentage), Status::NoChanges);
}
}
/// Print "g3: <message> <path> [status]" with cyan path.
pub fn complete_with_path(message: &str, path: &str, status: Status) {
print!(
"{} {} {}{}{}",
Self::format_prefix(),
message,
SetForegroundColor(Color::Cyan),
path,
ResetColor
);
Self::status(&status);
}
/// Print project loading status: "g3: loading <project-name> .. ✓ file1 ✓ file2 .. [done]"
///
/// Used by the /project command to show what project files were loaded.
pub fn loading_project(project_name: &str, loaded_files_status: &str) {
print!(
"{} loading {}{}{} .. {} ..",
Self::format_prefix(),
SetForegroundColor(Color::Cyan),
project_name,
ResetColor,
loaded_files_status
);
Self::done();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_from_str() {
assert_eq!(Status::parse("done"), Status::Done);
assert_eq!(Status::parse("failed"), Status::Failed);
assert_eq!(Status::parse("resolved"), Status::Resolved);
assert_eq!(Status::parse("insufficient"), Status::Insufficient);
assert_eq!(Status::parse("error: timeout"), Status::Error("timeout".to_string()));
assert_eq!(Status::parse("error timeout"), Status::Error("timeout".to_string()));
assert_eq!(Status::parse("custom"), Status::Custom("custom".to_string()));
}
#[test]
fn test_format_status_contains_ansi() {
let done = G3Status::format_status(&Status::Done);
assert!(done.contains("[done]"));
assert!(done.contains("\x1b")); // Contains ANSI escape
let failed = G3Status::format_status(&Status::Failed);
assert!(failed.contains("[failed]"));
let error = G3Status::format_status(&Status::Error("test".to_string()));
assert!(error.contains("[error: test]"));
}
#[test]
fn test_format_prefix() {
let prefix = G3Status::format_prefix();
assert!(prefix.contains("g3:"));
assert!(prefix.contains("\x1b")); // Contains ANSI escape
}
}

View File

@@ -1,400 +0,0 @@
//! Interactive mode for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError;
use rustyline::{Config, Editor};
use crate::completion::G3Helper;
use std::path::Path;
use tracing::{debug, error};
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use crate::commands::handle_command;
use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path};
use crate::g3_status::{G3Status, Status};
use crate::project::Project;
use crate::project_files::extract_readme_heading;
use crate::simple_output::SimpleOutput;
use crate::task_execution::execute_task_with_retry;
use crate::utils::display_context_progress;
/// Build the interactive prompt string.
///
/// Format:
/// - Multiline mode: `"... > "`
/// - No project: `"agent_name> "` (defaults to "g3")
/// - With project: `"agent_name | project_name> "`
pub fn build_prompt(in_multiline: bool, agent_name: Option<&str>, active_project: &Option<Project>) -> String {
if in_multiline {
"... > ".to_string()
} else {
let base_name = agent_name.unwrap_or("g3");
if let Some(project) = active_project {
let project_name = project.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
format!("{} | {}> ", base_name, project_name)
} else {
format!("{}> ", base_name)
}
}
}
/// Run interactive mode with console output.
/// If `agent_name` is Some, we're in agent+chat mode: skip session resume/verbose welcome,
/// and use the agent name as the prompt (e.g., "butler>").
pub async fn run_interactive<W: UiWriter>(
mut agent: Agent<W>,
show_prompt: bool,
show_code: bool,
combined_content: Option<String>,
workspace_path: &Path,
new_session: bool,
agent_name: Option<&str>,
) -> Result<()> {
let output = SimpleOutput::new();
let from_agent_mode = agent_name.is_some();
// Check for session continuation (skip if --new-session was passed or coming from agent mode)
// Agent mode with --chat should start fresh without prompting
if !new_session && !from_agent_mode {
if let Ok(Some(continuation)) = g3_core::load_continuation() {
// Print session info and prompt on same line (no newline)
print!(
"\n >> session in progress: {}{}{} | {:.1}% used | resume? [y/n] ",
SetForegroundColor(Color::Cyan),
&continuation.session_id[..continuation.session_id.len().min(20)],
ResetColor,
continuation.context_percentage
);
use std::io::Write;
std::io::stdout().flush()?;
// Read user input
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" || input == "yes" {
// Resume the session
match agent.restore_from_continuation(&continuation) {
Ok(true) => {
G3Status::resuming(&continuation.session_id, Status::Done);
}
Ok(false) => {
G3Status::resuming_summary(&continuation.session_id);
}
Err(e) => {
G3Status::resuming(&continuation.session_id, Status::Error(e.to_string()));
// Clear the invalid continuation
let _ = g3_core::clear_continuation();
}
}
} else {
// User declined, clear the continuation
G3Status::info_inline("starting fresh");
let _ = g3_core::clear_continuation();
}
}
}
// Skip verbose welcome when coming from agent mode (it already printed context info)
if !from_agent_mode {
output.print("");
output.print("g3 programming agent");
output.print(" >> what shall we build today?");
output.print("");
// Display provider and model information
match agent.get_provider_info() {
Ok((provider, model)) => {
print!(
"🔧 {}{}{} | {}{}{}\n",
SetForegroundColor(Color::Cyan),
provider,
ResetColor,
SetForegroundColor(Color::Yellow),
model,
ResetColor
);
}
Err(e) => {
error!("Failed to get provider info: {}", e);
}
}
// Display message if AGENTS.md or README was loaded
if let Some(ref content) = combined_content {
let loaded = LoadedContent::from_combined_content(content);
// Extract project name if README is loaded
if loaded.has_readme {
if let Some(name) = extract_readme_heading(content) {
print_project_heading(&name);
}
}
print_loaded_status(&loaded);
}
// Display workspace path
print_workspace_path(workspace_path);
output.print("");
}
// Initialize rustyline editor with history
let config = Config::builder()
.completion_type(rustyline::CompletionType::List)
.build();
let mut rl = Editor::with_config(config)?;
rl.set_helper(Some(G3Helper::new()));
// Try to load history from a file in the user's home directory
let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_history");
path
});
if let Some(ref history_path) = history_file {
let _ = rl.load_history(history_path);
}
// Track multiline input
let mut multiline_buffer = String::new();
let mut in_multiline = false;
// Track active project
let mut active_project: Option<Project> = None;
loop {
// Display context window progress bar before each prompt
display_context_progress(&agent, &output);
// Build prompt
let prompt = build_prompt(in_multiline, agent_name, &active_project);
let readline = rl.readline(&prompt);
match readline {
Ok(line) => {
let trimmed = line.trim_end();
// Check if line ends with backslash for continuation
if let Some(without_backslash) = trimmed.strip_suffix('\\') {
// Remove the backslash and add to buffer
multiline_buffer.push_str(without_backslash);
multiline_buffer.push('\n');
in_multiline = true;
continue;
}
// If we're in multiline mode and no backslash, this is the final line
if in_multiline {
multiline_buffer.push_str(&line);
in_multiline = false;
// Process the complete multiline input
let input = multiline_buffer.trim().to_string();
multiline_buffer.clear();
if input.is_empty() {
continue;
}
// Add complete multiline to history
rl.add_history_entry(&input)?;
if input == "exit" || input == "quit" {
break;
}
// Process the multiline input
let completed = execute_task_with_retry(
&mut agent,
&input,
show_prompt,
show_code,
&output,
)
.await;
if !completed {
break;
}
// Send auto-memory reminder if enabled and tools were called
// Skip per-turn reminders when from_agent_mode - we'll send once on exit
if !from_agent_mode {
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
}
} else {
// Single line input
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
break;
}
// Add to history
rl.add_history_entry(&input)?;
// Check for control commands
if input.starts_with('/') {
let should_continue = handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await?;
if should_continue {
continue;
} else {
break;
}
}
// Process the single line input
let completed = execute_task_with_retry(
&mut agent,
&input,
show_prompt,
show_code,
&output,
)
.await;
if !completed {
break;
}
// Send auto-memory reminder if enabled and tools were called
// Skip per-turn reminders when from_agent_mode - we'll send once on exit
if !from_agent_mode {
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
}
}
}
Err(ReadlineError::Interrupted) => {
// Ctrl-C pressed
output.print("");
break;
}
Err(ReadlineError::Eof) => {
output.print("CTRL-D");
break;
}
Err(err) => {
error!("Error: {:?}", err);
break;
}
}
}
// Save history before exiting
if let Some(ref history_path) = history_file {
let _ = rl.save_history(history_path);
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
// Send auto-memory reminder once on exit when in agent+chat mode
// (Per-turn reminders were skipped to avoid being too onerous)
if from_agent_mode {
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder on exit failed: {}", e);
}
}
output.print("👋 Goodbye!");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_project(name: &str) -> Project {
Project {
path: PathBuf::from(format!("/test/projects/{}", name)),
content: "test content".to_string(),
loaded_files: vec!["brief.md".to_string()],
}
}
#[test]
fn test_build_prompt_default() {
let prompt = build_prompt(false, None, &None);
assert_eq!(prompt, "g3> ");
}
#[test]
fn test_build_prompt_with_agent_name() {
let prompt = build_prompt(false, Some("butler"), &None);
assert_eq!(prompt, "butler> ");
}
#[test]
fn test_build_prompt_multiline() {
let prompt = build_prompt(true, None, &None);
assert_eq!(prompt, "... > ");
// Multiline takes precedence over agent name
let prompt = build_prompt(true, Some("butler"), &None);
assert_eq!(prompt, "... > ");
// Multiline takes precedence over project
let project = Some(create_test_project("myapp"));
let prompt = build_prompt(true, None, &project);
assert_eq!(prompt, "... > ");
}
#[test]
fn test_build_prompt_with_project() {
let project = Some(create_test_project("myapp"));
let prompt = build_prompt(false, None, &project);
// Should contain the project name in the prompt
assert!(prompt.contains("g3"));
assert!(prompt.contains("myapp"));
assert!(prompt.contains("|"));
}
#[test]
fn test_build_prompt_with_agent_and_project() {
let project = Some(create_test_project("myapp"));
let prompt = build_prompt(false, Some("carmack"), &project);
// Should contain both agent name and project name
assert!(prompt.contains("carmack"));
assert!(prompt.contains("myapp"));
assert!(prompt.contains("|"));
}
#[test]
fn test_build_prompt_unproject_resets() {
// Simulate /project loading
let project = Some(create_test_project("myapp"));
let prompt_with_project = build_prompt(false, None, &project);
assert!(prompt_with_project.contains("myapp"));
// Simulate /unproject (sets active_project to None)
let prompt_after_unproject = build_prompt(false, None, &None);
assert_eq!(prompt_after_unproject, "g3> ");
assert!(!prompt_after_unproject.contains("myapp"));
}
#[test]
fn test_build_prompt_project_name_from_path() {
// Test that project name is extracted from path
let project = Some(Project {
path: PathBuf::from("/Users/dev/projects/awesome-app"),
content: "test".to_string(),
loaded_files: vec![],
});
let prompt = build_prompt(false, None, &project);
assert!(prompt.contains("awesome-app"));
}
}

View File

@@ -1,247 +0,0 @@
//! Language-specific prompt injection.
//!
//! Detects programming languages in the workspace and injects relevant
//! toolchain guidance into the system prompt.
//!
//! Language prompts are embedded at compile time from `prompts/langs/*.md`.
use std::path::Path;
/// Embedded language prompts, keyed by language name.
/// The key should match common file extensions or language identifiers.
static LANGUAGE_PROMPTS: &[(&str, &[&str], &str)] = &[
// (language_name, file_extensions, prompt_content)
(
"rust",
&[".rs"],
"", // No base Rust prompt; agent-specific prompts handle this
),
(
"racket",
&[".rkt", ".rktl", ".rktd", ".scrbl"],
include_str!("../../../prompts/langs/racket.md"),
),
];
/// Embedded agent-specific language prompts.
/// Format: (agent_name, language_name, prompt_content)
static AGENT_LANGUAGE_PROMPTS: &[(&str, &str, &str)] = &[
// (agent_name, language_name, prompt_content)
("carmack", "racket", include_str!("../../../prompts/langs/carmack.racket.md")),
("carmack", "rust", include_str!("../../../prompts/langs/carmack.rust.md")),
];
/// Detect languages present in the workspace by scanning for file extensions.
/// Returns a list of detected language names.
pub fn detect_languages(workspace_dir: &Path) -> Vec<&'static str> {
let mut detected = Vec::new();
for (lang_name, extensions, _) in LANGUAGE_PROMPTS {
if has_files_with_extensions(workspace_dir, extensions) {
detected.push(*lang_name);
}
}
detected
}
/// Check if the workspace contains files with any of the given extensions.
/// Scans up to a reasonable depth to avoid slow startup on large repos.
fn has_files_with_extensions(workspace_dir: &Path, extensions: &[&str]) -> bool {
// Quick check: scan top-level and one level deep
// This avoids slow startup on large repos while catching most projects
scan_directory_for_extensions(workspace_dir, extensions, 2)
}
/// Recursively scan a directory for files with given extensions, up to max_depth.
fn scan_directory_for_extensions(dir: &Path, extensions: &[&str], max_depth: usize) -> bool {
if max_depth == 0 {
return false;
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return false,
};
for entry in entries.flatten() {
let path = entry.path();
// Skip hidden directories and common non-source directories
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "vendor" {
continue;
}
}
if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
for ext in extensions {
if name.ends_with(ext) {
return true;
}
}
}
} else if path.is_dir() {
if scan_directory_for_extensions(&path, extensions, max_depth - 1) {
return true;
}
}
}
false
}
/// Get the prompt content for a specific language.
pub fn get_language_prompt(lang: &str) -> Option<&'static str> {
LANGUAGE_PROMPTS
.iter()
.find(|(name, _, _)| *name == lang)
.map(|(_, _, content)| *content)
}
/// Get all language prompts for detected languages in the workspace.
/// Returns formatted content ready for injection into the system prompt.
pub fn get_language_prompts_for_workspace(workspace_dir: &Path) -> Option<String> {
let detected = detect_languages(workspace_dir);
if detected.is_empty() {
return None;
}
let mut prompts = Vec::new();
for lang in detected {
if let Some(content) = get_language_prompt(lang) {
prompts.push(content);
}
}
if prompts.is_empty() {
return None;
}
Some(format!(
"🔧 Language-Specific Guidance:\n\n{}",
prompts.join("\n\n---\n\n")
))
}
/// List all available language prompts.
pub fn list_available_languages() -> Vec<&'static str> {
LANGUAGE_PROMPTS.iter().map(|(name, _, _)| *name).collect()
}
/// Get agent-specific language prompt for a specific agent and language.
pub fn get_agent_language_prompt(agent_name: &str, lang: &str) -> Option<&'static str> {
AGENT_LANGUAGE_PROMPTS
.iter()
.find(|(agent, language, _)| *agent == agent_name && *language == lang)
.map(|(_, _, content)| *content)
}
/// Get agent-specific language prompts for detected languages in the workspace.
/// Returns formatted content ready for injection into the agent's system prompt.
#[allow(dead_code)]
pub fn get_agent_language_prompts_for_workspace(
workspace_dir: &Path,
agent_name: &str,
) -> Option<String> {
let (content, _) = get_agent_language_prompts_for_workspace_with_langs(workspace_dir, agent_name);
content
}
/// Get agent-specific language prompts for detected languages in the workspace.
/// Returns both the formatted content and the list of languages that had matching prompts.
pub fn get_agent_language_prompts_for_workspace_with_langs(
workspace_dir: &Path,
agent_name: &str,
) -> (Option<String>, Vec<&'static str>) {
let detected = detect_languages(workspace_dir);
let mut prompts = Vec::new();
let mut matched_langs = Vec::new();
for lang in detected {
if let Some(content) = get_agent_language_prompt(agent_name, lang) {
prompts.push(content.to_string());
matched_langs.push(lang);
}
}
let content = if prompts.is_empty() { None } else { Some(prompts.join("\n\n---\n\n")) };
(content, matched_langs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_racket_prompt_embedded() {
let prompt = get_language_prompt("racket");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("raco"));
}
#[test]
fn test_list_available_languages() {
let langs = list_available_languages();
assert!(langs.contains(&"racket"));
}
#[test]
fn test_detect_racket_files() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let detected = detect_languages(temp_dir.path());
assert!(detected.contains(&"racket"));
}
#[test]
fn test_no_detection_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let detected = detect_languages(temp_dir.path());
assert!(detected.is_empty());
}
#[test]
fn test_get_prompts_for_workspace() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let prompts = get_language_prompts_for_workspace(temp_dir.path());
assert!(prompts.is_some());
let content = prompts.unwrap();
assert!(content.contains("🔧 Language-Specific Guidance"));
assert!(content.contains("raco"));
}
#[test]
fn test_carmack_racket_prompt_embedded() {
let prompt = get_agent_language_prompt("carmack", "racket");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("obvious, readable Racket"));
}
#[test]
fn test_agent_language_prompt_not_found() {
let prompt = get_agent_language_prompt("nonexistent", "racket");
assert!(prompt.is_none());
}
#[test]
fn test_get_agent_prompts_for_workspace() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let prompts = get_agent_language_prompts_for_workspace(temp_dir.path(), "carmack");
assert!(prompts.is_some());
let content = prompts.unwrap();
assert!(content.contains("obvious, readable Racket"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
use g3_core::ui_writer::UiWriter;
use std::io::{self, Write};
/// Machine-mode implementation of UiWriter that prints plain, unformatted output
/// This is designed for programmatic consumption and outputs everything verbatim
pub struct MachineUiWriter;
impl MachineUiWriter {
pub fn new() -> Self {
Self
}
}
impl UiWriter for MachineUiWriter {
fn print(&self, message: &str) {
print!("{}", message);
}
fn println(&self, message: &str) {
println!("{}", message);
}
fn print_inline(&self, message: &str) {
print!("{}", message);
let _ = io::stdout().flush();
}
fn print_system_prompt(&self, prompt: &str) {
println!("SYSTEM_PROMPT:");
println!("{}", prompt);
println!("END_SYSTEM_PROMPT");
println!();
}
fn print_context_status(&self, message: &str) {
println!("CONTEXT_STATUS: {}", message);
}
fn print_context_thinning(&self, message: &str) {
println!("CONTEXT_THINNING: {}", message);
}
fn print_tool_header(&self, tool_name: &str) {
println!("TOOL_CALL: {}", tool_name);
}
fn print_tool_arg(&self, key: &str, value: &str) {
println!("TOOL_ARG: {} = {}", key, value);
}
fn print_tool_output_header(&self) {
println!("TOOL_OUTPUT:");
}
fn update_tool_output_line(&self, line: &str) {
println!("{}", line);
}
fn print_tool_output_line(&self, line: &str) {
println!("{}", line);
}
fn print_tool_output_summary(&self, count: usize) {
println!("TOOL_OUTPUT_LINES: {}", count);
}
fn print_tool_timing(&self, duration_str: &str) {
println!("TOOL_DURATION: {}", duration_str);
println!("END_TOOL_OUTPUT");
println!();
}
fn print_agent_prompt(&self) {
println!("AGENT_RESPONSE:");
let _ = io::stdout().flush();
}
fn print_agent_response(&self, content: &str) {
print!("{}", content);
let _ = io::stdout().flush();
}
fn notify_sse_received(&self) {
// No-op for machine mode
}
fn flush(&self) {
let _ = io::stdout().flush();
}
fn wants_full_output(&self) -> bool {
true // Machine mode wants complete, untruncated output
}
}

View File

@@ -1,147 +0,0 @@
//! Turn metrics and histogram generation for performance visualization.
use std::time::Duration;
/// Metrics captured for a single turn of interaction.
#[derive(Debug, Clone)]
pub struct TurnMetrics {
pub turn_number: usize,
pub tokens_used: u32,
pub wall_clock_time: Duration,
}
/// Format a Duration as human-readable elapsed time (e.g., "1h 23m 45s").
pub fn format_elapsed_time(duration: Duration) -> String {
let total_secs = duration.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
match (hours, minutes, seconds) {
(h, m, s) if h > 0 => format!("{}h {}m {}s", h, m, s),
(_, m, s) if m > 0 => format!("{}m {}s", m, s),
(_, _, s) if s > 0 => format!("{}s", s),
_ => format!("{}ms", duration.as_millis()),
}
}
/// Generate a histogram showing tokens used and wall clock time per turn.
pub fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
if turn_metrics.is_empty() {
return " No turn data available".to_string();
}
const MAX_BAR_WIDTH: usize = 40;
const TOKEN_CHAR: char = '█';
const TIME_CHAR: char = '▓';
let max_tokens = turn_metrics.iter().map(|t| t.tokens_used).max().unwrap_or(1);
let max_time_ms = turn_metrics
.iter()
.map(|t| t.wall_clock_time.as_millis().min(u32::MAX as u128) as u32)
.max()
.unwrap_or(1);
let mut histogram = String::new();
histogram.push_str("\n📊 Per-Turn Performance Histogram:\n");
histogram.push_str(&format!(" {} = Tokens Used (max: {})\n", TOKEN_CHAR, max_tokens));
histogram.push_str(&format!(
" {} = Wall Clock Time (max: {:.1}s)\n\n",
TIME_CHAR,
max_time_ms as f64 / 1000.0
));
for metrics in turn_metrics {
let turn_time_ms = metrics.wall_clock_time.as_millis().min(u32::MAX as u128) as u32;
let token_bar_len = scale_bar(metrics.tokens_used, max_tokens, MAX_BAR_WIDTH);
let time_bar_len = scale_bar(turn_time_ms, max_time_ms, MAX_BAR_WIDTH);
let time_str = format_duration_ms(turn_time_ms);
let token_bar = TOKEN_CHAR.to_string().repeat(token_bar_len);
let time_bar = TIME_CHAR.to_string().repeat(time_bar_len);
histogram.push_str(&format!(
" Turn {:2}: {:>6} tokens │{:<40}\n",
metrics.turn_number, metrics.tokens_used, token_bar
));
histogram.push_str(&format!(" {:>6}{:<40}\n", time_str, time_bar));
// Separator between turns (except for last)
if metrics.turn_number != turn_metrics.last().unwrap().turn_number {
histogram.push_str(
" ────────────┼────────────────────────────────────────┤\n",
);
}
}
append_summary_statistics(&mut histogram, turn_metrics);
histogram
}
/// Scale a value to a bar length proportional to max.
fn scale_bar(value: u32, max: u32, max_width: usize) -> usize {
if max == 0 {
0
} else {
((value as f64 / max as f64) * max_width as f64) as usize
}
}
/// Format milliseconds as a human-readable duration string.
fn format_duration_ms(ms: u32) -> String {
match ms {
ms if ms < 1000 => format!("{}ms", ms),
ms if ms < 60_000 => format!("{:.1}s", ms as f64 / 1000.0),
ms => {
let minutes = ms / 60_000;
let seconds = (ms % 60_000) as f64 / 1000.0;
format!("{}m{:.1}s", minutes, seconds)
}
}
}
/// Append summary statistics to the histogram output.
fn append_summary_statistics(histogram: &mut String, turn_metrics: &[TurnMetrics]) {
let total_tokens: u32 = turn_metrics.iter().map(|t| t.tokens_used).sum();
let total_time: Duration = turn_metrics.iter().map(|t| t.wall_clock_time).sum();
let avg_tokens = total_tokens as f64 / turn_metrics.len() as f64;
let avg_time_ms = total_time.as_millis() as f64 / turn_metrics.len() as f64;
histogram.push_str("\n📈 Summary Statistics:\n");
histogram.push_str(&format!(
" • Total Tokens: {} across {} turns\n",
total_tokens,
turn_metrics.len()
));
histogram.push_str(&format!(" • Average Tokens/Turn: {:.1}\n", avg_tokens));
histogram.push_str(&format!(" • Total Time: {:.1}s\n", total_time.as_secs_f64()));
histogram.push_str(&format!(" • Average Time/Turn: {:.1}s\n", avg_time_ms / 1000.0));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_elapsed_time() {
assert_eq!(format_elapsed_time(Duration::from_millis(500)), "500ms");
assert_eq!(format_elapsed_time(Duration::from_secs(45)), "45s");
assert_eq!(format_elapsed_time(Duration::from_secs(90)), "1m 30s");
assert_eq!(format_elapsed_time(Duration::from_secs(3661)), "1h 1m 1s");
}
#[test]
fn test_empty_histogram() {
let result = generate_turn_histogram(&[]);
assert!(result.contains("No turn data available"));
}
#[test]
fn test_scale_bar() {
assert_eq!(scale_bar(50, 100, 40), 20);
assert_eq!(scale_bar(100, 100, 40), 40);
assert_eq!(scale_bar(0, 100, 40), 0);
assert_eq!(scale_bar(50, 0, 40), 0);
}
}

View File

@@ -1,194 +0,0 @@
//! Project loading and management for the /project command.
//!
//! Projects allow loading context from a specific project directory that persists
//! in the system message and survives compaction/dehydration.
use std::path::{Path, PathBuf};
/// Represents an active project with its loaded content.
#[derive(Debug, Clone)]
pub struct Project {
/// Absolute path to the project directory
pub path: PathBuf,
/// Combined content blob to append to system message
pub content: String,
/// List of files that were successfully loaded
pub loaded_files: Vec<String>,
}
impl Project {
/// Load a project from the given absolute path.
///
/// Loads the following files if present (skips missing silently):
/// - brief.md
/// - contacts.yaml
/// - status.md
///
/// Also loads projects.md from the workspace root if present.
pub fn load(project_path: &Path, workspace_dir: &Path) -> Option<Self> {
let mut content_parts = Vec::new();
let mut loaded_files = Vec::new();
// Load workspace-level projects.md if present
let projects_md_path = workspace_dir.join("projects.md");
if projects_md_path.exists() {
if let Ok(projects_content) = std::fs::read_to_string(&projects_md_path) {
content_parts.push(format!(
"=== PROJECT INSTRUCTIONS ===\n{}\n=== END PROJECT INSTRUCTIONS ===",
projects_content.trim()
));
loaded_files.push("projects.md".to_string());
}
}
// Load project-specific files
let project_files = ["brief.md", "contacts.yaml", "status.md"];
let mut project_content_parts = Vec::new();
for filename in &project_files {
let file_path = project_path.join(filename);
if file_path.exists() {
if let Ok(file_content) = std::fs::read_to_string(&file_path) {
let section_name = match *filename {
"brief.md" => "Brief",
"contacts.yaml" => "Contacts",
"status.md" => "Status",
_ => filename,
};
project_content_parts.push(format!(
"## {}\n{}",
section_name,
file_content.trim()
));
loaded_files.push(filename.to_string());
}
}
}
// If we loaded any project-specific files, add the active project header
if !project_content_parts.is_empty() {
content_parts.push(format!(
"=== ACTIVE PROJECT: {} ===\n{}",
project_path.display(),
project_content_parts.join("\n\n")
));
}
// Only return a project if we loaded something
if loaded_files.is_empty() {
return None;
}
Some(Project {
path: project_path.to_path_buf(),
content: content_parts.join("\n\n"),
loaded_files,
})
}
/// Format the loaded files status message (e.g., "✓ brief.md ✓ status.md")
pub fn format_loaded_status(&self) -> String {
self.loaded_files
.iter()
.map(|f| format!("{}", f))
.collect::<Vec<_>>()
.join(" ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_format_loaded_status() {
let project = Project {
path: PathBuf::from("/test/project"),
content: String::new(),
loaded_files: vec!["brief.md".to_string(), "status.md".to_string()],
};
assert_eq!(project.format_loaded_status(), "✓ brief.md ✓ status.md");
}
#[test]
fn test_format_loaded_status_single_file() {
let project = Project {
path: PathBuf::from("/test/project"),
content: String::new(),
loaded_files: vec!["brief.md".to_string()],
};
assert_eq!(project.format_loaded_status(), "✓ brief.md");
}
#[test]
fn test_load_project_with_all_files() {
let workspace = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// Create project files
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
fs::write(project_dir.path().join("contacts.yaml"), "contacts: []").unwrap();
fs::write(project_dir.path().join("status.md"), "In progress").unwrap();
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
assert_eq!(project.loaded_files.len(), 3);
assert!(project.loaded_files.contains(&"brief.md".to_string()));
assert!(project.loaded_files.contains(&"contacts.yaml".to_string()));
assert!(project.loaded_files.contains(&"status.md".to_string()));
assert!(project.content.contains("=== ACTIVE PROJECT:"));
assert!(project.content.contains("## Brief"));
assert!(project.content.contains("## Contacts"));
assert!(project.content.contains("## Status"));
}
#[test]
fn test_load_project_with_workspace_projects_md() {
let workspace = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// Create workspace projects.md
fs::write(workspace.path().join("projects.md"), "Global project instructions").unwrap();
// Create one project file
fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap();
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
assert_eq!(project.loaded_files.len(), 2);
assert!(project.loaded_files.contains(&"projects.md".to_string()));
assert!(project.loaded_files.contains(&"brief.md".to_string()));
assert!(project.content.contains("=== PROJECT INSTRUCTIONS ==="));
assert!(project.content.contains("=== END PROJECT INSTRUCTIONS ==="));
assert!(project.content.contains("=== ACTIVE PROJECT:"));
}
#[test]
fn test_load_project_missing_files() {
let workspace = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// Create only one file
fs::write(project_dir.path().join("status.md"), "Status only").unwrap();
let project = Project::load(project_dir.path(), workspace.path()).unwrap();
assert_eq!(project.loaded_files.len(), 1);
assert!(project.loaded_files.contains(&"status.md".to_string()));
assert!(!project.content.contains("## Brief"));
assert!(project.content.contains("## Status"));
}
#[test]
fn test_load_project_no_files() {
let workspace = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// No files created
let project = Project::load(project_dir.path(), workspace.path());
assert!(project.is_none());
}
}

View File

@@ -1,407 +0,0 @@
//! Project file reading utilities.
//!
//! Reads AGENTS.md, README.md, and workspace memory files from the workspace.
use std::path::Path;
use tracing::error;
use crate::template::process_template;
/// Read AGENTS.md configuration from the workspace directory.
/// Returns formatted content with emoji prefix, or None if not found.
pub fn read_agents_config(workspace_dir: &Path) -> Option<String> {
// Try AGENTS.md first, then agents.md
let paths = [
(workspace_dir.join("AGENTS.md"), "AGENTS.md"),
(workspace_dir.join("agents.md"), "agents.md"),
];
for (path, name) in &paths {
if path.exists() {
match std::fs::read_to_string(path) {
Ok(content) => {
return Some(format!("🤖 Agent Configuration (from {}):{}\n{}", name, "\n", content));
}
Err(e) => {
error!("Failed to read {}: {}", name, e);
}
}
}
}
None
}
/// Read README from the workspace directory if it's a project directory.
/// Returns formatted content with emoji prefix, or None if not found.
pub fn read_project_readme(workspace_dir: &Path) -> Option<String> {
// Only read README if we're in a project directory
let is_project_dir = workspace_dir.join(".g3").exists() || workspace_dir.join(".git").exists();
if !is_project_dir {
return None;
}
const README_NAMES: &[&str] = &[
"README.md",
"README.MD",
"readme.md",
"Readme.md",
"README",
"README.txt",
"README.rst",
];
for name in README_NAMES {
let path = workspace_dir.join(name);
if path.exists() {
match std::fs::read_to_string(&path) {
Ok(content) => {
return Some(format!("📚 Project README (from {}):{}\n{}", name, "\n", content));
}
Err(e) => {
error!("Failed to read {}: {}", path.display(), e);
}
}
}
}
None
}
/// Read workspace memory from analysis/memory.md in the workspace directory.
/// Returns formatted content with emoji prefix and size info, or None if not found.
pub fn read_workspace_memory(workspace_dir: &Path) -> Option<String> {
let memory_path = workspace_dir.join("analysis").join("memory.md");
if !memory_path.exists() {
return None;
}
match std::fs::read_to_string(&memory_path) {
Ok(content) => {
let size = format_size(content.len());
Some(format!(
"=== Workspace Memory (read from analysis/memory.md, {}) ===\n{}\n=== End Workspace Memory ===",
size,
content
))
}
Err(_) => None,
}
}
/// Read include prompt content from a specified file path.
/// Returns formatted content with emoji prefix, or None if path is None or file doesn't exist.
pub fn read_include_prompt(path: Option<&std::path::Path>) -> Option<String> {
let path = path?;
if !path.exists() {
tracing::error!("Include prompt file not found: {}", path.display());
return None;
}
match std::fs::read_to_string(path) {
Ok(content) => {
let processed = process_template(&content);
Some(format!("📎 Included Prompt (from {}):\n{}", path.display(), processed))
}
Err(e) => {
tracing::error!("Failed to read include prompt file {}: {}", path.display(), e);
None
}
}
}
/// Combine AGENTS.md, README, and memory content into a single string.
///
/// Returns None if all inputs are None, otherwise joins non-None parts with double newlines.
/// Prepends the current working directory to help the LLM avoid path hallucinations.
///
/// Order: Working Directory → AGENTS.md → README → Language prompts → Include prompt → Memory
pub fn combine_project_content(
agents_content: Option<String>,
readme_content: Option<String>,
memory_content: Option<String>,
language_content: Option<String>,
include_prompt: Option<String>,
workspace_dir: &Path,
) -> Option<String> {
// Always include working directory to prevent LLM from hallucinating paths
let cwd_info = format!("📂 Working Directory: {}", workspace_dir.display());
// Order: cwd → agents → readme → language → include_prompt → memory
// Include prompt comes BEFORE memory so memory is always last (most recent context)
let parts: Vec<String> = [
Some(cwd_info), agents_content, readme_content, language_content, include_prompt, memory_content
]
.into_iter()
.flatten()
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
/// Format a byte size for display.
fn format_size(len: usize) -> String {
if len < 1000 {
format!("{} chars", len)
} else {
format!("{:.1}k chars", len as f64 / 1000.0)
}
}
/// Extract the first H1 heading from README content for display.
pub fn extract_readme_heading(readme_content: &str) -> Option<String> {
// Find where the actual README content starts (after any prefix markers)
let readme_start = readme_content.find("📚 Project README (from");
let content_to_search = match readme_start {
Some(pos) => &readme_content[pos..],
None => readme_content,
};
// Skip the prefix line and collect content
let content: String = content_to_search
.lines()
.filter(|line| !line.starts_with("📚 Project README"))
.collect::<Vec<_>>()
.join("\n");
// Look for H1 heading
for line in content.lines() {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix("# ") {
let title = stripped.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
// Fallback: first non-empty, non-metadata line
find_fallback_title(&content)
}
/// Find a fallback title from the first few lines of content.
fn find_fallback_title(content: &str) -> Option<String> {
for line in content.lines().take(5) {
let trimmed = line.trim();
if !trimmed.is_empty()
&& !trimmed.starts_with("📚")
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("==")
&& !trimmed.starts_with("--")
{
return Some(truncate_for_display(trimmed, 100));
}
}
None
}
/// Truncate a string for display, adding ellipsis if needed.
fn truncate_for_display(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
// Truncate at character boundary, not byte boundary
let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
format!("{}...", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_readme_heading() {
let content = "# My Project\n\nSome description";
assert_eq!(extract_readme_heading(content), Some("My Project".to_string()));
}
#[test]
fn test_extract_readme_heading_with_prefix() {
let content = "📚 Project README (from README.md):\n# Cool App\n\nDescription";
assert_eq!(extract_readme_heading(content), Some("Cool App".to_string()));
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500 chars");
assert_eq!(format_size(1500), "1.5k chars");
}
#[test]
fn test_truncate_for_display() {
assert_eq!(truncate_for_display("short", 100), "short");
let long = "a".repeat(150);
let truncated = truncate_for_display(&long, 100);
assert!(truncated.ends_with("..."));
assert_eq!(truncated.len(), 100);
}
#[test]
fn test_truncate_for_display_utf8() {
// Multi-byte characters should not cause panics
let emoji_text = "Hello 👋 World 🌍 Test ✨ More text here and more";
let truncated = truncate_for_display(emoji_text, 15);
assert!(truncated.ends_with("..."));
assert!(truncated.chars().count() <= 15);
}
#[test]
fn test_combine_project_content_all_some() {
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(
Some("agents".to_string()),
Some("readme".to_string()),
Some("memory".to_string()),
Some("language".to_string()),
None, // include_prompt
&workspace,
);
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("📂 Working Directory: /test/workspace"));
assert!(content.contains("agents"));
assert!(content.contains("readme"));
assert!(content.contains("memory"));
assert!(content.contains("language"));
}
#[test]
fn test_combine_project_content_partial() {
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(None, Some("readme".to_string()), None, None, None, &workspace);
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("📂 Working Directory: /test/workspace"));
assert!(content.contains("readme"));
}
#[test]
fn test_combine_project_content_all_none() {
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(None, None, None, None, None, &workspace);
// Now always returns Some because we always include the working directory
assert!(result.is_some());
assert!(result.unwrap().contains("📂 Working Directory: /test/workspace"));
}
#[test]
fn test_combine_project_content_with_include_prompt() {
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(
Some("agents".to_string()),
Some("readme".to_string()),
Some("memory".to_string()),
Some("language".to_string()),
Some("include_prompt".to_string()),
&workspace,
);
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("include_prompt"));
}
#[test]
fn test_combine_project_content_order_include_before_memory() {
// Verify that include_prompt appears BEFORE memory in the combined content
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(
Some("AGENTS_CONTENT".to_string()),
Some("README_CONTENT".to_string()),
Some("MEMORY_CONTENT".to_string()),
Some("LANGUAGE_CONTENT".to_string()),
Some("INCLUDE_PROMPT_CONTENT".to_string()),
&workspace,
);
let content = result.unwrap();
// Find positions of each section
let agents_pos = content.find("AGENTS_CONTENT").expect("agents not found");
let readme_pos = content.find("README_CONTENT").expect("readme not found");
let language_pos = content.find("LANGUAGE_CONTENT").expect("language not found");
let include_pos = content.find("INCLUDE_PROMPT_CONTENT").expect("include_prompt not found");
let memory_pos = content.find("MEMORY_CONTENT").expect("memory not found");
// Verify order: agents < readme < language < include_prompt < memory
assert!(agents_pos < readme_pos, "agents should come before readme");
assert!(readme_pos < language_pos, "readme should come before language");
assert!(language_pos < include_pos, "language should come before include_prompt");
assert!(include_pos < memory_pos, "include_prompt should come before memory");
}
#[test]
fn test_combine_project_content_order_memory_last() {
// Verify memory is always last even when include_prompt is None
let workspace = std::path::PathBuf::from("/test/workspace");
let result = combine_project_content(
Some("AGENTS".to_string()),
Some("README".to_string()),
Some("MEMORY".to_string()),
Some("LANGUAGE".to_string()),
None, // no include_prompt
&workspace,
);
let content = result.unwrap();
// Memory should still be last
let language_pos = content.find("LANGUAGE").expect("language not found");
let memory_pos = content.find("MEMORY").expect("memory not found");
assert!(language_pos < memory_pos, "memory should come after language");
}
#[test]
fn test_read_include_prompt_none_path() {
// None path should return None
let result = read_include_prompt(None);
assert!(result.is_none());
}
#[test]
fn test_read_include_prompt_nonexistent_file() {
// Non-existent file should return None
let path = std::path::Path::new("/nonexistent/path/to/file.md");
let result = read_include_prompt(Some(path));
assert!(result.is_none());
}
#[test]
fn test_read_include_prompt_valid_file() {
// Create a temp file and read it
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_include_prompt.md");
std::fs::write(&temp_file, "Test prompt content").unwrap();
let result = read_include_prompt(Some(&temp_file));
assert!(result.is_some());
let content = result.unwrap();
assert!(content.contains("📎 Included Prompt"));
assert!(content.contains("Test prompt content"));
// Cleanup
let _ = std::fs::remove_file(&temp_file);
}
#[test]
fn test_read_include_prompt_with_template_variables() {
// Create a temp file with template variables
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_include_prompt_template.md");
std::fs::write(&temp_file, "Today is {{today}} and {{unknown}} stays").unwrap();
let result = read_include_prompt(Some(&temp_file));
assert!(result.is_some());
let content = result.unwrap();
// {{today}} should be replaced with a date, {{unknown}} should remain
assert!(!content.contains("{{today}}"));
assert!(content.contains("{{unknown}}"));
// Cleanup
let _ = std::fs::remove_file(&temp_file);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,27 @@
use crate::g3_status::{G3Status, Status};
/// Simple output helper for printing messages
#[derive(Clone)]
pub struct SimpleOutput;
pub struct SimpleOutput {
machine_mode: bool,
}
impl SimpleOutput {
pub fn new() -> Self {
SimpleOutput
SimpleOutput { machine_mode: false }
}
pub fn new_with_mode(machine_mode: bool) -> Self {
SimpleOutput { machine_mode }
}
pub fn print(&self, message: &str) {
println!("{}", message);
}
pub fn print_inline(&self, message: &str) {
use std::io::{Write, stdout};
print!("{}", message);
let _ = stdout().flush();
if !self.machine_mode {
println!("{}", message);
}
}
pub fn print_smart(&self, message: &str) {
println!("{}", message);
}
/// Print a g3 status message with colored tag and status
/// Format: "g3: <message> ... [status]"
/// Uses centralized G3Status formatting.
pub fn print_g3_status(&self, message: &str, status: &str) {
G3Status::complete(message, Status::parse(status));
}
/// Print a g3 status message in progress (no status yet)
/// Format: "g3: <message> ..."
/// Uses centralized G3Status formatting.
pub fn print_g3_progress(&self, message: &str) {
G3Status::progress_ln(message);
if !self.machine_mode {
println!("{}", message);
}
}
}

View File

@@ -1,967 +0,0 @@
//! Streaming markdown formatter with tag counting.
//!
//! This module provides a state machine that buffers markdown constructs
//! and emits formatted output as soon as constructs are complete.
//!
//! Design principles:
//! - Raw text streams immediately
//! - Inline constructs (bold, italic, inline code) buffer until closed
//! - Block constructs (code blocks, tables, blockquotes) buffer until complete
//! - Proper delimiter counting handles nested/overlapping markers
//! - Escape sequences are respected
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
use termimad::MadSkin;
/// Lazily loaded syntax set for code highlighting.
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
/// Types of markdown delimiters we track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DelimiterKind {
/// `[` - link text start
LinkBracket,
/// `**` - strong/bold
DoubleStar,
/// `*` - emphasis/italic
SingleStar,
/// `__` - strong/bold (underscore variant)
DoubleUnderscore,
/// `_` - emphasis/italic (underscore variant)
SingleUnderscore,
/// `` ` `` - inline code
Backtick,
/// `~~` - strikethrough
DoubleSquiggle,
}
/// Block-level constructs that require multi-line buffering.
#[derive(Debug, Clone, PartialEq, Eq)]
enum BlockState {
/// Not in any special block
None,
/// In a fenced code block, with optional language
CodeBlock { lang: Option<String>, fence: String },
/// In a blockquote (lines starting with >)
BlockQuote,
/// In a table (lines with |)
Table,
}
/// The streaming markdown formatter.
///
/// Feed it chunks of text, and it will emit formatted output
/// as soon as markdown constructs are complete.
pub struct StreamingMarkdownFormatter {
/// Stack of open inline delimiters with their positions in the buffer
delimiter_stack: Vec<(DelimiterKind, usize)>,
/// Current block-level state
block_state: BlockState,
/// Whether the previous character was a backslash (for escapes)
escape_next: bool,
/// Whether the last character added to current_line was escaped
last_char_escaped: bool,
/// The termimad skin for formatting
skin: MadSkin,
/// Pending output that's ready to emit
pending_output: VecDeque<String>,
/// Track if we're at the start of a line (for block detection)
at_line_start: bool,
/// Track if we just emitted a list bullet and should skip the next space
skip_next_space: bool,
/// Accumulated lines for block constructs
block_buffer: Vec<String>,
/// Current line being built
current_line: String,
}
impl StreamingMarkdownFormatter {
pub fn new(skin: MadSkin) -> Self {
Self {
delimiter_stack: Vec::new(),
block_state: BlockState::None,
escape_next: false,
last_char_escaped: false,
skin,
pending_output: VecDeque::new(),
at_line_start: true,
skip_next_space: false,
block_buffer: Vec::new(),
current_line: String::new(),
}
}
/// Process an incoming chunk of text.
/// Returns formatted output that's ready to display.
pub fn process(&mut self, chunk: &str) -> String {
for ch in chunk.chars() {
self.process_char(ch);
}
self.collect_output()
}
/// Signal end of stream and flush any remaining content.
pub fn finish(&mut self) -> String {
// Flush any incomplete constructs as-is
self.flush_incomplete();
self.collect_output()
}
/// Process a single character.
fn process_char(&mut self, ch: char) {
// Skip space after list bullet
if self.skip_next_space {
self.skip_next_space = false;
if ch == ' ' {
return;
}
}
// Handle escape sequences
if self.escape_next {
self.escape_next = false;
self.last_char_escaped = true;
self.current_line.push(ch);
self.at_line_start = false;
return;
}
if ch == '\\' {
self.escape_next = true;
self.last_char_escaped = false;
self.current_line.push(ch);
self.at_line_start = false;
return;
}
// Handle based on current block state
match &self.block_state {
BlockState::CodeBlock { .. } => self.process_in_code_block(ch),
BlockState::BlockQuote => self.process_in_blockquote(ch),
BlockState::Table => self.process_in_table(ch),
BlockState::None => self.process_normal(ch),
}
}
/// Process character in normal (non-block) mode.
fn process_normal(&mut self, ch: char) {
// Check for block-level constructs at line start
if self.at_line_start {
// Handle - at line start: could be list item or horizontal rule
// Buffer it and decide later
if ch == '-' && self.current_line.chars().all(|c| c.is_whitespace() || c == '-') {
self.current_line.push(ch);
// Keep buffering - will decide at space or newline
return;
}
// If we have buffered a single dash (possibly with leading whitespace) and now see a space, it's a list item
if ch == ' ' && self.current_line.trim() == "-" {
// Extract indentation
let indent: String = self.current_line.chars().take_while(|c| c.is_whitespace()).collect();
self.current_line.clear();
if !indent.is_empty() {
self.pending_output.push_back(indent);
}
self.pending_output.push_back("".to_string());
self.at_line_start = false;
return;
}
// Handle ordered lists: digit(s) followed by . at line start
if ch == '.' && !self.current_line.is_empty()
&& self.current_line.chars().all(|c| c.is_ascii_digit() || c.is_whitespace())
&& self.current_line.chars().any(|c| c.is_ascii_digit()) {
// This is an ordered list item like "1." or " 2."
// Emit the number with period immediately
self.current_line.push(ch);
self.current_line.push(' ');
self.pending_output.push_back(self.current_line.clone());
self.current_line.clear();
self.at_line_start = false;
return;
}
// If we're already buffering a code fence (```), continue buffering until newline
// This handles the language identifier after ``` (e.g., ```rust)
let trimmed = self.current_line.trim_start();
if trimmed.starts_with("```") && ch != '\n' {
// Continue buffering non-newline characters
self.current_line.push(ch);
return;
}
// If ch == '\n', fall through to the newline handler below
if ch == '`' {
self.current_line.push(ch);
// Check if this might be starting a code fence
let trimmed = self.current_line.trim_start();
if trimmed.starts_with("```") {
// Don't emit yet - wait for the full fence line
} else if trimmed == "`" || trimmed == "``" {
// Might become a fence, keep buffering
// (current_line may have leading whitespace)
}
return;
} else if ch == '>' && self.current_line.is_empty() {
// Starting a blockquote
self.block_state = BlockState::BlockQuote;
self.current_line.push(ch);
return;
} else if ch == '|' && self.current_line.is_empty() {
// Might be starting a table
self.block_state = BlockState::Table;
self.current_line.push(ch);
return;
} else if ch == '#' && self.current_line.is_empty() {
// Header - buffer until newline
self.current_line.push(ch);
self.at_line_start = false;
return;
}
}
// Handle newlines
if ch == '\n' {
self.handle_newline();
return;
}
// Check for inline delimiters
if let Some(delim) = self.check_delimiter(ch) {
self.at_line_start = false;
self.handle_delimiter(delim, ch);
} else if self.at_line_start && ch.is_whitespace() {
// Keep at_line_start true for leading whitespace (for nested lists)
self.current_line.push(ch);
self.last_char_escaped = false;
// Don't set at_line_start = false yet
} else {
self.at_line_start = false;
self.last_char_escaped = false;
// Check if we can stream immediately:
// - No open delimiters
// - Buffer is empty (we've been streaming)
// - Current char is not a potential delimiter start
// - Buffer doesn't start with # (header)
// - Buffer doesn't start with ` (potential code fence)
// - Buffer doesn't contain unclosed link bracket
let in_header = self.current_line.starts_with('#');
let in_potential_fence = self.current_line.starts_with('`');
// A complete link ends with ) after ](, so buffer until then
let has_bracket = self.current_line.contains("[");
let link_complete = self.current_line.contains("](") && self.current_line.ends_with(")");
let in_potential_link = has_bracket && !link_complete;
if self.delimiter_stack.is_empty() && !in_header && !in_potential_fence
&& !in_potential_link && !is_potential_delimiter_start(ch)
{
// Stream immediately - but format any buffered content first if needed
self.current_line.push(ch);
// Check if buffer has any formatting that needs processing
let has_formatting = self.current_line.contains(['[', '*', '_', '`', '~']);
if has_formatting {
let formatted = self.format_inline_content(&self.current_line);
self.pending_output.push_back(formatted);
} else {
self.pending_output.push_back(self.current_line.clone());
}
self.current_line.clear();
} else {
self.current_line.push(ch);
}
}
}
/// Check if current char (possibly with lookahead in buffer) forms a delimiter.
fn check_delimiter(&self, ch: char) -> Option<DelimiterKind> {
let last_char = self.current_line.chars().last();
// If the last character was escaped, it can't be part of a delimiter
if self.last_char_escaped {
return None;
}
match ch {
'*' => {
if last_char == Some('*') {
Some(DelimiterKind::DoubleStar)
} else {
None // Will check on next char
}
}
'_' => {
if last_char == Some('_') {
Some(DelimiterKind::DoubleUnderscore)
} else {
None
}
}
'`' => Some(DelimiterKind::Backtick),
'~' => {
if last_char == Some('~') {
Some(DelimiterKind::DoubleSquiggle)
} else {
None
}
}
'[' => Some(DelimiterKind::LinkBracket),
']' => {
// Only treat as closing if we have an open bracket
if self.delimiter_stack.iter().any(|(d, _)| *d == DelimiterKind::LinkBracket) {
Some(DelimiterKind::LinkBracket)
} else {
None
}
}
_ => {
// Check if previous char was a single delimiter
// But make sure it's not part of a double delimiter (e.g., ** or __)
let second_last = if self.current_line.len() >= 2 {
self.current_line.chars().rev().nth(1)
} else {
None
};
match last_char {
Some('*') => {
// Previous * was a single star only if char before it wasn't also *
if second_last != Some('*') {
Some(DelimiterKind::SingleStar)
} else {
None
}
}
Some('_') => {
if second_last != Some('_') {
Some(DelimiterKind::SingleUnderscore)
} else {
None
}
}
_ => None,
}
}
}
}
/// Handle a detected delimiter.
fn handle_delimiter(&mut self, delim: DelimiterKind, ch: char) {
// Don't modify the buffer - we want to preserve raw markdown
// for regex-based formatting in format_inline_content
// Check if this closes an existing delimiter
if let Some(pos) = self.find_matching_open_delimiter(delim) {
// Close the delimiter - the content is complete
self.delimiter_stack.truncate(pos);
self.current_line.push(ch);
self.last_char_escaped = false;
// If stack is now empty AND we're not inside a potential link, emit
// A potential link is indicated by an unclosed '[' in the buffer
// that hasn't been followed by '](' yet
let in_potential_link = self.current_line.contains('[')
&& !self.current_line.contains("](")
&& !self.current_line.ends_with(')');
// Don't emit yet if this could be a horizontal rule (all asterisks/dashes/underscores)
// We need to wait for newline to know for sure
let could_be_hr = self.current_line.chars().all(|c| c == '*' || c == '-' || c == '_')
&& self.current_line.len() >= 2; // At least ** or -- or __
if self.delimiter_stack.is_empty() && !in_potential_link && !could_be_hr {
self.emit_formatted_inline();
}
} else {
// Open a new delimiter
let pos = self.current_line.len();
self.delimiter_stack.push((delim, pos));
self.current_line.push(ch);
self.last_char_escaped = false;
}
}
/// Find a matching open delimiter in the stack.
fn find_matching_open_delimiter(&self, delim: DelimiterKind) -> Option<usize> {
// Search from the end (most recent) to find matching delimiter
for (i, (d, _)) in self.delimiter_stack.iter().enumerate().rev() {
if *d == delim {
return Some(i);
}
}
None
}
/// Handle a newline character.
fn handle_newline(&mut self) {
// Check if we were building a code fence
// Support indented code fences (up to 3 spaces per CommonMark spec)
let trimmed = self.current_line.trim_start();
let leading_spaces = self.current_line.len() - trimmed.len();
if trimmed.starts_with("```") && leading_spaces <= 3 {
let lang = trimmed[3..].trim().to_string();
let lang = if lang.is_empty() { None } else { Some(lang) };
self.block_state = BlockState::CodeBlock {
lang,
fence: "```".to_string(),
};
self.current_line.clear();
self.at_line_start = true;
return;
}
self.current_line.push('\n');
// Always emit the line at newline, even if there are unclosed delimiters
// This handles cases like unclosed inline code at end of line
// The format_inline_content function will handle unclosed delimiters gracefully
self.emit_formatted_inline();
self.at_line_start = true;
}
/// Process character while in a code block.
fn process_in_code_block(&mut self, ch: char) {
if ch == '\n' {
// Check if this line closes the code block
// Only close if the fence is at the start of the line with at most 3 spaces
// of indentation (per CommonMark spec). This prevents content like " ```"
// (4+ spaces, which is code indentation) from closing the block.
let trimmed = self.current_line.trim_start();
let leading_spaces = self.current_line.len() - trimmed.len();
if trimmed == "```" && leading_spaces <= 3 {
// Emit the entire code block
self.emit_code_block();
self.block_state = BlockState::None;
self.current_line.clear();
} else {
self.block_buffer.push(self.current_line.clone());
self.current_line.clear();
}
self.at_line_start = true;
} else {
self.current_line.push(ch);
self.at_line_start = false;
}
}
/// Process character while in a blockquote.
fn process_in_blockquote(&mut self, ch: char) {
if ch == '\n' {
self.block_buffer.push(self.current_line.clone());
self.current_line.clear();
self.at_line_start = true;
} else if self.at_line_start && ch != '>' && !ch.is_whitespace() {
// Line doesn't start with > - blockquote ended
self.emit_blockquote();
self.block_state = BlockState::None;
self.current_line.push(ch);
self.at_line_start = false;
} else {
self.current_line.push(ch);
self.at_line_start = false;
}
}
/// Process character while in a table.
fn process_in_table(&mut self, ch: char) {
if ch == '\n' {
self.block_buffer.push(self.current_line.clone());
self.current_line.clear();
self.at_line_start = true;
} else if self.at_line_start && ch != '|' && !ch.is_whitespace() {
// Line doesn't start with | - table ended
self.emit_table();
self.block_state = BlockState::None;
self.current_line.push(ch);
self.at_line_start = false;
} else {
self.current_line.push(ch);
self.at_line_start = false;
}
}
/// Emit formatted inline content.
fn emit_formatted_inline(&mut self) {
if self.current_line.is_empty() {
return;
}
let line = &self.current_line;
// Check for headers
if line.starts_with('#') {
let formatted = self.format_header(line);
self.pending_output.push_back(formatted);
self.current_line.clear();
self.delimiter_stack.clear();
return;
}
// Check for horizontal rule (---, ***, ___) - only if nothing else emitted on this line
// This prevents "****" from being treated as "***" + "*" horizontal rule
if self.pending_output.is_empty() || self.pending_output.back().map(|s| s.ends_with('\n')).unwrap_or(true) {
let trimmed = line.trim();
// Must be exactly 3+ of the same character, not mixed
let is_hr = (trimmed == "---" || trimmed == "***" || trimmed == "___")
|| (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-'))
|| (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_'));
if is_hr {
// Emit a horizontal rule
self.pending_output.push_back("\x1b[2m────────────────────────────────────────\x1b[0m\n".to_string());
self.current_line.clear();
self.delimiter_stack.clear();
return;
}
}
// Format inline content (bold, italic, code, strikethrough, links)
let formatted = self.format_inline_content(line);
self.pending_output.push_back(formatted);
self.current_line.clear();
self.delimiter_stack.clear();
}
/// Format a header line.
fn format_header(&self, line: &str) -> String {
let mut level = 0;
let mut chars = line.chars().peekable();
// Count # characters
while chars.peek() == Some(&'#') {
level += 1;
chars.next();
}
// Skip whitespace after #
while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
chars.next();
}
let content: String = chars.collect();
let content = content.trim_end();
// Process inline formatting (bold, italic, code, etc.) within the header
let formatted_content = self.format_inline_content(content);
// Remove trailing newline from format_inline_content since we add our own
let formatted_content = formatted_content.trim_end();
// Format based on level (magenta, bold for h1/h2)
// We wrap the already-formatted content in header color, then reset at the end
match level {
1 => format!("\x1b[1;95m{}\x1b[0m\n", formatted_content), // Bold pink (Dracula)
2 => format!("\x1b[35m{}\x1b[0m\n", formatted_content), // Purple/magenta (Dracula)
3 => format!("\x1b[36m{}\x1b[0m\n", formatted_content), // Cyan (Dracula)
4 => format!("\x1b[37m{}\x1b[0m\n", formatted_content), // White (Dracula)
5 => format!("\x1b[2m{}\x1b[0m\n", formatted_content), // Dim (Dracula)
_ => format!("\x1b[2m{}\x1b[0m\n", formatted_content), // Dim for h6+ (Dracula)
}
}
/// Format inline content with bold, italic, code, strikethrough, and links.
fn format_inline_content(&self, line: &str) -> String {
// Use regex-based replacement for inline formatting
let mut result = line.to_string();
// First, handle escaped characters: \* \_ \` \[ \] \~
// Replace with placeholder that doesn't contain the original char
// Use different codes for each: *=1, _=2, `=3, [=4, ]=5, ~=6
let escape_re = regex::Regex::new(r"\\\*").unwrap();
result = escape_re.replace_all(&result, "\x00E1\x00").to_string();
let escape_re = regex::Regex::new(r"\\_").unwrap();
result = escape_re.replace_all(&result, "\x00E2\x00").to_string();
let escape_re = regex::Regex::new(r"\\`").unwrap();
result = escape_re.replace_all(&result, "\x00E3\x00").to_string();
let escape_re = regex::Regex::new(r"\\\[").unwrap();
result = escape_re.replace_all(&result, "\x00E4\x00").to_string();
let escape_re = regex::Regex::new(r"\\\]").unwrap();
result = escape_re.replace_all(&result, "\x00E5\x00").to_string();
let escape_re = regex::Regex::new(r"\\~").unwrap();
result = escape_re.replace_all(&result, "\x00E6\x00").to_string();
// Process links [text](url) -> text (in cyan, underlined)
// Allow any characters inside the brackets including backticks
let link_re = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap();
result = link_re.replace_all(&result, |caps: &regex::Captures| {
let text = &caps[1];
// Format any inline code within the link text
let formatted_text = format_inline_code_only(text);
format!("\x1b[36;4m{}\x1b[0m", formatted_text)
}).to_string();
// Process inline code `code` -> code (in orange)
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
result = code_re.replace_all(&result, |caps: &regex::Captures| {
let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
}).to_string();
// Handle unclosed inline code at end of line: `code without closing backtick
// This renders the content after the backtick in orange and removes the backtick
let unclosed_code_re = regex::Regex::new(r"`([^`]+)$").unwrap();
result = unclosed_code_re.replace_all(&result, |caps: &regex::Captures| {
let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
}).to_string();
// Process strikethrough ~~text~~ -> text (with strikethrough)
let strike_re = regex::Regex::new(r"~~([^~]+)~~").unwrap();
result = strike_re.replace_all(&result, |caps: &regex::Captures| {
let text = &caps[1];
format!("\x1b[9m{}\x1b[0m", text)
}).to_string();
// Process italic *text* -> text (in cyan italic)
// Handle italic with potential nested bold: *italic with **bold** inside*
// We need to be careful not to match ** as italic delimiters
// Must be processed BEFORE bold so we can detect ** inside *...*
result = process_italic_with_nested_bold(&result);
// Process bold **text** -> text (in green bold)
// Allow any characters inside including single asterisks for nested italic
let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap();
result = bold_re.replace_all(&result, |caps: &regex::Captures| {
let text = &caps[1];
// Process nested italic within bold
let inner = format_nested_italic(text);
format!("\x1b[1;32m{}\x1b[0m", inner)
}).to_string();
// Restore escaped characters (remove the placeholder markers)
result = result.replace("\x00E1\x00", "*");
result = result.replace("\x00E2\x00", "_");
result = result.replace("\x00E3\x00", "`");
result = result.replace("\x00E4\x00", "[");
result = result.replace("\x00E5\x00", "]");
result = result.replace("\x00E6\x00", "~");
result
}
fn emit_code_block(&mut self) {
let lang = if let BlockState::CodeBlock { lang, .. } = &self.block_state {
lang.clone()
} else {
None
};
// Emit language label
if let Some(ref l) = lang {
self.pending_output
.push_back(format!("\x1b[2;3m{}\x1b[0m\n", l));
}
// Highlight the code
let code = self.block_buffer.join("\n");
let highlighted = highlight_code(&code, lang.as_deref());
self.pending_output.push_back(highlighted);
self.pending_output.push_back("\n".to_string());
self.block_buffer.clear();
}
/// Emit a complete blockquote.
fn emit_blockquote(&mut self) {
let content = self.block_buffer.join("\n");
let formatted = format!("{}", self.skin.term_text(&content));
self.pending_output.push_back(formatted);
self.block_buffer.clear();
}
/// Emit a complete table.
fn emit_table(&mut self) {
let content = self.block_buffer.join("\n");
let formatted = format!("{}", self.skin.term_text(&content));
self.pending_output.push_back(formatted);
self.block_buffer.clear();
}
/// Flush any incomplete constructs.
fn flush_incomplete(&mut self) {
// Emit any remaining block content
match &self.block_state {
BlockState::CodeBlock { .. } => {
// Unclosed code block - emit as-is
if !self.block_buffer.is_empty() || !self.current_line.is_empty() {
if !self.current_line.is_empty() {
// Check if current_line is the closing fence (``` without trailing newline)
let trimmed = self.current_line.trim_start();
let leading_spaces = self.current_line.len() - trimmed.len();
if trimmed == "```" && leading_spaces <= 3 {
// This is the closing fence - don't include it in content
// Just clear it and emit the block
} else {
self.block_buffer.push(self.current_line.clone());
}
self.current_line.clear();
}
self.emit_code_block();
}
}
BlockState::BlockQuote => {
if !self.current_line.is_empty() {
self.block_buffer.push(self.current_line.clone());
}
if !self.block_buffer.is_empty() {
self.emit_blockquote();
}
}
BlockState::Table => {
if !self.current_line.is_empty() {
self.block_buffer.push(self.current_line.clone());
}
if !self.block_buffer.is_empty() {
self.emit_table();
}
}
BlockState::None => {}
}
self.block_state = BlockState::None;
// Emit any remaining inline content
if !self.current_line.is_empty() {
// Even with unclosed delimiters, emit what we have
let formatted = self.format_inline_content(&self.current_line.clone());
self.pending_output.push_back(formatted);
self.current_line.clear();
}
self.delimiter_stack.clear();
}
/// Collect all pending output into a single string.
fn collect_output(&mut self) -> String {
let mut output = String::new();
while let Some(s) = self.pending_output.pop_front() {
output.push_str(&s);
}
output
}
}
/// Format only inline code within text (used for nested formatting in links)
fn format_inline_code_only(text: &str) -> String {
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
code_re.replace_all(text, |caps: &regex::Captures| {
let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code)
}).to_string()
}
/// Format nested italic within bold text
fn format_nested_italic(text: &str) -> String {
let italic_re = regex::Regex::new(r"\*([^*]+)\*").unwrap();
italic_re.replace_all(text, |caps: &regex::Captures| {
let inner = &caps[1];
format!("\x1b[3;36m{}\x1b[0m\x1b[1;32m", inner) // italic, then restore bold
}).to_string()
}
/// Format nested bold within italic text
fn format_nested_bold(text: &str) -> String {
let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap();
bold_re.replace_all(text, |caps: &regex::Captures| {
let inner = &caps[1];
format!("\x1b[1;32m{}\x1b[0m\x1b[3;36m", inner) // bold, then restore italic
}).to_string()
}
/// Process italic text that may contain nested bold
/// Matches *text* where the * is not part of **
fn process_italic_with_nested_bold(text: &str) -> String {
let mut result = String::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check for single * (not **)
if chars[i] == '*' && (i + 1 >= chars.len() || chars[i + 1] != '*')
&& (i == 0 || chars[i - 1] != '*')
{
// Found opening single *, look for closing single *
let start = i + 1;
let mut end = None;
let mut j = start;
while j < chars.len() {
if chars[j] == '*' && (j + 1 >= chars.len() || chars[j + 1] != '*')
&& (j == 0 || chars[j - 1] != '*')
{
end = Some(j);
break;
}
j += 1;
}
if let Some(end_pos) = end {
// Found matching closing *, format as italic
let inner: String = chars[start..end_pos].iter().collect();
// Process nested bold within the italic content
let formatted_inner = format_nested_bold(&inner);
result.push_str(&format!("\x1b[3;36m{}\x1b[0m", formatted_inner));
i = end_pos + 1;
} else {
// No closing *, just output the *
result.push(chars[i]);
i += 1;
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
/// Check if a character could start a markdown delimiter
fn is_potential_delimiter_start(ch: char) -> bool {
matches!(ch, '*' | '_' | '`' | '~' | '[' | ']' | '#')
}
/// Highlight code with syntect.
fn highlight_code(code: &str, lang: Option<&str>) -> String {
// Map language aliases to syntect-recognized names
let normalized_lang = lang.map(|l| match l.to_lowercase().as_str() {
// Lisp family - syntect's "Lisp" syntax handles these well
"racket" | "rkt" => "lisp",
"elisp" | "emacs-lisp" => "lisp",
"scheme" => "lisp",
"common-lisp" | "cl" => "lisp",
// Other common aliases
"shell" | "sh" => "bash",
"zsh" => "bash",
"dockerfile" => "bash",
_ => l,
});
let syntax = lang
.and_then(|_| normalized_lang.and_then(|l| SYNTAX_SET.find_syntax_by_token(l)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
let mut output = String::new();
for line in LinesWithEndings::from(code) {
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(ranges) => {
output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
}
Err(_) => {
output.push_str(line);
}
}
}
output.push_str("\x1b[0m");
output
}
#[cfg(test)]
mod tests {
use super::*;
fn make_formatter() -> StreamingMarkdownFormatter {
let skin = MadSkin::default();
StreamingMarkdownFormatter::new(skin)
}
#[test]
fn test_plain_text_streams_immediately() {
let mut fmt = make_formatter();
let output = fmt.process("hello world\n");
assert!(!output.is_empty());
assert!(output.contains("hello world"));
}
#[test]
fn test_bold_buffers_until_closed() {
let mut fmt = make_formatter();
// Open bold - should buffer
let output1 = fmt.process("**bold");
assert!(output1.is_empty(), "Should buffer until closed");
// Close bold - should emit
let output2 = fmt.process("**\n");
assert!(!output2.is_empty(), "Should emit when closed");
}
#[test]
fn test_code_block_buffers() {
let mut fmt = make_formatter();
// Start code block
let o1 = fmt.process("```rust\n");
assert!(o1.is_empty(), "Code fence should buffer");
// Code content
let o2 = fmt.process("fn main() {}\n");
assert!(o2.is_empty(), "Code content should buffer");
// Close code block
let o3 = fmt.process("```\n");
assert!(!o3.is_empty(), "Should emit on close");
assert!(o3.contains("\x1b["), "Should have ANSI codes");
}
#[test]
fn test_escape_sequences() {
let mut fmt = make_formatter();
// Escaped asterisks should not start bold
let output = fmt.process("\\*not bold\\*\n");
assert!(!output.is_empty());
// The backslashes and asterisks should pass through
}
#[test]
fn test_nested_delimiters() {
let mut fmt = make_formatter();
// **bold *italic* still bold**
let output = fmt.process("**bold *italic* still bold**\n");
assert!(!output.is_empty());
}
#[test]
fn test_inline_code() {
let mut fmt = make_formatter();
let output = fmt.process("use `code` here\n");
assert!(!output.is_empty());
}
#[test]
fn test_finish_flushes_incomplete() {
let mut fmt = make_formatter();
// Unclosed bold
let o1 = fmt.process("**unclosed bold");
assert!(o1.is_empty());
// Finish should flush
let o2 = fmt.finish();
assert!(!o2.is_empty());
assert!(o2.contains("unclosed bold"));
}
}

View File

@@ -1,148 +0,0 @@
//! Task execution with retry logic for G3 CLI.
use g3_core::error_handling::{calculate_retry_delay, classify_error, ErrorType, RecoverableError};
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error};
use crate::simple_output::SimpleOutput;
use crate::g3_status::G3Status;
/// Maximum number of retry attempts for recoverable errors
const MAX_RETRIES: u32 = 3;
/// Get a human-readable name for a recoverable error type.
fn recoverable_error_name(err: &RecoverableError) -> &'static str {
match err {
RecoverableError::RateLimit => "rate limited",
RecoverableError::ServerError => "server error",
RecoverableError::NetworkError => "network error",
RecoverableError::Timeout => "timeout",
RecoverableError::ModelBusy => "model overloaded",
RecoverableError::TokenLimit => "token limit",
RecoverableError::ContextLengthExceeded => "context length exceeded",
}
}
/// Execute a task with retry logic for recoverable errors.
/// Returns `true` if the task completed normally, `false` if cancelled by Ctrl+C.
pub async fn execute_task_with_retry<W: UiWriter>(
agent: &mut Agent<W>,
input: &str,
show_prompt: bool,
show_code: bool,
output: &SimpleOutput,
) -> bool {
let mut attempt = 0;
output.print("🤔 Thinking...");
// Create cancellation token for this request
let cancellation_token = CancellationToken::new();
let cancel_token_clone = cancellation_token.clone();
loop {
attempt += 1;
// Execute task with cancellation support
let execution_result = tokio::select! {
result = agent.execute_task_with_timing_cancellable(
input, None, false, show_prompt, show_code, true, cancellation_token.clone(), None
) => {
result
}
_ = tokio::signal::ctrl_c() => {
cancel_token_clone.cancel();
output.print("\n⚠️ Operation cancelled by user (Ctrl+C)");
return false;
}
};
match execution_result {
Ok(_) => {
if attempt > 1 {
output.print(&format!("✅ Request succeeded after {} attempts", attempt));
}
// Response was already displayed during streaming - don't print again
return true;
}
Err(e) => {
if e.to_string().contains("cancelled") {
output.print("⚠️ Operation cancelled by user");
return false;
}
// Check if this is a recoverable error that we should retry
let error_type = classify_error(&e);
if let ErrorType::Recoverable(recoverable_error) = error_type {
if attempt < MAX_RETRIES {
// Use shared retry delay calculation (non-autonomous mode)
let delay = calculate_retry_delay(attempt, false);
let delay_secs = delay.as_secs_f64();
// Print error status
G3Status::complete(
recoverable_error_name(&recoverable_error),
crate::g3_status::Status::Error(String::new()),
);
// Print retry message (no newline, will show [done] after sleep)
G3Status::progress(&format!("retrying in {:.1}s ({}/{})", delay_secs, attempt, MAX_RETRIES));
// Wait before retrying
tokio::time::sleep(delay).await;
G3Status::done();
continue;
}
}
// For non-recoverable errors or after max retries
handle_execution_error(&e, input, output, attempt);
return true;
}
}
}
}
/// Handle execution errors with detailed logging and user-friendly output.
pub fn handle_execution_error(e: &anyhow::Error, input: &str, _output: &SimpleOutput, attempt: u32) {
// Check if this is a recoverable error type (for logging level decision)
let error_type = classify_error(e);
let is_recoverable = matches!(error_type, ErrorType::Recoverable(_));
// Use debug level for recoverable errors (they're expected), error level for others
if is_recoverable {
debug!("Task execution failed (recoverable): {}", e);
if attempt > 1 {
debug!("Failed after {} attempts", attempt);
}
} else {
error!("=== TASK EXECUTION ERROR ===");
error!("Error: {}", e);
if attempt > 1 {
error!("Failed after {} attempts", attempt);
}
// Log error chain only for non-recoverable errors
let mut source = e.source();
let mut depth = 1;
while let Some(err) = source {
error!(" Caused by [{}]: {}", depth, err);
source = err.source();
depth += 1;
}
error!("Task input: {}", input);
error!("Error type: {}", std::any::type_name_of_val(&e));
}
// Display user-friendly error message using G3Status
if let ErrorType::Recoverable(ref recoverable_error) = error_type {
let error_name = recoverable_error_name(recoverable_error);
G3Status::complete(error_name, crate::g3_status::Status::Failed);
} else {
G3Status::complete(&format!("error: {}", e), crate::g3_status::Status::Failed);
}
}

View File

@@ -1,140 +0,0 @@
//! Template variable injection for included prompt files.
//!
//! Supports `{{var}}` syntax for variable substitution.
//! Currently supported variables:
//! - `today`: Current date in ISO format (YYYY-MM-DD)
use chrono::Local;
use regex::Regex;
use std::collections::HashSet;
/// Process template variables in the given content.
///
/// Replaces `{{var}}` patterns with their values.
/// Warns about unknown variables and leaves them unchanged.
pub fn process_template(content: &str) -> String {
// Regex to match {{variable_name}}
let re = Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}").unwrap();
// Track unknown variables to warn only once per variable
let mut unknown_vars: HashSet<String> = HashSet::new();
let result = re.replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1];
match resolve_variable(var_name) {
Some(value) => value,
None => {
if unknown_vars.insert(var_name.to_string()) {
tracing::warn!("Unknown template variable: {{{{{}}}}}", var_name);
}
// Leave unknown variables unchanged
caps[0].to_string()
}
}
});
result.into_owned()
}
/// Resolve a template variable to its value.
fn resolve_variable(name: &str) -> Option<String> {
match name {
"today" => Some(Local::now().format("%Y-%m-%d").to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_today_variable() {
let input = "Today is {{today}}";
let result = process_template(input);
// Should contain a date in YYYY-MM-DD format
assert!(!result.contains("{{today}}"));
assert!(result.starts_with("Today is "));
// Verify date format (YYYY-MM-DD)
let date_part = &result["Today is ".len()..];
assert_eq!(date_part.len(), 10);
assert_eq!(&date_part[4..5], "-");
assert_eq!(&date_part[7..8], "-");
}
#[test]
fn test_multiple_today_variables() {
let input = "Start: {{today}}, End: {{today}}";
let result = process_template(input);
// Both should be replaced
assert!(!result.contains("{{today}}"));
assert!(result.contains("Start: "));
assert!(result.contains(", End: "));
}
#[test]
fn test_unknown_variable_unchanged() {
let input = "Hello {{unknown_var}}!";
let result = process_template(input);
// Unknown variable should remain unchanged
assert_eq!(result, "Hello {{unknown_var}}!");
}
#[test]
fn test_mixed_known_and_unknown() {
let input = "Date: {{today}}, Name: {{name}}";
let result = process_template(input);
// today should be replaced, name should remain
assert!(!result.contains("{{today}}"));
assert!(result.contains("{{name}}"));
}
#[test]
fn test_no_variables() {
let input = "No variables here";
let result = process_template(input);
assert_eq!(result, "No variables here");
}
#[test]
fn test_empty_braces() {
let input = "Empty {{}} braces";
let result = process_template(input);
// Empty braces don't match the pattern, should remain unchanged
assert_eq!(result, "Empty {{}} braces");
}
#[test]
fn test_single_braces_ignored() {
let input = "Single {today} braces";
let result = process_template(input);
// Single braces should not be processed
assert_eq!(result, "Single {today} braces");
}
#[test]
fn test_variable_with_underscores() {
let input = "{{my_custom_var}}";
let result = process_template(input);
// Unknown but valid variable name, should remain unchanged
assert_eq!(result, "{{my_custom_var}}");
}
#[test]
fn test_variable_with_numbers() {
let input = "{{var123}}";
let result = process_template(input);
// Unknown but valid variable name, should remain unchanged
assert_eq!(result, "{{var123}}");
}
}

View File

@@ -4,11 +4,7 @@ use std::fs;
use std::path::Path;
use anyhow::Result;
/// Color theme configuration for the TUI.
///
/// Note: The "retro" theme is the default theme (inspired by Alien terminals).
/// This is a theme option, not a separate TUI mode. The theme can be selected
/// via config file or the `from_name()` method ("default" and "retro" are equivalent).
/// Color theme configuration for the retro TUI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorTheme {
/// Name of the theme

158
crates/g3-cli/src/tui.rs Normal file
View File

@@ -0,0 +1,158 @@
use crossterm::style::Color;
use crossterm::style::{SetForegroundColor, ResetColor};
use std::io::{self, Write};
use termimad::MadSkin;
/// Simple output handler with markdown support
pub struct SimpleOutput {
mad_skin: MadSkin,
}
impl SimpleOutput {
pub fn new() -> Self {
let mut mad_skin = MadSkin::default();
// Dracula color scheme
// Background: #282a36, Foreground: #f8f8f2
// Colors: Cyan #8be9fd, Green #50fa7b, Orange #ffb86c, Pink #ff79c6, Purple #bd93f9, Red #ff5555, Yellow #f1fa8c
mad_skin.set_headers_fg(Color::Rgb { r: 189, g: 147, b: 249 }); // Purple for headers
mad_skin.bold.set_fg(Color::Rgb { r: 255, g: 121, b: 198 }); // Pink for bold
mad_skin.italic.set_fg(Color::Rgb { r: 139, g: 233, b: 253 }); // Cyan for italic
mad_skin.code_block.set_bg(Color::Rgb { r: 68, g: 71, b: 90 }); // Dracula background variant
mad_skin.code_block.set_fg(Color::Rgb { r: 80, g: 250, b: 123 }); // Green for code text
mad_skin.inline_code.set_bg(Color::Rgb { r: 68, g: 71, b: 90 }); // Same background for inline code
mad_skin.inline_code.set_fg(Color::Rgb { r: 241, g: 250, b: 140 }); // Yellow for inline code
mad_skin.quote_mark.set_fg(Color::Rgb { r: 98, g: 114, b: 164 }); // Comment purple for quote marks
mad_skin.strikeout.set_fg(Color::Rgb { r: 255, g: 85, b: 85 }); // Red for strikethrough
Self { mad_skin }
}
/// Detect if text contains markdown formatting
fn has_markdown(&self, text: &str) -> bool {
// Check for common markdown patterns
text.contains("**") ||
text.contains("```") ||
text.contains("`") ||
text.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with('#') ||
trimmed.starts_with("- ") ||
trimmed.starts_with("* ") ||
trimmed.starts_with("+ ") ||
(trimmed.len() > 2 &&
trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) &&
trimmed.chars().nth(1) == Some('.') &&
trimmed.chars().nth(2) == Some(' ')) ||
(trimmed.contains('[') && trimmed.contains("]("))
}) ||
(text.matches('*').count() >= 2 && !text.contains("/*") && !text.contains("*/"))
}
pub fn print(&self, text: &str) {
println!("{}", text);
}
/// Smart print that automatically detects and renders markdown
pub fn print_smart(&self, text: &str) {
if self.has_markdown(text) {
self.print_markdown(text);
} else {
self.print(text);
}
}
pub fn print_markdown(&self, markdown: &str) {
self.mad_skin.print_text(markdown);
}
pub fn _print_status(&self, status: &str) {
println!("📊 {}", status);
}
pub fn print_context(&self, used: u32, total: u32, percentage: f32) {
let bar_width: usize = 10;
let filled_width = ((percentage / 100.0) * bar_width as f32) as usize;
let empty_width = bar_width.saturating_sub(filled_width);
let filled_chars = "".repeat(filled_width);
let empty_chars = "".repeat(empty_width);
// Determine color based on percentage
let color = if percentage < 60.0 {
crossterm::style::Color::Green
} else if percentage < 80.0 {
crossterm::style::Color::Yellow
} else {
crossterm::style::Color::Red
};
// Print with colored progress bar
print!("Context: ");
print!("{}", SetForegroundColor(color));
print!("{}{}", filled_chars, empty_chars);
print!("{}", ResetColor);
println!(" {:.1}% | {}/{} tokens", percentage, used, total);
}
pub fn print_context_thinning(&self, message: &str) {
// Animated highlight for context thinning
// Use bright cyan/green with a quick flash animation
// Flash animation: print with bright background, then normal
let frames = vec![
"\x1b[1;97;46m", // Frame 1: Bold white on cyan background
"\x1b[1;97;42m", // Frame 2: Bold white on green background
"\x1b[1;96;40m", // Frame 3: Bold cyan on black background
];
println!();
// Quick flash animation
for frame in &frames {
print!("\r{}{}\x1b[0m", frame, message);
let _ = io::stdout().flush();
std::thread::sleep(std::time::Duration::from_millis(80));
}
// Final display with bright cyan and sparkle emojis
print!("\r\x1b[1;96m✨ {}\x1b[0m", message);
println!();
// Add a subtle "success" indicator line
println!("\x1b[2;36m └─ Context optimized successfully\x1b[0m");
println!();
let _ = io::stdout().flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_detection() {
let output = SimpleOutput::new();
// Should detect markdown
assert!(output.has_markdown("**bold text**"));
assert!(output.has_markdown("`code`"));
assert!(output.has_markdown("```\ncode block\n```"));
assert!(output.has_markdown("# Header"));
assert!(output.has_markdown("- list item"));
assert!(output.has_markdown("* list item"));
assert!(output.has_markdown("+ list item"));
assert!(output.has_markdown("1. numbered item"));
assert!(output.has_markdown("[link](url)"));
assert!(output.has_markdown("*italic* text"));
// Should NOT detect markdown
assert!(!output.has_markdown("plain text"));
assert!(!output.has_markdown("file.txt"));
assert!(!output.has_markdown("/* comment */"));
assert!(!output.has_markdown("just one * asterisk"));
assert!(!output.has_markdown("📁 Workspace: /path/to/dir"));
assert!(!output.has_markdown("✅ Success message"));
}
}

View File

@@ -1,225 +1,78 @@
use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint};
use crate::display::{shorten_path, shorten_paths_in_command};
use crate::streaming_markdown::StreamingMarkdownFormatter;
use g3_core::ui_writer::UiWriter;
use std::io::{self, Write};
use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU8, Ordering}};
use termimad::MadSkin;
/// Padding width for tool names in compact display (longest tool: "str_replace" = 11 chars)
const TOOL_NAME_PADDING: usize = 11;
/// ANSI escape codes
mod ansi {
pub const YELLOW: &str = "\x1b[33m";
pub const ORANGE: &str = "\x1b[38;5;208m";
pub const RED: &str = "\x1b[31m";
}
/// Colorize a str_replace summary (e.g., "+5 | -3" -> green "+5" | red "-3")
fn colorize_str_replace_summary(summary: &str) -> String {
// Parse patterns like "+5 | -3", "+5", "-3"
if summary.contains(" | ") {
let parts: Vec<&str> = summary.split(" | ").collect();
if parts.len() == 2 {
return format!("\x1b[32m{}\x1b[0m \x1b[2m|\x1b[0m \x1b[31m{}\x1b[0m", parts[0], parts[1]);
}
} else if summary.starts_with('+') {
return format!("\x1b[32m{}\x1b[0m", summary);
} else if summary.starts_with('-') {
return format!("\x1b[31m{}\x1b[0m", summary);
}
summary.to_string()
}
/// ANSI color codes for tool names
const TOOL_COLOR_NORMAL: &str = "\x1b[32m";
const TOOL_COLOR_NORMAL_BOLD: &str = "\x1b[1;32m";
const TOOL_COLOR_AGENT: &str = "\x1b[38;5;250m";
const TOOL_COLOR_AGENT_BOLD: &str = "\x1b[1;38;5;250m";
/// Blink state values for the streaming indicator
const BLINK_INACTIVE: u8 = 0;
const BLINK_SHOW_PIPE: u8 = 1;
const BLINK_SHOW_SPACE: u8 = 2;
/// Shared state for tool parsing hints that can be used in callbacks.
/// This is separate from ConsoleUiWriter so it can be captured by Arc in closures.
#[derive(Clone)]
struct ParsingHintState {
parsing_indicator_printed: Arc<AtomicBool>,
last_output_was_text: Arc<AtomicBool>,
last_output_was_tool: Arc<AtomicBool>,
is_agent_mode: Arc<AtomicBool>,
/// Blink state: 0 = inactive, 1 = show pipe, 2 = show space
blink_state: Arc<AtomicU8>,
}
impl ParsingHintState {
fn new() -> Self {
Self {
parsing_indicator_printed: Arc::new(AtomicBool::new(false)),
last_output_was_text: Arc::new(AtomicBool::new(false)),
last_output_was_tool: Arc::new(AtomicBool::new(false)),
is_agent_mode: Arc::new(AtomicBool::new(false)),
blink_state: Arc::new(AtomicU8::new(BLINK_INACTIVE)),
}
}
fn clear(&self) {
self.parsing_indicator_printed.store(false, Ordering::Relaxed);
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
}
/// Handle a tool parsing hint - this is the core logic extracted for use in callbacks
fn handle_hint(&self, hint: ToolParsingHint) {
match hint {
ToolParsingHint::Detected(tool_name) => {
// Stop any previous blinking
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
// Check if we've already printed an indicator (this is an update)
let already_printed = self.parsing_indicator_printed.load(Ordering::Relaxed);
if already_printed {
// Update in place: clear line and reprint with new name
print!("\r\x1b[2K");
} else {
// First time: add blank line if last output was text
if self.last_output_was_text.load(Ordering::Relaxed) {
println!();
}
self.last_output_was_text.store(false, Ordering::Relaxed);
self.last_output_was_tool.store(true, Ordering::Relaxed);
}
// Get color based on agent mode
let tool_color = if self.is_agent_mode.load(Ordering::Relaxed) {
TOOL_COLOR_AGENT
} else {
TOOL_COLOR_NORMAL
};
// Print the indicator: " ● tool_name |"
print!(" \x1b[2m●\x1b[0m {}{:<width$}\x1b[0m \x1b[2m|\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
let _ = io::stdout().flush();
self.parsing_indicator_printed.store(true, Ordering::Relaxed);
self.blink_state.store(BLINK_SHOW_PIPE, Ordering::Relaxed);
}
ToolParsingHint::Active => {
// Toggle blink state for visual feedback
let current = self.blink_state.load(Ordering::Relaxed);
if current != BLINK_INACTIVE {
let new_state = if current == BLINK_SHOW_PIPE { BLINK_SHOW_SPACE } else { BLINK_SHOW_PIPE };
self.blink_state.store(new_state, Ordering::Relaxed);
let indicator = if new_state == BLINK_SHOW_PIPE { "|" } else { " " };
// Move back one char and reprint
print!("\x1b[1D\x1b[2m{}\x1b[0m", indicator);
let _ = io::stdout().flush();
}
}
ToolParsingHint::Complete => {
// Stop blinking
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
// Clear the parsing indicator line - the actual tool output will follow
if self.parsing_indicator_printed.load(Ordering::Relaxed) {
// Clear the current line and move to start
print!("\r\x1b[2K");
let _ = io::stdout().flush();
}
self.clear();
}
}
}
}
use std::sync::Mutex;
/// Console implementation of UiWriter that prints to stdout
pub struct ConsoleUiWriter {
current_tool_name: std::sync::Mutex<Option<String>>,
current_tool_args: std::sync::Mutex<Vec<(String, String)>>,
/// Workspace path for shortening displayed paths
workspace_path: std::sync::Mutex<Option<std::path::PathBuf>>,
/// Project path for shortening displayed paths (takes priority over workspace)
project_path: std::sync::Mutex<Option<std::path::PathBuf>>,
/// Project name for display (e.g., "appa_estate")
project_name: std::sync::Mutex<Option<String>>,
current_output_line: std::sync::Mutex<Option<String>>,
output_line_printed: std::sync::Mutex<bool>,
/// Track if we're in shell compact mode (for appending timing to output line)
is_shell_compact: std::sync::Mutex<bool>,
/// Streaming markdown formatter for agent responses
markdown_formatter: Mutex<Option<StreamingMarkdownFormatter>>,
/// Track the last read_file path for continuation display
last_read_file_path: std::sync::Mutex<Option<String>>,
/// Shared state for tool parsing hints (used by real-time callback)
hint_state: ParsingHintState,
}
/// ANSI color code for duration display based on elapsed time.
/// Returns empty string for fast operations, yellow/orange/red for slower ones.
fn duration_color(duration_str: &str) -> &'static str {
if duration_str.ends_with("ms") {
return "";
}
if let Some(m_pos) = duration_str.find('m') {
if let Ok(minutes) = duration_str[..m_pos].trim().parse::<u32>() {
return match minutes {
5.. => ansi::RED,
1.. => ansi::ORANGE,
_ => "",
};
}
} else if let Some(s_value) = duration_str.strip_suffix('s') {
if let Ok(seconds) = s_value.trim().parse::<f64>() {
if seconds >= 1.0 {
return ansi::YELLOW;
}
}
}
""
}
impl ConsoleUiWriter {
/// Clear all stored tool state after output is complete.
fn clear_tool_state(&self) {
*self.current_tool_name.lock().unwrap() = None;
self.current_tool_args.lock().unwrap().clear();
*self.current_output_line.lock().unwrap() = None;
*self.output_line_printed.lock().unwrap() = false;
}
current_tool_name: Mutex<Option<String>>,
current_tool_args: Mutex<Vec<(String, String)>>,
current_output_line: Mutex<Option<String>>,
output_line_printed: Mutex<bool>,
in_todo_tool: Mutex<bool>,
}
impl ConsoleUiWriter {
pub fn new() -> Self {
Self {
current_tool_name: std::sync::Mutex::new(None),
current_tool_args: std::sync::Mutex::new(Vec::new()),
workspace_path: std::sync::Mutex::new(None),
project_path: std::sync::Mutex::new(None),
project_name: std::sync::Mutex::new(None),
current_output_line: std::sync::Mutex::new(None),
output_line_printed: std::sync::Mutex::new(false),
is_shell_compact: std::sync::Mutex::new(false),
markdown_formatter: Mutex::new(None),
last_read_file_path: std::sync::Mutex::new(None),
hint_state: ParsingHintState::new(),
current_tool_name: Mutex::new(None),
current_tool_args: Mutex::new(Vec::new()),
current_output_line: Mutex::new(None),
output_line_printed: Mutex::new(false),
in_todo_tool: Mutex::new(false),
}
}
}
impl ConsoleUiWriter {
fn get_workspace_path(&self) -> Option<std::path::PathBuf> {
self.workspace_path.lock().unwrap().clone()
}
fn get_project_info(&self) -> Option<(std::path::PathBuf, String)> {
let path = self.project_path.lock().unwrap().clone()?;
let name = self.project_name.lock().unwrap().clone()?;
Some((path, name))
fn print_todo_line(&self, line: &str) {
// Transform and print todo list lines elegantly
let trimmed = line.trim();
// Skip the "📝 TODO list:" prefix line
if trimmed.starts_with("📝 TODO list:") || trimmed == "📝 TODO list is empty" {
return;
}
// Handle empty lines
if trimmed.is_empty() {
println!();
return;
}
// Detect indentation level
let indent_count = line.chars().take_while(|c| c.is_whitespace()).count();
let indent = " ".repeat(indent_count / 2); // Convert spaces to visual indent
// Format based on line type
if trimmed.starts_with("- [ ]") {
// Incomplete task
let task = trimmed.strip_prefix("- [ ]").unwrap_or(trimmed).trim();
println!("{}{}", indent, task);
} else if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
// Completed task
let task = trimmed.strip_prefix("- [x]")
.or_else(|| trimmed.strip_prefix("- [X]"))
.unwrap_or(trimmed)
.trim();
println!("{}\x1b[2m☑ {}\x1b[0m", indent, task);
} else if trimmed.starts_with("- ") {
// Regular bullet point
let item = trimmed.strip_prefix("- ").unwrap_or(trimmed).trim();
println!("{}{}", indent, item);
} else if trimmed.starts_with("# ") {
// Heading
let heading = trimmed.strip_prefix("# ").unwrap_or(trimmed).trim();
println!("\n\x1b[1m{}\x1b[0m", heading);
} else if trimmed.starts_with("## ") {
// Subheading
let subheading = trimmed.strip_prefix("## ").unwrap_or(trimmed).trim();
println!("\n\x1b[1m{}\x1b[0m", subheading);
} else if trimmed.starts_with("**") && trimmed.ends_with("**") {
// Bold text (section marker)
let text = trimmed.trim_start_matches("**").trim_end_matches("**");
println!("{}\x1b[1m{}\x1b[0m", indent, text);
} else {
// Regular text or note
println!("{}{}", indent, trimmed);
}
}
}
@@ -249,25 +102,49 @@ impl UiWriter for ConsoleUiWriter {
println!("{}", message);
}
fn print_g3_progress(&self, message: &str) {
crate::g3_status::G3Status::progress(message);
fn print_context_thinning(&self, message: &str) {
// Animated highlight for context thinning
// Use bright cyan/green with a quick flash animation
// Flash animation: print with bright background, then normal
let frames = vec![
"\x1b[1;97;46m", // Frame 1: Bold white on cyan background
"\x1b[1;97;42m", // Frame 2: Bold white on green background
"\x1b[1;96;40m", // Frame 3: Bold cyan on black background
];
println!();
// Quick flash animation
for frame in &frames {
print!("\r{}{}\x1b[0m", frame, message);
let _ = io::stdout().flush();
std::thread::sleep(std::time::Duration::from_millis(80));
}
// Final display with bright cyan and sparkle emojis
print!("\r\x1b[1;96m✨ {}\x1b[0m", message);
println!();
// Add a subtle "success" indicator line
println!("\x1b[2;36m └─ Context optimized successfully\x1b[0m");
println!();
let _ = io::stdout().flush();
}
fn print_g3_status(&self, message: &str, status: &str) {
use crate::g3_status::Status;
let _ = message; // unused now - progress already printed the message
crate::g3_status::G3Status::status(&Status::parse(status));
}
fn print_thin_result(&self, result: &g3_core::ThinResult) {
// Use centralized G3Status formatting
crate::g3_status::G3Status::thin_result(result);
}
fn print_tool_header(&self, tool_name: &str, _tool_args: Option<&serde_json::Value>) {
fn print_tool_header(&self, tool_name: &str) {
// Store the tool name and clear args for collection
*self.current_tool_name.lock().unwrap() = Some(tool_name.to_string());
self.current_tool_args.lock().unwrap().clear();
// Check if this is a todo tool call
let is_todo = tool_name == "todo_read" || tool_name == "todo_write";
*self.in_todo_tool.lock().unwrap() = is_todo;
// For todo tools, we'll skip the normal header and print a custom one later
if is_todo {
}
}
fn print_tool_arg(&self, key: &str, value: &str) {
@@ -290,32 +167,14 @@ impl UiWriter for ConsoleUiWriter {
}
fn print_tool_output_header(&self) {
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
self.hint_state.handle_hint(ToolParsingHint::Complete);
// Add blank line if last output was text (for visual separation)
let last_was_text = self.hint_state.last_output_was_text.load(Ordering::Relaxed);
if last_was_text {
println!();
// Skip normal header for todo tools
if *self.in_todo_tool.lock().unwrap() {
println!(); // Just add a newline
return;
}
self.hint_state.last_output_was_text.store(false, Ordering::Relaxed);
self.hint_state.last_output_was_tool.store(true, Ordering::Relaxed);
// Reset output_line_printed at the start of a new tool output
// This ensures the header isn't cleared by update_tool_output_line
*self.output_line_printed.lock().unwrap() = false;
// Reset shell compact mode
*self.is_shell_compact.lock().unwrap() = false;
// Now print the tool header with the most important arg
// Use light gray/silver in agent mode, bold green otherwise
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed);
// Light gray/silver: \x1b[38;5;250m, Bold green: \x1b[1;32m
let tool_color = if is_agent_mode {
TOOL_COLOR_AGENT_BOLD
} else {
TOOL_COLOR_NORMAL_BOLD
};
println!();
// Now print the tool header with the most important arg in bold green
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
let args = self.current_tool_args.lock().unwrap();
@@ -330,28 +189,16 @@ impl UiWriter for ConsoleUiWriter {
// For multi-line values, only show the first line
let first_line = value.lines().next().unwrap_or("");
// Get workspace path for shortening
let workspace = self.get_workspace_path();
let workspace_ref = workspace.as_deref();
// Get project info for shortening
let project_info = self.get_project_info();
let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str()));
// Shorten paths in the value (handles both file paths and shell commands)
let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref);
// Truncate long values for display (after shortening)
let display_value = if shortened.chars().count() > 80 {
// Truncate long values for display
let display_value = if first_line.len() > 80 {
// Use char_indices to safely truncate at character boundary
let truncate_at = shortened
.char_indices()
let truncate_at = first_line.char_indices()
.nth(77)
.map(|(i, _)| i)
.unwrap_or(shortened.len());
format!("{}...", &shortened[..truncate_at])
.unwrap_or(first_line.len());
format!("{}...", &first_line[..truncate_at])
} else {
shortened
first_line.to_string()
};
// Add range information for read_file tool calls
@@ -359,18 +206,10 @@ impl UiWriter for ConsoleUiWriter {
// Check if start or end parameters are present
let has_start = args.iter().any(|(k, _)| k == "start");
let has_end = args.iter().any(|(k, _)| k == "end");
if has_start || has_end {
let start_val = args
.iter()
.find(|(k, _)| k == "start")
.map(|(_, v)| v.as_str())
.unwrap_or("0");
let end_val = args
.iter()
.find(|(k, _)| k == "end")
.map(|(_, v)| v.as_str())
.unwrap_or("end");
let start_val = args.iter().find(|(k, _)| k == "start").map(|(_, v)| v.as_str()).unwrap_or("0");
let end_val = args.iter().find(|(k, _)| k == "end").map(|(_, v)| v.as_str()).unwrap_or("end");
format!(" [{}..{}]", start_val, end_val)
} else {
String::new()
@@ -379,64 +218,27 @@ impl UiWriter for ConsoleUiWriter {
String::new()
};
// Check if this is a shell command - use compact format
if tool_name == "shell" {
*self.is_shell_compact.lock().unwrap() = true;
// Print compact shell header: "● shell | command"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m|\x1b[0m \x1b[35m{}\x1b[0m",
tool_color, tool_name, display_value
);
return;
}
// Print with tool name in color (royal blue for agent mode, green otherwise)
println!(
"┌─{} {}\x1b[0m\x1b[35m | {}{}\x1b[0m",
tool_color, tool_name, display_value, header_suffix
);
// Print with bold green tool name, purple (non-bold) for pipe and args
println!("┌─\x1b[1;32m {}\x1b[0m\x1b[35m | {}{}\x1b[0m", tool_name, display_value, header_suffix);
} else {
// Print with tool name in color
println!("┌─{} {}\x1b[0m", tool_color, tool_name);
// Print with bold green formatting using ANSI escape codes
println!("┌─\x1b[1;32m {}\x1b[0m", tool_name);
}
}
}
fn update_tool_output_line(&self, line: &str) {
// Truncate long lines to prevent terminal wrapping issues
// When lines wrap, the cursor-up escape code only moves up one visual line
const MAX_LINE_WIDTH: usize = 120;
let mut current_line = self.current_output_line.lock().unwrap();
let mut line_printed = self.output_line_printed.lock().unwrap();
let is_shell = *self.is_shell_compact.lock().unwrap();
// If we've already printed a line, clear it first
if *line_printed {
if is_shell {
// For shell, we printed without newline, so just clear the line
print!("\r\x1b[2K");
} else {
// Move cursor up one line and clear it
print!("\x1b[1A\x1b[2K");
}
// Move cursor up one line and clear it
print!("\x1b[1A\x1b[2K");
}
// Truncate line if needed to prevent wrapping
let display_line = if line.chars().count() > MAX_LINE_WIDTH {
let truncated: String = line.chars().take(MAX_LINE_WIDTH - 3).collect();
format!("{}...", truncated)
} else {
line.to_string()
};
// Use different prefix for shell (└─) vs other tools (│)
if is_shell {
// For shell, print without newline so timing can be appended
print!(" \x1b[2m└─ {}\x1b[0m", display_line);
} else {
println!("\x1b[2m{}\x1b[0m", display_line);
}
// Print the new line
println!("\x1b[2m{}\x1b[0m", line);
let _ = io::stdout().flush();
// Update state
@@ -445,282 +247,84 @@ impl UiWriter for ConsoleUiWriter {
}
fn print_tool_output_line(&self, line: &str) {
// Skip the TODO list header line
if line.starts_with("📝 TODO list:") {
// Special handling for todo tools
if *self.in_todo_tool.lock().unwrap() {
self.print_todo_line(line);
return;
}
println!("\x1b[2m{}\x1b[0m", line);
}
fn print_tool_output_summary(&self, count: usize) {
let is_shell = *self.is_shell_compact.lock().unwrap();
if is_shell {
// For shell, append to the same line (no newline)
print!(" \x1b[2m({} line{})\x1b[0m", count, if count == 1 { "" } else { "s" });
let _ = io::stdout().flush();
} else {
println!(
"\x1b[2m({} line{})\x1b[0m",
count,
if count == 1 { "" } else { "s" }
);
// Skip for todo tools
if *self.in_todo_tool.lock().unwrap() {
return;
}
println!(
"\x1b[2m({} line{})\x1b[0m",
count,
if count == 1 { "" } else { "s" }
);
}
fn print_tool_compact(&self, tool_name: &str, summary: &str, duration_str: &str, tokens_delta: u32, _context_percentage: f32) -> bool {
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
self.hint_state.handle_hint(ToolParsingHint::Complete);
// Handle file operation tools and other compact tools
let is_compact_tool = matches!(tool_name, "read_file" | "write_file" | "str_replace" | "remember" | "screenshot" | "coverage" | "rehydrate" | "code_search");
if !is_compact_tool {
// Reset continuation tracking for non-compact tools
*self.last_read_file_path.lock().unwrap() = None;
return false;
}
// Add blank line if last output was text (for visual separation)
if self.hint_state.last_output_was_text.load(Ordering::Relaxed) {
fn print_tool_timing(&self, duration_str: &str) {
// For todo tools, just print a simple completion message
if *self.in_todo_tool.lock().unwrap() {
println!();
*self.in_todo_tool.lock().unwrap() = false;
return;
}
self.hint_state.last_output_was_text.store(false, Ordering::Relaxed);
self.hint_state.last_output_was_tool.store(true, Ordering::Relaxed);
let args = self.current_tool_args.lock().unwrap();
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed);
// Get file path (for file operation tools)
let file_path = args
.iter()
.find(|(k, _)| k == "file_path")
.map(|(_, v)| v.as_str())
.unwrap_or("");
// Check if this is a continuation of reading the same file
let mut last_read_path = self.last_read_file_path.lock().unwrap();
let is_continuation = tool_name == "read_file" && !file_path.is_empty() && last_read_path.as_deref() == Some(file_path);
// For tools without file_path, get other relevant args
let display_arg = if file_path.is_empty() {
// For code_search, extract language and name from searches
if tool_name == "code_search" {
// searches arg is JSON array, try to extract first search's language and name
if let Some((_, searches_json)) = args.iter().find(|(k, _)| k == "searches") {
if let Ok(searches) = serde_json::from_str::<serde_json::Value>(searches_json) {
if let Some(first_search) = searches.as_array().and_then(|arr| arr.first()) {
let lang = first_search.get("language").and_then(|v| v.as_str()).unwrap_or("?");
let name = first_search.get("name").and_then(|v| v.as_str()).unwrap_or("?");
// Truncate name if too long
let display_name = if name.len() > 30 {
let truncate_at = name.char_indices().nth(27).map(|(i, _)| i).unwrap_or(name.len());
format!("{}...", &name[..truncate_at])
} else {
name.to_string()
};
format!("{}:\"{}\"", lang, display_name)
} else {
String::new()
}
// Parse the duration string to determine color
// Format is like "1.5s", "500ms", "2m 30.0s"
let color_code = if duration_str.ends_with("ms") {
// Milliseconds - use default color (< 1s)
""
} else if duration_str.contains('m') {
// Contains minutes
// Extract minutes value
if let Some(m_pos) = duration_str.find('m') {
if let Ok(minutes) = duration_str[..m_pos].trim().parse::<u32>() {
if minutes >= 5 {
"\x1b[31m" // Red for >= 5 minutes
} else {
String::new()
"\x1b[38;5;208m" // Orange for >= 1 minute but < 5 minutes
}
} else {
String::new()
"" // Default color if parsing fails
}
} else {
// For remember, screenshot, etc. - no path to show
String::new()
"" // Default color if 'm' not found (shouldn't happen)
}
} else {
// Shorten path (project -> name/, workspace -> ./, home -> ~) then truncate if still long
let workspace = self.get_workspace_path();
let project_info = self.get_project_info();
let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str()));
let shortened = shorten_path(file_path, workspace.as_deref(), project_ref);
if shortened.chars().count() > 60 {
let truncate_at = shortened
.char_indices()
.nth(57)
.map(|(i, _)| i)
.unwrap_or(shortened.len());
format!("{}...", &shortened[..truncate_at])
} else {
shortened
}
};
// Build range suffix for read_file
let range_suffix = if tool_name == "read_file" {
let has_start = args.iter().any(|(k, _)| k == "start");
let has_end = args.iter().any(|(k, _)| k == "end");
if has_start || has_end {
let start_val = args
.iter()
.find(|(k, _)| k == "start")
.map(|(_, v)| v.as_str())
.unwrap_or("0");
let end_val = args
.iter()
.find(|(k, _)| k == "end")
.map(|(_, v)| v.as_str())
.unwrap_or("end");
format!(" [{}..{}]", start_val, end_val)
} else {
String::new()
}
} else {
String::new()
};
// Color for tool name
let tool_color = if is_agent_mode { TOOL_COLOR_AGENT } else { TOOL_COLOR_NORMAL };
// Colorize summary for str_replace (green insertions, red deletions)
let display_summary = if tool_name == "str_replace" {
colorize_str_replace_summary(summary)
} else {
summary.to_string()
};
// Print compact single line
if is_continuation {
// Continuation line for consecutive read_file on same file:
// " └─ reading further [range] | summary | tokens ◉ time"
println!(
" \x1b[2m└─ reading further\x1b[0m\x1b[35m{}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
range_suffix,
display_summary,
tokens_delta,
duration_str
);
} else if display_arg.is_empty() {
// Tools without file path: " ● tool_name | summary | tokens ◉ time"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
tool_color, tool_name, display_summary, tokens_delta, duration_str
);
} else {
// Tools with file path: " ● tool_name | path [range] | summary | tokens ◉ time"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m|\x1b[0m \x1b[35m{}{}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
tool_color, tool_name, display_arg, range_suffix, display_summary, tokens_delta, duration_str
);
}
// Update last_read_file_path for continuation tracking
if tool_name == "read_file" && !file_path.is_empty() {
*last_read_path = Some(file_path.to_string());
} else {
// Reset for non-read_file tools
*last_read_path = None;
}
// Clear the stored tool info
drop(args); // Release the lock before clearing
drop(last_read_path); // Release this lock too
self.clear_tool_state();
true
}
fn print_todo_compact(&self, content: Option<&str>, is_write: bool) -> bool {
let tool_name = if is_write { "todo_write" } else { "todo_read" };
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
self.hint_state.handle_hint(ToolParsingHint::Complete);
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed);
let tool_color = if is_agent_mode { TOOL_COLOR_AGENT } else { TOOL_COLOR_NORMAL };
// Add blank line if last output was text (for visual separation)
if self.hint_state.last_output_was_text.load(Ordering::Relaxed) {
println!();
}
self.hint_state.last_output_was_text.store(false, Ordering::Relaxed);
self.hint_state.last_output_was_tool.store(true, Ordering::Relaxed);
// Reset read_file continuation tracking
*self.last_read_file_path.lock().unwrap() = None;
match content {
None => {
// Empty TODO
println!(" \x1b[2m●\x1b[0m {}{:<width$}\x1b[0m \x1b[2m|\x1b[0m \x1b[35mempty\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
}
Some(text) => {
// Header
println!(" \x1b[2m●\x1b[0m {}{:<width$}\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
let lines: Vec<&str> = text.lines().collect();
let last_idx = lines.len().saturating_sub(1);
for (i, line) in lines.iter().enumerate() {
let is_last = i == last_idx;
let prefix = if is_last { "" } else { "" };
// Convert checkboxes to styled symbols and strikethrough completed items
let is_completed = line.contains("- [x]") || line.contains("- [X]");
let styled_line = if is_completed {
// Replace checkbox and apply strikethrough to the task text
let task_text = line
.replace("- [x]", "")
.replace("- [X]", "")
.trim_start()
.to_string();
format!("\x1b[9m{}\x1b[0m\x1b[2m", task_text) // \x1b[9m is strikethrough
} else if duration_str.ends_with('s') {
// Seconds only
if let Some(s_value) = duration_str.strip_suffix('s') {
if let Ok(seconds) = s_value.trim().parse::<f64>() {
if seconds >= 1.0 {
"\x1b[33m" // Yellow for >= 1 second
} else {
line.replace("- [ ]", "")
};
// Dim the line content
println!(" \x1b[2m{} {}\x1b[0m", prefix, styled_line);
"" // Default color for < 1 second
}
} else {
"" // Default color if parsing fails
}
// Add blank line after content for readability
println!();
} else {
"" // Default color
}
}
// Clear tool state
self.clear_tool_state();
true
}
fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32) {
let color_code = duration_color(duration_str);
// Reset read_file continuation tracking for non-read_file tools
// (read_file tools handle this in print_tool_compact)
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
if tool_name != "read_file" {
*self.last_read_file_path.lock().unwrap() = None;
}
}
// Add blank line before footer for research tool (its output is a full report)
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
if tool_name == "research" {
println!();
}
}
// Check if we're in shell compact mode - append timing to the output line
let is_shell = *self.is_shell_compact.lock().unwrap();
if is_shell {
// Append timing to the same line as shell output
println!(" \x1b[2m| {}{}{}\x1b[0m", tokens_delta, color_code, duration_str);
println!();
} else {
println!("└─ ⚡️ {}{}\x1b[0m \x1b[2m{} ◉ | {:.0}%\x1b[0m", color_code, duration_str, tokens_delta, context_percentage);
println!();
}
// Milliseconds or other format - use default color
""
};
println!("└─ ⚡️ {}{}\x1b[0m", color_code, duration_str);
println!();
// Clear the stored tool info
self.clear_tool_state();
*self.is_shell_compact.lock().unwrap() = false;
*self.current_tool_name.lock().unwrap() = None;
self.current_tool_args.lock().unwrap().clear();
*self.current_output_line.lock().unwrap() = None;
*self.output_line_printed.lock().unwrap() = false;
}
fn print_agent_prompt(&self) {
@@ -728,134 +332,16 @@ impl UiWriter for ConsoleUiWriter {
}
fn print_agent_response(&self, content: &str) {
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
// Initialize formatter if not already done
if formatter_guard.is_none() {
let mut skin = MadSkin::default();
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { r: 216, g: 177, b: 114 });
*formatter_guard = Some(StreamingMarkdownFormatter::new(skin));
}
// Process the chunk through the formatter
if let Some(ref mut formatter) = *formatter_guard {
// Add blank line if last output was a tool call (for visual separation)
// Only do this once at the start of new text content
let last_was_tool = self.hint_state.last_output_was_tool.load(Ordering::Relaxed);
if last_was_tool && !content.trim().is_empty() {
println!();
self.hint_state.last_output_was_tool.store(false, Ordering::Relaxed);
}
let formatted = formatter.process(content);
print!("{}", formatted);
// Track that we just output text (only if non-empty)
if !content.trim().is_empty() {
self.hint_state.last_output_was_text.store(true, Ordering::Relaxed);
// Reset read_file continuation tracking when text is output between tool calls
*self.last_read_file_path.lock().unwrap() = None;
}
let _ = io::stdout().flush();
}
}
fn finish_streaming_markdown(&self) {
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
if let Some(ref mut formatter) = *formatter_guard {
// Flush any remaining buffered content
let remaining = formatter.finish();
print!("{}", remaining);
let _ = io::stdout().flush();
}
// Reset the formatter for the next response
*formatter_guard = None;
print!("{}", content);
let _ = io::stdout().flush();
}
fn notify_sse_received(&self) {
// No-op for console - we don't track SSEs in console mode
}
fn print_tool_streaming_hint(&self, tool_name: &str) {
// Use the hint state to show the streaming indicator
self.hint_state.handle_hint(ToolParsingHint::Detected(tool_name.to_string()));
}
fn print_tool_streaming_active(&self) {
// Trigger the blink animation
self.hint_state.handle_hint(ToolParsingHint::Active);
}
fn flush(&self) {
let _ = io::stdout().flush();
}
fn prompt_user_yes_no(&self, message: &str) -> bool {
print!("{} [y/N] ", message);
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
let trimmed = input.trim().to_lowercase();
trimmed == "y" || trimmed == "yes"
} else {
false
}
}
fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize {
println!("{} ", message);
for (i, option) in options.iter().enumerate() {
println!(" [{}] {}", i + 1, option);
}
print!("Select an option (1-{}): ", options.len());
let _ = io::stdout().flush();
loop {
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
if let Ok(choice) = input.trim().parse::<usize>() {
if choice > 0 && choice <= options.len() {
return choice - 1;
}
}
}
print!("Invalid choice. Please select (1-{}): ", options.len());
let _ = io::stdout().flush();
}
}
fn filter_json_tool_calls(&self, content: &str) -> String {
// Filter the content to remove JSON tool calls from display.
// Tool streaming hints are now handled via the provider's tool_call_streaming
// field in CompletionChunk, not via callbacks during JSON filtering.
filter_json_tool_calls(content)
}
fn reset_json_filter(&self) {
// Reset the filter state for a new response
reset_json_tool_state();
}
fn set_agent_mode(&self, is_agent_mode: bool) {
self.hint_state.is_agent_mode.store(is_agent_mode, Ordering::Relaxed);
}
fn set_workspace_path(&self, path: std::path::PathBuf) {
*self.workspace_path.lock().unwrap() = Some(path);
}
fn set_project_path(&self, path: std::path::PathBuf, name: String) {
*self.project_path.lock().unwrap() = Some(path);
*self.project_name.lock().unwrap() = Some(name);
}
fn clear_project(&self) {
*self.project_path.lock().unwrap() = None;
*self.project_name.lock().unwrap() = None;
}
}

View File

@@ -1,180 +0,0 @@
//! Utility functions for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use g3_config::Config;
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use std::path::PathBuf;
use crate::cli_args::Cli;
use crate::simple_output::SimpleOutput;
/// Display context window progress bar.
pub fn display_context_progress<W: UiWriter>(agent: &Agent<W>, _output: &SimpleOutput) {
let context = agent.get_context_window();
let percentage = context.percentage_used();
// Ensure we start on a new line (previous response may not end with newline)
println!();
// Create 10 dots representing context fullness
let total_dots: usize = 10;
let filled_dots = ((percentage / 100.0) * total_dots as f32).round() as usize;
let empty_dots = total_dots.saturating_sub(filled_dots);
let filled_str = "".repeat(filled_dots);
let empty_str = "".repeat(empty_dots);
// Determine color based on percentage
let color = if percentage < 40.0 {
Color::Green
} else if percentage < 60.0 {
Color::Yellow
} else if percentage < 80.0 {
Color::Rgb {
r: 255,
g: 165,
b: 0,
} // Orange
} else {
Color::Red
};
// Format tokens as compact strings (e.g., "38.5k" instead of "38531")
let format_tokens = |tokens: u32| -> String {
if tokens >= 1_000_000 {
format!("{:.1}m", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
let k = tokens as f64 / 1000.0;
if k >= 100.0 {
format!("{:.0}k", k)
} else {
format!("{:.1}k", k)
}
} else {
format!("{}", tokens)
}
};
// Print with colored dots (using print! directly to handle color codes)
print!(
"{}{}{}{} {}/{} ◉ | {:.0}%\n",
SetForegroundColor(color),
filled_str,
empty_str,
ResetColor,
format_tokens(context.used_tokens),
format_tokens(context.total_tokens),
percentage
);
}
/// Set up the workspace directory for autonomous mode.
/// Uses G3_WORKSPACE environment variable or defaults to ~/tmp/workspace.
pub fn setup_workspace_directory() -> Result<PathBuf> {
let workspace_dir = if let Ok(env_workspace) = std::env::var("G3_WORKSPACE") {
PathBuf::from(env_workspace)
} else {
// Default to ~/tmp/workspace
let home_dir = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
home_dir.join("tmp").join("workspace")
};
// Create the directory if it doesn't exist
if !workspace_dir.exists() {
std::fs::create_dir_all(&workspace_dir)?;
let output = SimpleOutput::new();
output.print(&format!(
"📁 Created workspace directory: {}",
workspace_dir.display()
));
}
Ok(workspace_dir)
}
/// Load configuration with CLI argument overrides applied.
///
/// This is the canonical function for loading config with CLI overrides.
/// All CLI entry points should use this to ensure consistent behavior.
pub fn load_config_with_cli_overrides(cli: &Cli) -> Result<Config> {
let mut config = Config::load_with_overrides(
cli.config.as_deref(),
cli.provider.clone(),
cli.model.clone(),
)?;
// Apply webdriver flag override
if cli.webdriver {
config.webdriver.enabled = true;
}
// Apply chrome-headless flag override
// Only apply chrome-headless if safari is not explicitly set
if cli.chrome_headless && !cli.safari {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::ChromeHeadless;
// Run Chrome diagnostics - only show output if there are issues
let report =
g3_computer_control::run_chrome_diagnostics(config.webdriver.chrome_binary.as_deref());
if !report.all_ok() {
println!("{}", report.format_report());
}
}
// Apply safari flag override
if cli.safari {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::Safari;
}
// Apply no-auto-compact flag override
if cli.manual_compact {
config.agent.auto_compact = false;
}
// Validate provider if specified
if let Some(ref provider) = cli.provider {
let valid_providers = ["anthropic", "databricks", "embedded", "openai"];
if !valid_providers.contains(&provider.as_str()) {
return Err(anyhow::anyhow!(
"Invalid provider '{}'. Valid options: {:?}",
provider,
valid_providers
));
}
}
Ok(config)
}
/// Initialize logging based on CLI verbosity settings.
pub fn initialize_logging(verbose: bool) {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
let filter = if verbose {
EnvFilter::from_default_env()
.add_directive(format!("{}=debug", env!("CARGO_PKG_NAME")).parse().unwrap())
.add_directive("g3_core=debug".parse().unwrap())
.add_directive("g3_cli=debug".parse().unwrap())
.add_directive("g3_execution=debug".parse().unwrap())
.add_directive("g3_providers=debug".parse().unwrap())
} else {
EnvFilter::from_default_env()
.add_directive(format!("{}=info", env!("CARGO_PKG_NAME")).parse().unwrap())
.add_directive("g3_core=info".parse().unwrap())
.add_directive("g3_cli=info".parse().unwrap())
.add_directive("g3_execution=info".parse().unwrap())
.add_directive("g3_providers=info".parse().unwrap())
.add_directive("llama_cpp=off".parse().unwrap())
.add_directive("llama=off".parse().unwrap())
};
let _ = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)
.try_init();
}

View File

@@ -1,307 +0,0 @@
//! CLI Integration Tests (Blackbox)
//!
//! CHARACTERIZATION: These tests verify the CLI's external behavior through
//! its public interface (command-line arguments and exit codes).
//!
//! What these tests protect:
//! - CLI argument parsing works correctly
//! - Help and version output are available
//! - Invalid arguments produce appropriate errors
//! - Workspace directory handling works
//!
//! What these tests intentionally do NOT assert:
//! - Internal implementation details
//! - Specific error message wording (only that errors occur)
//! - Provider-specific behavior (requires API keys)
use std::process::Command;
/// Get the path to the g3 binary.
/// In test mode, this will be in the target/debug directory.
fn get_g3_binary() -> String {
// When running tests, the binary is in target/debug/
let mut path = std::env::current_exe().unwrap();
path.pop(); // Remove test binary name
path.pop(); // Remove deps
path.push("g3");
path.to_string_lossy().to_string()
}
// =============================================================================
// Test: --help flag produces help output
// =============================================================================
#[test]
fn test_help_flag_produces_output() {
let output = Command::new(get_g3_binary())
.arg("--help")
.output()
.expect("Failed to execute g3 --help");
// Help should succeed
assert!(
output.status.success(),
"g3 --help should exit successfully"
);
let stdout = String::from_utf8_lossy(&output.stdout);
// Should contain key elements of help output
assert!(
stdout.contains("Usage:"),
"Help output should contain 'Usage:'"
);
assert!(
stdout.contains("Options:"),
"Help output should contain 'Options:'"
);
assert!(
stdout.contains("--help"),
"Help output should mention --help flag"
);
assert!(
stdout.contains("--version"),
"Help output should mention --version flag"
);
}
#[test]
fn test_short_help_flag() {
let output = Command::new(get_g3_binary())
.arg("-h")
.output()
.expect("Failed to execute g3 -h");
assert!(output.status.success(), "g3 -h should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Usage:"),
"Short help should also show usage"
);
}
// =============================================================================
// Test: --version flag produces version output
// =============================================================================
#[test]
fn test_version_flag_produces_output() {
let output = Command::new(get_g3_binary())
.arg("--version")
.output()
.expect("Failed to execute g3 --version");
assert!(
output.status.success(),
"g3 --version should exit successfully"
);
let stdout = String::from_utf8_lossy(&output.stdout);
// Should contain version number pattern (e.g., "g3 0.1.0")
assert!(
stdout.contains("g3") || stdout.contains("0."),
"Version output should contain program name or version number"
);
}
#[test]
fn test_short_version_flag() {
let output = Command::new(get_g3_binary())
.arg("-V")
.output()
.expect("Failed to execute g3 -V");
assert!(output.status.success(), "g3 -V should exit successfully");
}
// =============================================================================
// Test: Invalid arguments produce errors
// =============================================================================
#[test]
fn test_invalid_flag_produces_error() {
let output = Command::new(get_g3_binary())
.arg("--this-flag-does-not-exist")
.output()
.expect("Failed to execute g3 with invalid flag");
// Should fail with non-zero exit code
assert!(
!output.status.success(),
"Invalid flag should cause non-zero exit"
);
let stderr = String::from_utf8_lossy(&output.stderr);
// Should have some error message
assert!(
!stderr.is_empty() || !output.stdout.is_empty(),
"Should produce some output on invalid flag"
);
}
// =============================================================================
// Test: Conflicting mode flags
// =============================================================================
#[test]
fn test_agent_conflicts_with_autonomous() {
// --agent conflicts with --autonomous
let output = Command::new(get_g3_binary())
.args(["--agent", "test", "--autonomous"])
.output()
.expect("Failed to execute g3 with conflicting flags");
// Should fail due to conflicting arguments
assert!(
!output.status.success(),
"--agent and --autonomous should conflict"
);
}
#[test]
fn test_planning_conflicts_with_autonomous() {
let output = Command::new(get_g3_binary())
.args(["--planning", "--autonomous"])
.output()
.expect("Failed to execute g3 with conflicting flags");
assert!(
!output.status.success(),
"--planning and --autonomous should conflict"
);
}
// =============================================================================
// Test: Workspace directory option is accepted
// =============================================================================
#[test]
fn test_workspace_option_accepted() {
// Just verify the option is recognized (don't actually run the agent)
let output = Command::new(get_g3_binary())
.args(["--workspace", "/tmp", "--help"])
.output()
.expect("Failed to execute g3 with workspace option");
// --help should still work even with other options
assert!(
output.status.success(),
"--workspace option should be recognized"
);
}
// =============================================================================
// Test: Config file option is accepted
// =============================================================================
#[test]
fn test_config_option_accepted() {
let output = Command::new(get_g3_binary())
.args(["--config", "/nonexistent/config.toml", "--help"])
.output()
.expect("Failed to execute g3 with config option");
// --help should still work
assert!(
output.status.success(),
"--config option should be recognized"
);
}
// =============================================================================
// Test: Provider override option is accepted
// =============================================================================
#[test]
fn test_provider_option_accepted() {
let output = Command::new(get_g3_binary())
.args(["--provider", "anthropic", "--help"])
.output()
.expect("Failed to execute g3 with provider option");
assert!(
output.status.success(),
"--provider option should be recognized"
);
}
// =============================================================================
// Test: Quiet mode option is accepted
// =============================================================================
#[test]
fn test_quiet_option_accepted() {
let output = Command::new(get_g3_binary())
.args(["--quiet", "--help"])
.output()
.expect("Failed to execute g3 with quiet option");
assert!(
output.status.success(),
"--quiet option should be recognized"
);
}
// =============================================================================
// Test: Include prompt option is accepted
// =============================================================================
#[test]
fn test_include_prompt_option_accepted() {
let output = Command::new(get_g3_binary())
.args(["--include-prompt", "/tmp/prompt.md", "--help"])
.output()
.expect("Failed to execute g3 with include-prompt option");
assert!(
output.status.success(),
"--include-prompt option should be recognized"
);
}
#[test]
fn test_include_prompt_in_help_output() {
let output = Command::new(get_g3_binary())
.arg("--help")
.output()
.expect("Failed to execute g3 --help");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--include-prompt"),
"Help output should mention --include-prompt flag"
);
}
// =============================================================================
// Test: No auto-memory option is accepted
// =============================================================================
#[test]
fn test_no_auto_memory_option_accepted() {
let output = Command::new(get_g3_binary())
.args(["--no-auto-memory", "--help"])
.output()
.expect("Failed to execute g3 with no-auto-memory option");
assert!(
output.status.success(),
"--no-auto-memory option should be recognized"
);
}
#[test]
fn test_no_auto_memory_in_help_output() {
let output = Command::new(get_g3_binary())
.arg("--help")
.output()
.expect("Failed to execute g3 --help");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--no-auto-memory"),
"Help output should mention --no-auto-memory flag"
);
}

View File

@@ -1,344 +0,0 @@
use serde_json::json;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_extract_coach_feedback_with_timing_message() {
// Create a temporary directory for session logs
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".g3").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
// Create a mock session log with the problematic conversation history
// where timing message appears after the tool result
let session_id = "test_session_123";
let session_dir = sessions_dir.join(session_id);
fs::create_dir_all(&session_dir).unwrap();
let log_file_path = session_dir.join("session.json");
let log_content = json!({
"session_id": session_id,
"context_window": {
"conversation_history": [
{
"role": "assistant",
"content": "{\"tool\": \"final_output\", \"args\": {\"summary\":\"IMPLEMENTATION_APPROVED\"}}"
},
{
"role": "user",
"content": "Tool result: IMPLEMENTATION_APPROVED"
},
{
"role": "assistant",
"content": "🕝 27.7s | 💭 7.5s"
}
]
}
});
fs::write(&log_file_path, serde_json::to_string_pretty(&log_content).unwrap()).unwrap();
// Now test the extraction logic
let log_content_str = fs::read_to_string(&log_file_path).unwrap();
let log_json: serde_json::Value = serde_json::from_str(&log_content_str).unwrap();
if let Some(context_window) = log_json.get("context_window") {
if let Some(conversation_history) = context_window.get("conversation_history") {
if let Some(messages) = conversation_history.as_array() {
// This is the key logic we're testing - find the last USER message with "Tool result:"
let last_tool_result = messages.iter().rev().find(|msg| {
if let Some(role) = msg.get("role") {
if let Some(role_str) = role.as_str() {
if role_str == "User" || role_str == "user" {
if let Some(content) = msg.get("content") {
if let Some(content_str) = content.as_str() {
return content_str.starts_with("Tool result:");
}
}
}
}
}
false
});
// Verify we found the correct message
assert!(last_tool_result.is_some(), "Should find the tool result message");
if let Some(last_message) = last_tool_result {
if let Some(content) = last_message.get("content") {
if let Some(content_str) = content.as_str() {
let feedback = if content_str.starts_with("Tool result: ") {
content_str.strip_prefix("Tool result: ").unwrap_or(content_str)
} else {
content_str
};
// Verify we extracted the correct feedback
assert_eq!(feedback, "IMPLEMENTATION_APPROVED", "Should extract the actual feedback, not timing");
// Verify the feedback is NOT the timing message
assert!(!feedback.contains("🕝"), "Feedback should not be the timing message");
println!("✅ Successfully extracted coach feedback: {}", feedback);
return;
}
}
}
}
}
}
panic!("Failed to extract coach feedback");
}
#[test]
fn test_extract_only_final_output_tool_results() {
// Test that we only extract tool results from final_output, not from other tools
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".g3").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
let session_id = "test_session_final_output_only";
let session_dir = sessions_dir.join(session_id);
fs::create_dir_all(&session_dir).unwrap();
let log_file_path = session_dir.join("session.json");
let log_content = json!({
"session_id": session_id,
"context_window": {
"conversation_history": [
{
"role": "assistant",
"content": "{\"tool\": \"shell\", \"args\": {\"command\":\"ls\"}}"
},
{
"role": "user",
"content": "Tool result: file1.txt\nfile2.txt"
},
{
"role": "assistant",
"content": "{\"tool\": \"read_file\", \"args\": {\"file_path\":\"test.txt\"}}"
},
{
"role": "user",
"content": "Tool result: This is test content"
},
{
"role": "assistant",
"content": "{\"tool\": \"final_output\", \"args\": {\"summary\":\"APPROVED_RESULT\"}}"
},
{
"role": "user",
"content": "Tool result: APPROVED_RESULT"
},
{
"role": "assistant",
"content": "🕝 20.5s | 💭 5.2s"
}
]
}
});
fs::write(&log_file_path, serde_json::to_string_pretty(&log_content).unwrap()).unwrap();
// Test the new extraction logic that verifies the tool is final_output
let log_content_str = fs::read_to_string(&log_file_path).unwrap();
let log_json: serde_json::Value = serde_json::from_str(&log_content_str).unwrap();
if let Some(context_window) = log_json.get("context_window") {
if let Some(conversation_history) = context_window.get("conversation_history") {
if let Some(messages) = conversation_history.as_array() {
// Go backwards through messages to find final_output tool result
for i in (0..messages.len()).rev() {
let msg = &messages[i];
if let Some(role) = msg.get("role") {
if let Some(role_str) = role.as_str() {
if role_str == "User" || role_str == "user" {
if let Some(content) = msg.get("content") {
if let Some(content_str) = content.as_str() {
if content_str.starts_with("Tool result:") {
// Check if preceding message was final_output
if i > 0 {
let prev_msg = &messages[i - 1];
if let Some(prev_content) = prev_msg.get("content") {
if let Some(prev_content_str) = prev_content.as_str() {
if prev_content_str.contains("\"tool\": \"final_output\"") {
let feedback = content_str.strip_prefix("Tool result: ").unwrap_or(content_str);
assert_eq!(feedback, "APPROVED_RESULT", "Should extract only final_output result");
println!("✅ Correctly extracted only final_output tool result: {}", feedback);
return;
}
}
}
}
}
}
}
}
}
}
}
}
}
}
panic!("Failed to extract final_output tool result");
}
#[test]
fn test_extract_coach_feedback_without_timing_message() {
// Create a temporary directory for session logs
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".g3").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
// Test the case where there's no timing message (backward compatibility)
let session_id = "test_session_456";
let session_dir = sessions_dir.join(session_id);
fs::create_dir_all(&session_dir).unwrap();
let log_file_path = session_dir.join("session.json");
let log_content = json!({
"session_id": session_id,
"context_window": {
"conversation_history": [
{
"role": "assistant",
"content": "{\"tool\": \"final_output\", \"args\": {\"summary\":\"TEST_FEEDBACK\"}}"
},
{
"role": "user",
"content": "Tool result: TEST_FEEDBACK"
}
]
}
});
fs::write(&log_file_path, serde_json::to_string_pretty(&log_content).unwrap()).unwrap();
// Test extraction
let log_content_str = fs::read_to_string(&log_file_path).unwrap();
let log_json: serde_json::Value = serde_json::from_str(&log_content_str).unwrap();
if let Some(context_window) = log_json.get("context_window") {
if let Some(conversation_history) = context_window.get("conversation_history") {
if let Some(messages) = conversation_history.as_array() {
let last_tool_result = messages.iter().rev().find(|msg| {
if let Some(role) = msg.get("role") {
if let Some(role_str) = role.as_str() {
if role_str == "User" || role_str == "user" {
if let Some(content) = msg.get("content") {
if let Some(content_str) = content.as_str() {
return content_str.starts_with("Tool result:");
}
}
}
}
}
false
});
assert!(last_tool_result.is_some());
if let Some(last_message) = last_tool_result {
if let Some(content) = last_message.get("content") {
if let Some(content_str) = content.as_str() {
let feedback = content_str.strip_prefix("Tool result: ").unwrap_or(content_str);
assert_eq!(feedback, "TEST_FEEDBACK");
println!("✅ Successfully extracted coach feedback without timing: {}", feedback);
return;
}
}
}
}
}
}
panic!("Failed to extract coach feedback");
}
#[test]
fn test_extract_coach_feedback_with_multiple_tool_results() {
// Test that we get the LAST tool result when there are multiple
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".g3").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
let session_id = "test_session_789";
let session_dir = sessions_dir.join(session_id);
fs::create_dir_all(&session_dir).unwrap();
let log_file_path = session_dir.join("session.json");
let log_content = json!({
"session_id": session_id,
"context_window": {
"conversation_history": [
{
"role": "assistant",
"content": "{\"tool\": \"shell\", \"args\": {\"command\":\"ls\"}}"
},
{
"role": "user",
"content": "Tool result: file1.txt\nfile2.txt"
},
{
"role": "assistant",
"content": "{\"tool\": \"final_output\", \"args\": {\"summary\":\"FINAL_RESULT\"}}"
},
{
"role": "user",
"content": "Tool result: FINAL_RESULT"
},
{
"role": "assistant",
"content": "🕝 15.2s | 💭 3.1s"
}
]
}
});
fs::write(&log_file_path, serde_json::to_string_pretty(&log_content).unwrap()).unwrap();
// Test extraction
let log_content_str = fs::read_to_string(&log_file_path).unwrap();
let log_json: serde_json::Value = serde_json::from_str(&log_content_str).unwrap();
if let Some(context_window) = log_json.get("context_window") {
if let Some(conversation_history) = context_window.get("conversation_history") {
if let Some(messages) = conversation_history.as_array() {
let last_tool_result = messages.iter().rev().find(|msg| {
if let Some(role) = msg.get("role") {
if let Some(role_str) = role.as_str() {
if role_str == "User" || role_str == "user" {
if let Some(content) = msg.get("content") {
if let Some(content_str) = content.as_str() {
return content_str.starts_with("Tool result:");
}
}
}
}
}
false
});
assert!(last_tool_result.is_some());
if let Some(last_message) = last_tool_result {
if let Some(content) = last_message.get("content") {
if let Some(content_str) = content.as_str() {
let feedback = content_str.strip_prefix("Tool result: ").unwrap_or(content_str);
// Should get the LAST tool result (final_output), not the first one (shell)
assert_eq!(feedback, "FINAL_RESULT", "Should extract the last tool result");
assert!(!feedback.contains("file1.txt"), "Should not extract earlier tool results");
println!("✅ Successfully extracted last tool result: {}", feedback);
return;
}
}
}
}
}
}
panic!("Failed to extract coach feedback");
}

View File

@@ -1,644 +0,0 @@
//! Stress tests for JSON tool call filtering.
//!
//! These tests hammer the filter with malformed JSON, partial tool calls,
//! edge cases, and adversarial inputs to ensure robustness.
use g3_cli::filter_json::{filter_json_tool_calls, flush_json_tool_filter, reset_json_tool_state};
// ============================================================================
// Malformed JSON Tests
// ============================================================================
#[test]
fn test_unclosed_brace_at_end() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"cmd\": \"ls\"";
let result = filter_json_tool_calls(input);
// Should suppress the incomplete tool call
assert_eq!(result, "Text\n");
}
#[test]
fn test_missing_closing_quote() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"cmd\": \"ls}}\nMore";
let result = filter_json_tool_calls(input);
// The unbalanced quote makes brace counting tricky
// Should still filter the tool call attempt
assert_eq!(result, "Text\n");
}
#[test]
fn test_extra_closing_braces() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {}}}}}\nMore";
let result = filter_json_tool_calls(input);
// Extra braces after valid JSON should pass through
assert_eq!(result, "Text\n}}}\nMore");
}
#[test]
fn test_deeply_nested_malformed() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"x\", \"args\": {{{{{{}}}}}}}\nMore";
let result = filter_json_tool_calls(input);
// Should handle deep nesting - extra braces get consumed as part of the tool call
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_null_bytes_in_json() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\0\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Should handle null bytes gracefully
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_unicode_in_tool_name() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shëll\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Unicode in tool name - still a valid tool call pattern
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_emoji_in_args() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"msg\": \"Hello 🎉\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_very_long_string_value() {
reset_json_tool_state();
let long_string = "x".repeat(10000);
let input = format!("Text\n{{\"tool\": \"shell\", \"args\": {{\"data\": \"{}\"}}}}\nMore", long_string);
let result = filter_json_tool_calls(&input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_many_escaped_quotes() {
reset_json_tool_state();
let input = r#"Text
{"tool": "shell", "args": {"cmd": "echo \"a\" \"b\" \"c\" \"d\" \"e\""}}
More"#;
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_escaped_backslash_before_quote() {
reset_json_tool_state();
// This is: {"tool": "shell", "args": {"path": "C:\\"}}
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"path\": \"C:\\\\\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_newlines_inside_string() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"shell\", \"args\": {\"cmd\": \"echo\\nworld\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
// ============================================================================
// Partial Tool Call Pattern Tests
// ============================================================================
#[test]
fn test_just_opening_brace() {
reset_json_tool_state();
let result = filter_json_tool_calls("Text\n{");
// Should buffer, waiting for more
assert_eq!(result, "Text\n");
// Now send something that's not a tool call
let result2 = filter_json_tool_calls("\"other\": 1}\nMore");
assert_eq!(result2, "{\"other\": 1}\nMore");
}
#[test]
fn test_partial_tool_keyword() {
reset_json_tool_state();
let chunks = vec!["Text\n{", "\"to", "ol", "\": ", "\"sh", "ell\"", ", \"args\": {}", "}\nMore"];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_tool_then_not_colon() {
reset_json_tool_state();
let input = "Text\n{\"tool\" \"shell\"}\nMore"; // Missing colon
let result = filter_json_tool_calls(input);
// Not a valid tool call pattern - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_colon_then_number() {
reset_json_tool_state();
let input = "Text\n{\"tool\": 123}\nMore"; // Number instead of string
let result = filter_json_tool_calls(input);
// Not a valid tool call pattern - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_colon_then_null() {
reset_json_tool_state();
let input = "Text\n{\"tool\": null}\nMore";
let result = filter_json_tool_calls(input);
// Not a valid tool call pattern - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_colon_then_array() {
reset_json_tool_state();
let input = "Text\n{\"tool\": []}\nMore";
let result = filter_json_tool_calls(input);
// Not a valid tool call pattern - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_colon_then_object() {
reset_json_tool_state();
let input = "Text\n{\"tool\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Not a valid tool call pattern - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tools_plural() {
reset_json_tool_state();
let input = "Text\n{\"tools\": \"shell\"}\nMore";
let result = filter_json_tool_calls(input);
// "tools" is not "tool" - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_with_prefix() {
reset_json_tool_state();
let input = "Text\n{\"mytool\": \"shell\"}\nMore";
let result = filter_json_tool_calls(input);
// "mytool" is not "tool" - should pass through
assert_eq!(result, input);
}
#[test]
fn test_tool_uppercase() {
reset_json_tool_state();
let input = "Text\n{\"TOOL\": \"shell\"}\nMore";
let result = filter_json_tool_calls(input);
// "TOOL" is not "tool" - should pass through
assert_eq!(result, input);
}
// ============================================================================
// Streaming Edge Cases
// ============================================================================
#[test]
fn test_single_char_streaming() {
reset_json_tool_state();
let input = "Hi\n{\"tool\": \"x\", \"args\": {}}\nBye";
let mut result = String::new();
for ch in input.chars() {
result.push_str(&filter_json_tool_calls(&ch.to_string()));
}
assert_eq!(result, "Hi\n\nBye");
}
#[test]
fn test_two_char_streaming() {
reset_json_tool_state();
let input = "Hi\n{\"tool\": \"x\", \"args\": {}}\nBye";
let mut result = String::new();
let chars: Vec<char> = input.chars().collect();
for chunk in chars.chunks(2) {
let s: String = chunk.iter().collect();
result.push_str(&filter_json_tool_calls(&s));
}
assert_eq!(result, "Hi\n\nBye");
}
#[test]
fn test_random_chunk_sizes() {
reset_json_tool_state();
let input = "Before\n{\"tool\": \"shell\", \"args\": {\"cmd\": \"ls -la\"}}\nAfter";
// Chunk at various sizes
let chunk_sizes = [1, 3, 7, 11, 13, 17];
for &size in &chunk_sizes {
reset_json_tool_state();
let mut result = String::new();
let mut pos = 0;
while pos < input.len() {
let end = (pos + size).min(input.len());
let chunk = &input[pos..end];
result.push_str(&filter_json_tool_calls(chunk));
pos = end;
}
assert_eq!(result, "Before\n\nAfter", "Failed with chunk size {}", size);
}
}
#[test]
fn test_chunk_boundary_at_brace() {
reset_json_tool_state();
let chunks = vec!["Text\n", "{", "\"tool\": \"x\", \"args\": {}", "}", "\nMore"];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_chunk_boundary_at_quote() {
reset_json_tool_state();
let chunks = vec!["Text\n{\"tool\": \"", "shell", "\", \"args\": {}}", "\nMore"];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_chunk_boundary_at_colon() {
reset_json_tool_state();
let chunks = vec!["Text\n{\"tool\"", ":", " \"shell\", \"args\": {}}\nMore"];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Text\n\nMore");
}
// ============================================================================
// Multiple Tool Calls
// ============================================================================
#[test]
fn test_two_tool_calls_same_line() {
reset_json_tool_state();
// Two tool calls on same line (no newline between)
let input = "Text\n{\"tool\": \"a\", \"args\": {}}{\"tool\": \"b\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// First is filtered (starts at line beginning)
// Second starts immediately after first's }, not at line start, so passes through
// This is acceptable - LLMs typically put tool calls on separate lines
assert_eq!(result, "Text\n{\"tool\": \"b\", \"args\": {}}\nMore");
}
#[test]
fn test_three_tool_calls_separate_lines() {
reset_json_tool_state();
let input = "A\n{\"tool\": \"x\", \"args\": {}}\nB\n{\"tool\": \"y\", \"args\": {}}\nC\n{\"tool\": \"z\", \"args\": {}}\nD";
let result = filter_json_tool_calls(input);
assert_eq!(result, "A\n\nB\n\nC\n\nD");
}
#[test]
fn test_tool_call_then_regular_json() {
reset_json_tool_state();
let input = "A\n{\"tool\": \"x\", \"args\": {}}\nB\n{\"data\": 123}\nC";
let result = filter_json_tool_calls(input);
// First is tool call (filtered), second is regular JSON (kept)
assert_eq!(result, "A\n\nB\n{\"data\": 123}\nC");
}
#[test]
fn test_regular_json_then_tool_call() {
reset_json_tool_state();
let input = "A\n{\"data\": 123}\nB\n{\"tool\": \"x\", \"args\": {}}\nC";
let result = filter_json_tool_calls(input);
assert_eq!(result, "A\n{\"data\": 123}\nB\n\nC");
}
// ============================================================================
// Adversarial Inputs
// ============================================================================
#[test]
fn test_fake_tool_in_string() {
reset_json_tool_state();
// The tool pattern appears inside a string value
let input = r#"Text
{"message": "{\"tool\": \"shell\"}"}
More"#;
let result = filter_json_tool_calls(input);
// Should pass through - the pattern is inside a string
assert_eq!(result, input);
}
#[test]
fn test_nested_json_with_tool_key() {
reset_json_tool_state();
// Nested object has "tool" key but outer doesn't match pattern
let input = "Text\n{\"outer\": {\"tool\": \"inner\"}}\nMore";
let result = filter_json_tool_calls(input);
// Should pass through - outer object doesn't start with "tool"
assert_eq!(result, input);
}
#[test]
fn test_brace_bomb() {
reset_json_tool_state();
// Many braces to stress the counter
let input = "Text\n{\"tool\": \"x\", \"args\": {\"a\": {\"b\": {\"c\": {\"d\": {\"e\": {}}}}}}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_string_with_many_braces() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"x\", \"args\": {\"code\": \"{{{{}}}}\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_alternating_braces_in_string() {
reset_json_tool_state();
let input = "Text\n{\"tool\": \"x\", \"args\": {\"pat\": \"}{}{}{\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_quote_after_backslash_in_string() {
reset_json_tool_state();
// Tricky: \" inside string should not end the string
let input = r#"Text
{"tool": "x", "args": {"msg": "say \"hi\""}}
More"#;
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_double_backslash_then_quote() {
reset_json_tool_state();
// \\ followed by " - the quote DOES end the string
let input = "Text\n{\"tool\": \"x\", \"args\": {\"path\": \"C:\\\\\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_triple_backslash_then_quote() {
reset_json_tool_state();
// \\\" - escaped backslash followed by escaped quote
let input = "Text\n{\"tool\": \"x\", \"args\": {\"s\": \"a\\\\\\\"b\"}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
// ============================================================================
// Whitespace Variations
// ============================================================================
#[test]
fn test_tabs_before_brace() {
reset_json_tool_state();
let input = "Text\n\t\t{\"tool\": \"x\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Indented JSON should NOT be filtered - real tool calls are never indented
assert_eq!(result, input);
}
#[test]
fn test_spaces_before_brace() {
reset_json_tool_state();
let input = "Text\n {\"tool\": \"x\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Indented JSON should NOT be filtered - real tool calls are never indented
assert_eq!(result, input);
}
#[test]
fn test_mixed_whitespace_before_brace() {
reset_json_tool_state();
let input = "Text\n \t \t {\"tool\": \"x\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
// Indented JSON should NOT be filtered - real tool calls are never indented
assert_eq!(result, input);
}
#[test]
fn test_space_after_opening_brace() {
reset_json_tool_state();
let input = "Text\n{ \"tool\": \"x\", \"args\": {}}\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_lots_of_space_in_json() {
reset_json_tool_state();
let input = "Text\n{ \"tool\" : \"x\" , \"args\" : { } }\nMore";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Text\n\nMore");
}
#[test]
fn test_crlf_line_endings() {
reset_json_tool_state();
let input = "Text\r\n{\"tool\": \"x\", \"args\": {}}\r\nMore";
let result = filter_json_tool_calls(input);
// \r is not treated as line start, so { after \r\n should work
// Actually \n triggers line start, \r is just a regular char
assert_eq!(result, "Text\r\n\r\nMore");
}
// ============================================================================
// Empty and Minimal Cases
// ============================================================================
#[test]
fn test_empty_input() {
reset_json_tool_state();
assert_eq!(filter_json_tool_calls(""), "");
}
#[test]
fn test_just_newline() {
reset_json_tool_state();
let result = filter_json_tool_calls("\n");
let flushed = flush_json_tool_filter();
assert_eq!(format!("{}{}", result, flushed), "\n");
}
#[test]
fn test_just_brace() {
reset_json_tool_state();
let r1 = filter_json_tool_calls("{");
// At start of input (line start), { triggers buffering
assert_eq!(r1, "");
// Send non-tool content - the newline comes through
let r2 = filter_json_tool_calls("}\n");
assert_eq!(r2, "{}\n");
}
#[test]
fn test_minimal_tool_call() {
reset_json_tool_state();
let input = "{\"tool\":\"x\",\"args\":{}}";
let result = filter_json_tool_calls(input);
assert_eq!(result, "");
}
#[test]
fn test_tool_call_at_very_start() {
reset_json_tool_state();
let input = "{\"tool\": \"x\", \"args\": {}}\nAfter";
let result = filter_json_tool_calls(input);
assert_eq!(result, "\nAfter");
}
// ============================================================================
// State Reset Tests
// ============================================================================
#[test]
fn test_reset_clears_buffering_state() {
reset_json_tool_state();
// Start a potential tool call
let _ = filter_json_tool_calls("Text\n{");
// Reset
reset_json_tool_state();
// New input should work fresh
let result = filter_json_tool_calls("Fresh start");
assert_eq!(result, "Fresh start");
}
#[test]
fn test_reset_clears_suppressing_state() {
reset_json_tool_state();
// Start suppressing a tool call
let _ = filter_json_tool_calls("Text\n{\"tool\": \"x\", \"args\": {");
// Reset
reset_json_tool_state();
// New input should work fresh
let result = filter_json_tool_calls("Fresh start");
assert_eq!(result, "Fresh start");
}
// ============================================================================
// Real-World Patterns from Bug Reports
// ============================================================================
#[test]
fn test_str_replace_with_diff() {
reset_json_tool_state();
let input = r#"I'll update the file:
{"tool": "str_replace", "args": {"file_path": "src/main.rs", "diff": "@@ -1,3 +1,4 @@\n fn main() {\n+ println!(\"Hello\");\n }"}}
Done!"#;
let result = filter_json_tool_calls(input);
assert_eq!(result, "I'll update the file:\n\nDone!");
}
#[test]
fn test_shell_with_complex_command() {
reset_json_tool_state();
let input = r#"Running command:
{"tool": "shell", "args": {"command": "find . -name '*.rs' -exec grep -l 'TODO' {} \;"}}
Results above."#;
let result = filter_json_tool_calls(input);
assert_eq!(result, "Running command:\n\nResults above.");
}
#[test]
fn test_write_file_with_json_content() {
reset_json_tool_state();
let input = r#"Creating config:
{"tool": "write_file", "args": {"file_path": "config.json", "content": "{\"key\": \"value\"}"}}
File created."#;
let result = filter_json_tool_calls(input);
assert_eq!(result, "Creating config:\n\nFile created.");
}
#[test]
fn test_read_file_simple() {
reset_json_tool_state();
let input = "Let me check:\n{\"tool\": \"read_file\", \"args\": {\"file_path\": \"README.md\"}}\nHere's what I found:";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Let me check:\n\nHere's what I found:");
}
#[test]
fn test_final_output() {
reset_json_tool_state();
let input = "Task complete.\n{\"tool\": \"final_output\", \"args\": {\"summary\": \"# Summary\\n\\nI completed the task.\\n\\n## Details\\n- Item 1\\n- Item 2\"}}\n";
let result = filter_json_tool_calls(input);
assert_eq!(result, "Task complete.\n\n");
}
// ============================================================================
// Truncated JSON followed by Complete JSON (the original bug)
// ============================================================================
#[test]
fn test_truncated_then_complete_streaming() {
reset_json_tool_state();
// Chunk 1: text
let r1 = filter_json_tool_calls("Some text\n");
assert_eq!(r1, "Some text\n");
// Chunk 2: truncated tool call
let r2 = filter_json_tool_calls(r#"{"tool": "str_replace", "args": {"diff":"partial"#);
assert_eq!(r2, "");
// Chunk 3: new complete tool call (LLM retry)
let r3 = filter_json_tool_calls(r#"{"tool": "str_replace", "args": {"diff":"complete", "file_path":"x.rs"}}"#);
assert_eq!(r3, "");
// Chunk 4: text after
let r4 = filter_json_tool_calls("\nMore text");
assert_eq!(r4, "\nMore text");
}
#[test]
fn test_multiple_truncated_then_complete() {
reset_json_tool_state();
let chunks = vec![
"Start\n",
r#"{"tool": "a", "args": {"x": "trunc"#, // truncated
r#"{"tool": "b", "args": {"y": "also_trunc"#, // another truncated
r#"{"tool": "c", "args": {"z": "complete"}}"#, // finally complete
"\nEnd",
];
let mut result = String::new();
for chunk in chunks {
result.push_str(&filter_json_tool_calls(chunk));
}
assert_eq!(result, "Start\n\nEnd");
}

View File

@@ -1,553 +0,0 @@
//! Tests for JSON tool call filtering.
//!
//! These tests verify that the filter correctly identifies and removes JSON tool calls
//! from LLM output streams while preserving all other content.
#[cfg(test)]
mod filter_json_tests {
use g3_cli::filter_json::{filter_json_tool_calls, reset_json_tool_state};
use regex::Regex;
/// Test that regular text without tool calls passes through unchanged.
#[test]
fn test_no_tool_call_passthrough() {
reset_json_tool_state();
let input = "This is regular text without any tool calls.";
let result = filter_json_tool_calls(input);
assert_eq!(result, input);
}
/// Test detection and removal of a complete tool call in a single chunk.
#[test]
fn test_simple_tool_call_detection() {
reset_json_tool_state();
let input = r#"Some text before
{"tool": "shell", "args": {"command": "ls"}}
Some text after"#;
let result = filter_json_tool_calls(input);
let expected = "Some text before\n\nSome text after";
assert_eq!(result, expected);
}
/// Test handling of tool calls that arrive across multiple streaming chunks.
#[test]
fn test_streaming_chunks() {
reset_json_tool_state();
// Simulate streaming where the tool call comes in multiple chunks
let chunks = vec![
"Some text before\n",
"{\"tool\": \"",
"shell\", \"args\": {",
"\"command\": \"ls\"",
"}}\nText after",
];
let mut results = Vec::new();
for chunk in chunks {
let result = filter_json_tool_calls(chunk);
results.push(result);
}
// The final accumulated result should have the JSON filtered out
let final_result: String = results.join("");
let expected = "Some text before\n\nText after";
assert_eq!(final_result, expected);
}
/// Test correct handling of nested braces within JSON strings.
#[test]
fn test_nested_braces_in_tool_call() {
reset_json_tool_state();
let input = r#"Text before
{"tool": "write_file", "args": {"file_path": "test.json", "content": "{\"nested\": \"value\"}"}}
Text after"#;
let result = filter_json_tool_calls(input);
let expected = "Text before\n\nText after";
assert_eq!(result, expected);
}
/// Verify the regex pattern matches the specification with flexible whitespace.
#[test]
fn test_regex_pattern_specification() {
// Test the corrected regex pattern that's more flexible with whitespace
let pattern = Regex::new(r#"(?m)^\s*\{\s*"tool"\s*:"#).unwrap();
let test_cases = vec![
(
r#"line
{"tool":"#,
true,
),
(
r#"line
{"tool" :"#,
true,
),
(
r#"line
{ "tool":"#,
true,
), // Space after { DOES match with \s*
(
r#"line
{"tool123":"#,
false,
), // "tool123" is not exactly "tool"
(
r#"line
{"tool" : "#,
true,
),
];
for (input, should_match) in test_cases {
let matches = pattern.is_match(input);
assert_eq!(
matches, should_match,
"Pattern matching failed for: {}",
input
);
}
}
/// Test that tool calls must appear at the start of a line (after newline).
#[test]
fn test_newline_requirement() {
reset_json_tool_state();
// According to spec, tool call should be detected "on the very next newline"
// Our current regex matches any line that contains the pattern, not just after newlines
let input_with_newline = "Text\n{\"tool\": \"shell\", \"args\": {\"command\": \"ls\"}}";
let input_without_newline = "Text {\"tool\": \"shell\", \"args\": {\"command\": \"ls\"}}";
let result1 = filter_json_tool_calls(input_with_newline);
reset_json_tool_state();
let result2 = filter_json_tool_calls(input_without_newline);
// With the new aggressive filtering, only the newline case should trigger suppression
// The pattern requires { to be at the start of a line (after ^)
assert_eq!(result1, "Text\n");
// Without newline before {, it should pass through unchanged
assert_eq!(result2, input_without_newline);
}
/// Test handling of escaped quotes within JSON strings.
#[test]
fn test_json_with_escaped_quotes() {
reset_json_tool_state();
let input = r#"Text
{"tool": "write_file", "args": {"content": "He said \"hello\" to me"}}
More text"#;
let result = filter_json_tool_calls(input);
let expected = "Text\n\nMore text";
assert_eq!(result, expected);
}
/// Test graceful handling of incomplete/malformed JSON.
#[test]
fn test_edge_case_malformed_json() {
reset_json_tool_state();
// Test what happens with malformed JSON that starts like a tool call
let input = r#"Text
{"tool": "shell", "args": {"command": "ls"
More text"#;
let result = filter_json_tool_calls(input);
// Should handle gracefully - since JSON is incomplete, it should return content before JSON
let expected = "Text\n";
assert_eq!(result, expected);
}
/// Test processing multiple independent tool calls sequentially.
#[test]
fn test_multiple_tool_calls_sequential() {
reset_json_tool_state();
// Test processing multiple tool calls one at a time
let input1 = r#"First text
{"tool": "shell", "args": {"command": "ls"}}
Middle text"#;
let result1 = filter_json_tool_calls(input1);
let expected1 = "First text\n\nMiddle text";
assert_eq!(result1, expected1);
// Reset and process second tool call
reset_json_tool_state();
let input2 = r#"More text
{"tool": "read_file", "args": {"file_path": "test.txt"}}
Final text"#;
let result2 = filter_json_tool_calls(input2);
let expected2 = "More text\n\nFinal text";
assert_eq!(result2, expected2);
}
/// Test tool calls with complex multi-line arguments.
#[test]
fn test_tool_call_with_complex_args() {
reset_json_tool_state();
let input = r#"Before
{"tool": "str_replace", "args": {"file_path": "test.rs", "diff": "--- old\n-old line\n+++ new\n+new line", "start": 0, "end": 100}}
After"#;
let result = filter_json_tool_calls(input);
let expected = "Before\n\nAfter";
assert_eq!(result, expected);
}
/// Test input containing only a tool call with no surrounding text.
#[test]
fn test_tool_call_only() {
reset_json_tool_state();
let input = r#"
{"tool": "final_output", "args": {"summary": "Task completed successfully"}}"#;
let result = filter_json_tool_calls(input);
// Leading newline before tool call at start of input is suppressed
let expected = "";
assert_eq!(result, expected);
}
/// Test accurate brace counting with deeply nested structures.
#[test]
fn test_brace_counting_accuracy() {
reset_json_tool_state();
// Test complex nested structure
let input = r#"Start
{"tool": "write_file", "args": {"content": "function() { return {a: 1, b: {c: 2}}; }", "file_path": "test.js"}}
End"#;
let result = filter_json_tool_calls(input);
let expected = "Start\n\nEnd";
assert_eq!(result, expected);
}
/// Test that braces within strings don't affect brace counting.
#[test]
fn test_string_escaping_in_json() {
reset_json_tool_state();
// Test JSON with escaped quotes and braces in strings
let input = r#"Text
{"tool": "shell", "args": {"command": "echo \"Hello {world}\" > file.txt"}}
More"#;
let result = filter_json_tool_calls(input);
let expected = "Text\n\nMore";
assert_eq!(result, expected);
}
/// Verify compliance with the exact specification requirements.
#[test]
fn test_specification_compliance() {
reset_json_tool_state();
// Test the exact specification requirements:
// 1. Detect start with regex '\w*{\w*"tool"\w*:\w*"' on newline
// 2. Enter suppression mode and use brace counting
// 3. Elide only JSON between first '{' and last '}' (inclusive)
// 4. Return everything else
let input = "Before text\nSome more text\n{\"tool\": \"test\", \"args\": {}}\nAfter text\nMore after";
let result = filter_json_tool_calls(input);
let expected = "Before text\nSome more text\n\nAfter text\nMore after";
assert_eq!(result, expected);
}
/// Test that non-tool JSON objects are not filtered.
#[test]
fn test_no_false_positives() {
reset_json_tool_state();
// Test that we don't incorrectly identify non-tool JSON as tool calls
let input = r#"Some text
{"not_tool": "value", "other": "data"}
More text"#;
let result = filter_json_tool_calls(input);
// Should pass through unchanged since it doesn't match the tool pattern
assert_eq!(result, input);
}
/// Test patterns that look similar to tool calls but aren't exact matches.
#[test]
fn test_partial_tool_patterns() {
reset_json_tool_state();
// Test patterns that look like tool calls but aren't complete
let test_cases = vec![
"Text\n{\"too\": \"value\"}", // "too" not "tool"
"Text\n{\"tools\": \"value\"}", // "tools" not "tool"
"Text\n{\"tool\": }", // Missing value after colon
];
for input in test_cases {
reset_json_tool_state();
let result = filter_json_tool_calls(input);
// These should all pass through unchanged
assert_eq!(result, input, "Input should pass through: {}", input);
}
}
/// Test streaming with very small chunks (character-by-character).
#[test]
fn test_streaming_edge_cases() {
reset_json_tool_state();
// Test streaming with very small chunks
let chunks = vec![
"Text\n", "{", "\"", "tool", "\"", ":", " ", "\"", "test", "\"", "}", "\nAfter",
];
let mut results = Vec::new();
for chunk in chunks {
let result = filter_json_tool_calls(chunk);
results.push(result);
}
let final_result: String = results.join("");
// With the new aggressive filtering, the JSON should be completely filtered out
// even when it arrives in very small chunks
let expected = "Text\n\nAfter";
assert_eq!(final_result, expected);
}
/// Debug test with detailed logging for streaming behavior.
#[test]
fn test_streaming_debug() {
reset_json_tool_state();
// Debug the exact failing case
let chunks = vec![
"Some text before\n",
"{\"tool\": \"",
"shell\", \"args\": {",
"\"command\": \"ls\"",
"}}\nText after",
];
let mut results = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
let result = filter_json_tool_calls(chunk);
println!("Chunk {}: {:?} -> {:?}", i, chunk, result);
results.push(result);
}
let final_result: String = results.join("");
println!("Final result: {:?}", final_result);
println!("Expected: {:?}", "Some text before\n\nText after");
let expected = "Some text before\n\nText after";
assert_eq!(final_result, expected);
}
/// Test handling of truncated JSON followed by complete JSON (the json_err pattern)
#[test]
fn test_truncated_then_complete_json() {
reset_json_tool_state();
// Simulate the pattern from json_err trace:
// 1. Incomplete/truncated JSON appears
// 2. Then the same complete JSON appears
let chunks = vec![
"Some text\n",
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli"#, // Truncated
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli/src/lib.rs"}}"#, // Complete
"\nMore text",
];
let mut results = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
let result = filter_json_tool_calls(chunk);
println!("Chunk {}: {:?} -> {:?}", i, chunk, result);
results.push(result);
}
let final_result: String = results.join("");
println!("Final result: {:?}", final_result);
// The truncated JSON should be discarded when the complete one appears
// Both JSONs should be filtered out, leaving only the text
let expected = "Some text\n\nMore text";
assert_eq!(
final_result, expected,
"Failed to handle truncated JSON followed by complete JSON"
);
}
// ============================================================================
// Edge Case Tests - These test the bugs that were fixed in the rewrite
// ============================================================================
/// CRITICAL: Test that closing braces inside JSON strings don't break filtering.
/// This was the main bug in the original implementation.
#[test]
fn test_brace_inside_json_string_value() {
reset_json_tool_state();
// The } inside "echo }" should NOT cause premature exit from suppression
let input = r#"Text before
{"tool": "shell", "args": {"command": "echo }"}}
Text after"#;
let result = filter_json_tool_calls(input);
let expected = "Text before\n\nText after";
assert_eq!(
result, expected,
"Brace inside string value caused premature suppression exit"
);
}
/// Test multiple braces inside string values.
#[test]
fn test_multiple_braces_in_string() {
reset_json_tool_state();
let input = r#"Before
{"tool": "shell", "args": {"command": "echo {{{}}}"}}
After"#;
let result = filter_json_tool_calls(input);
let expected = "Before\n\nAfter";
assert_eq!(result, expected);
}
/// Test escaped quotes followed by braces in strings.
#[test]
fn test_escaped_quotes_with_braces() {
reset_json_tool_state();
let input = r#"Before
{"tool": "shell", "args": {"command": "echo \"test}\" done"}}
After"#;
let result = filter_json_tool_calls(input);
let expected = "Before\n\nAfter";
assert_eq!(result, expected);
}
/// Test braces in strings across streaming chunks.
#[test]
fn test_brace_in_string_across_chunks() {
reset_json_tool_state();
// The } appears in a separate chunk while we're inside a string
let chunks = vec![
"Before\n",
r#"{"tool": "shell", "args": {"command": "echo "#,
r#"}"}}"#,
"\nAfter",
];
let mut results = Vec::new();
for chunk in chunks {
results.push(filter_json_tool_calls(chunk));
}
let final_result: String = results.join("");
let expected = "Before\n\nAfter";
assert_eq!(
final_result, expected,
"Brace in string across chunks caused incorrect filtering"
);
}
/// Test complex nested JSON with braces in multiple string values.
#[test]
fn test_complex_nested_with_string_braces() {
reset_json_tool_state();
let input = r#"Start
{"tool": "write_file", "args": {"path": "test.json", "content": "{\"key\": \"value with } brace\"}"}}
End"#;
let result = filter_json_tool_calls(input);
let expected = "Start\n\nEnd";
assert_eq!(result, expected);
}
/// Test the real-world case from jsonfilter_err - str_replace with diff containing braces
#[test]
fn test_str_replace_with_diff_content() {
reset_json_tool_state();
// This is a real case where str_replace tool call wasn't being filtered
// The diff content contains braces in the code being replaced
let input = r#"{"tool": "str_replace", "args": {"diff":"--- a/crates/g3-cli/src/ui_writer_impl.rs\n+++ b/crates/g3-cli/src/ui_writer_impl.rs\n@@ -355,11 +355,11 @@\n fn filter_json_tool_calls(&self, content: &str) -> String {\n // Apply JSON tool call filtering for display\n- fixed_filter_json_tool_calls(content)\n+ filter_json_tool_calls(content)\n }\n \n fn reset_json_filter(&self) {\n // Reset the filter state for a new response\n- reset_fixed_json_tool_state();\n+ reset_json_tool_state();\n }\n }","file_path":"crates/g3-cli/src/ui_writer_impl.rs"}}"#;
let result = filter_json_tool_calls(input);
// The entire tool call should be filtered out
assert!(
result.is_empty() || result.trim().is_empty(),
"str_replace tool call was not filtered out. Got: {:?}",
result
);
}
/// Test tool call that appears after other content (from jsonfilter_err)
/// The filter requires tool calls to start at the beginning of a line
#[test]
fn test_tool_call_after_other_content() {
reset_json_tool_state();
// This simulates the jsonfilter_err case where a read_file result
// is followed by a str_replace tool call
let input = r#"┌─ read_file | ./crates/g3-cli/src/ui_writer_impl.rs [13000..13300]
│ }
│ (11 lines)
└─ ⚡️ 1ms
{"tool": "str_replace", "args": {"diff":"--- a/file.rs\n+++ b/file.rs\n-old\n+new","file_path":"file.rs"}}"#;
let result = filter_json_tool_calls(input);
// The tool call starts on its own line after the read_file output.
// The tool call is filtered out, and extra newlines before it are suppressed.
// Only one newline remains (the line ending after "1ms").
let expected = r#"┌─ read_file | ./crates/g3-cli/src/ui_writer_impl.rs [13000..13300]
│ }
│ (11 lines)
└─ ⚡️ 1ms
"#;
assert_eq!(
result, expected,
"Tool call after other content was not filtered correctly"
);
}
/// Test case from jsonfilter_err2 - tool call at line start should be filtered,
/// but tool call patterns inside string values should be preserved
#[test]
fn test_tool_call_with_nested_tool_pattern_in_string() {
reset_json_tool_state();
// From jsonfilter_err2: A shell tool call that contains another tool call
// pattern inside its command string (a heredoc with code that references tool calls)
// The outer shell tool call starts at line beginning -> should be filtered
// The inner str_replace pattern is inside a string -> should NOT trigger filtering
let input = "Let me create a test case:\n\n{\"tool\": \"shell\", \"args\": {\"command\":\"cat file.rs\\nlet x = r#\\\"{\\\"tool\\\": \\\"test\\\"}\\\"#;\"}}\n\nDone.";
let result = filter_json_tool_calls(input);
// The shell tool call starts at line beginning, so it should be filtered out
// Only the surrounding text should remain.
// Extra newlines before the tool call are suppressed (one blank line before
// becomes just the line ending), but newlines after are preserved.
let expected = "Let me create a test case:\n\n\nDone.";
assert_eq!(
result, expected,
"Tool call with nested pattern was not filtered correctly. Got: {:?}",
result
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@ tracing = { workspace = true }
uuid = { workspace = true }
shellexpand = "3.1"
dirs = "5.0"
# Async trait support
async-trait = "0.1"

View File

@@ -1,4 +1,63 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
// No build-time dependencies required
// VisionBridge OCR has been removed
// Only build Vision bridge on macOS
if env::var("CARGO_CFG_TARGET_OS").unwrap() != "macos" {
return;
}
println!("cargo:rerun-if-changed=vision-bridge/Sources/VisionBridge/VisionOCR.swift");
println!("cargo:rerun-if-changed=vision-bridge/Sources/VisionBridge/VisionBridge.h");
println!("cargo:rerun-if-changed=vision-bridge/Package.swift");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let vision_bridge_dir = manifest_dir.join("vision-bridge");
// Build Swift package
println!("cargo:warning=Building VisionBridge Swift package...");
let build_status = Command::new("swift")
.args(&["build", "-c", "release"])
.current_dir(&vision_bridge_dir)
.status()
.expect("Failed to build Swift package");
if !build_status.success() {
panic!("Swift build failed");
}
// Find the built library
let lib_path = vision_bridge_dir
.join(".build/release")
.canonicalize()
.expect("Failed to find .build/release directory");
// Copy the dylib to the output directory so it can be found at runtime
let target_dir = manifest_dir.parent().unwrap().parent().unwrap().join("target");
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
let output_dir = target_dir.join(&profile);
let dylib_src = lib_path.join("libVisionBridge.dylib");
let dylib_dst = output_dir.join("libVisionBridge.dylib");
std::fs::copy(&dylib_src, &dylib_dst)
.expect(&format!("Failed to copy dylib from {} to {}", dylib_src.display(), dylib_dst.display()));
println!("cargo:warning=Copied libVisionBridge.dylib to {}", dylib_dst.display());
// Add rpath so the dylib can be found at runtime
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path");
println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path");
println!("cargo:rustc-link-search=native={}", lib_path.display());
println!("cargo:rustc-link-lib=dylib=VisionBridge");
// Link required frameworks
println!("cargo:rustc-link-lib=framework=Vision");
println!("cargo:rustc-link-lib=framework=AppKit");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=CoreGraphics");
println!("cargo:rustc-link-lib=framework=CoreImage");
println!("cargo:warning=VisionBridge built successfully at {}", lib_path.display());
}

View File

@@ -3,19 +3,19 @@ use core_graphics::display::CGDisplay;
fn main() {
let display = CGDisplay::main();
let image = display.image().expect("Failed to capture screen");
println!("CGImage properties:");
println!(" Width: {}", image.width());
println!(" Height: {}", image.height());
println!(" Bits per component: {}", image.bits_per_component());
println!(" Bits per pixel: {}", image.bits_per_pixel());
println!(" Bytes per row: {}", image.bytes_per_row());
let data = image.data();
let expected_size = image.width() * image.height() * 4;
println!(" Data length: {}", data.len());
println!(" Expected (w*h*4): {}", expected_size);
// Check if there's padding in rows
let bytes_per_row = image.bytes_per_row();
let width = image.width();
@@ -23,25 +23,16 @@ fn main() {
println!("\nRow alignment:");
println!(" Actual bytes per row: {}", bytes_per_row);
println!(" Expected (width * 4): {}", expected_bytes_per_row);
println!(
" Padding per row: {}",
bytes_per_row - expected_bytes_per_row
);
println!(" Padding per row: {}", bytes_per_row - expected_bytes_per_row);
// Sample some pixels from different locations
println!("\nFirst 3 pixels (raw bytes):");
for i in 0..3 {
let offset = i * 4;
println!(
" Pixel {}: [{:3}, {:3}, {:3}, {:3}]",
i,
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3]
);
println!(" Pixel {}: [{:3}, {:3}, {:3}, {:3}]",
i, data[offset], data[offset+1], data[offset+2], data[offset+3]);
}
// Check a pixel from the middle
let mid_row = image.height() / 2;
let mid_col = image.width() / 2;
@@ -49,12 +40,7 @@ fn main() {
println!("\nMiddle pixel (row {}, col {}):", mid_row, mid_col);
println!(" Offset: {}", mid_offset);
if mid_offset + 3 < data.len() as usize {
println!(
" Bytes: [{:3}, {:3}, {:3}, {:3}]",
data[mid_offset],
data[mid_offset + 1],
data[mid_offset + 2],
data[mid_offset + 3]
);
println!(" Bytes: [{:3}, {:3}, {:3}, {:3}]",
data[mid_offset], data[mid_offset+1], data[mid_offset+2], data[mid_offset+3]);
}
}

View File

@@ -1,38 +1,34 @@
use core_foundation::base::{TCFType, ToVoid};
use core_graphics::window::{kCGWindowListOptionOnScreenOnly, kCGNullWindowID, CGWindowListCopyWindowInfo};
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
use core_graphics::window::{
kCGNullWindowID, kCGWindowListOptionOnScreenOnly, CGWindowListCopyWindowInfo,
};
use core_foundation::base::{TCFType, ToVoid};
fn main() {
println!("Listing all on-screen windows...");
println!("{:<10} {:<25} {}", "Window ID", "Owner", "Title");
println!("{}", "-".repeat(80));
unsafe {
let window_list =
CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
let count =
core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list)
.len();
let array =
core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
let window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID
);
let count = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list).len();
let array = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
for i in 0..count {
let dict = array.get(i).unwrap();
// Get window ID
let window_id_key = CFString::from_static_string("kCGWindowNumber");
let window_id: i64 = if let Some(value) = dict.find(window_id_key.to_void()) {
let num: core_foundation::number::CFNumber =
TCFType::wrap_under_get_rule(*value as *const _);
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
num.to_i64().unwrap_or(0)
} else {
0
};
// Get owner name
let owner_key = CFString::from_static_string("kCGWindowOwnerName");
let owner: String = if let Some(value) = dict.find(owner_key.to_void()) {
@@ -41,7 +37,7 @@ fn main() {
} else {
"Unknown".to_string()
};
// Get window name/title
let name_key = CFString::from_static_string("kCGWindowName");
let title: String = if let Some(value) = dict.find(name_key.to_void()) {
@@ -50,7 +46,7 @@ fn main() {
} else {
"".to_string()
};
// Show all windows
if !owner.is_empty() {
println!("{:<10} {:<25} {}", window_id, owner, title);

View File

@@ -0,0 +1,74 @@
//! Example demonstrating macOS Accessibility API tools
//!
//! This example shows how to use the macax tools to control macOS applications.
//!
//! Run with: cargo run --example macax_demo
use anyhow::Result;
use g3_computer_control::MacAxController;
#[tokio::main]
async fn main() -> Result<()> {
println!("🍎 macOS Accessibility API Demo\n");
println!("This demo shows how to control macOS applications using the Accessibility API.\n");
// Create controller
let controller = MacAxController::new()?;
println!("✅ MacAxController initialized\n");
// List running applications
println!("📱 Listing running applications:");
match controller.list_applications() {
Ok(apps) => {
for app in apps.iter().take(10) {
println!(" - {}", app.name);
}
if apps.len() > 10 {
println!(" ... and {} more", apps.len() - 10);
}
}
Err(e) => println!(" ❌ Error: {}", e),
}
println!();
// Get frontmost app
println!("🎯 Getting frontmost application:");
match controller.get_frontmost_app() {
Ok(app) => println!(" Current: {}", app.name),
Err(e) => println!(" ❌ Error: {}", e),
}
println!();
// Example: Activate Finder and get its UI tree
println!("📂 Activating Finder and inspecting UI:");
match controller.activate_app("Finder") {
Ok(_) => {
println!(" ✅ Finder activated");
// Wait a moment for activation
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Get UI tree
match controller.get_ui_tree("Finder", 2) {
Ok(tree) => {
println!("\n UI Tree:");
for line in tree.lines().take(10) {
println!(" {}", line);
}
}
Err(e) => println!(" ❌ Error getting UI tree: {}", e),
}
}
Err(e) => println!(" ❌ Error: {}", e),
}
println!();
println!("✨ Demo complete!\n");
println!("💡 Tips:");
println!(" - Use --macax flag with g3 to enable these tools");
println!(" - Grant accessibility permissions in System Preferences");
println!(" - Add accessibility identifiers to your apps for easier automation");
println!(" - See docs/macax-tools.md for full documentation\n");
Ok(())
}

View File

@@ -1,66 +1,64 @@
use anyhow::Result;
use g3_computer_control::webdriver::WebDriverController;
use g3_computer_control::SafariDriver;
use g3_computer_control::webdriver::WebDriverController;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
println!("Safari WebDriver Demo");
println!("=====================\n");
println!("Make sure to:");
println!("1. Enable 'Allow Remote Automation' in Safari's Develop menu");
println!("2. Run: /usr/bin/safaridriver --enable");
println!("3. Start safaridriver in another terminal: safaridriver --port 4444\n");
println!("Connecting to SafariDriver...");
let mut driver = SafariDriver::new().await?;
println!("✅ Connected!\n");
// Navigate to a website
println!("Navigating to example.com...");
driver.navigate("https://example.com").await?;
println!("✅ Navigated\n");
// Get page title
let title = driver.title().await?;
println!("Page title: {}\n", title);
// Get current URL
let url = driver.current_url().await?;
println!("Current URL: {}\n", url);
// Find an element
println!("Finding h1 element...");
let h1 = driver.find_element("h1").await?;
let h1_text = h1.text().await?;
println!("H1 text: {}\n", h1_text);
// Find all paragraphs
println!("Finding all paragraphs...");
let paragraphs = driver.find_elements("p").await?;
println!("Found {} paragraphs\n", paragraphs.len());
// Get page source
println!("Getting page source...");
let source = driver.page_source().await?;
println!("Page source length: {} bytes\n", source.len());
// Execute JavaScript
println!("Executing JavaScript...");
let result = driver
.execute_script("return document.title", vec![])
.await?;
let result = driver.execute_script("return document.title", vec![]).await?;
println!("JS result: {:?}\n", result);
// Take a screenshot
println!("Taking screenshot...");
driver.screenshot("/tmp/safari_demo.png").await?;
println!("✅ Screenshot saved to /tmp/safari_demo.png\n");
// Close the browser
println!("Closing browser...");
driver.quit().await?;
println!("✅ Done!");
Ok(())
}

View File

@@ -3,13 +3,10 @@ use g3_computer_control::create_controller;
#[tokio::main]
async fn main() {
println!("Testing screenshot with permission prompt...");
let controller = create_controller().expect("Failed to create controller");
match controller
.take_screenshot("/tmp/test_with_prompt.png", None, None)
.await
{
match controller.take_screenshot("/tmp/test_with_prompt.png", None, None).await {
Ok(_) => {
println!("\n✅ Screenshot saved to /tmp/test_with_prompt.png");
println!("Opening screenshot...");

View File

@@ -2,33 +2,29 @@ use std::process::Command;
fn main() {
let path = "/tmp/rust_screencapture_test.png";
println!("Testing screencapture command from Rust...");
let mut cmd = Command::new("screencapture");
cmd.arg("-x"); // No sound
cmd.arg(path);
println!("Command: {:?}", cmd);
match cmd.output() {
Ok(output) => {
println!("Exit status: {}", output.status);
println!("Stdout: {}", String::from_utf8_lossy(&output.stdout));
println!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
if output.status.success() {
println!("\n✅ Screenshot saved to: {}", path);
// Check file exists and size
if let Ok(metadata) = std::fs::metadata(path) {
println!(
"File size: {} bytes ({:.1} MB)",
metadata.len(),
metadata.len() as f64 / 1_000_000.0
);
println!("File size: {} bytes ({:.1} MB)", metadata.len(), metadata.len() as f64 / 1_000_000.0);
}
// Open it
let _ = Command::new("open").arg(path).spawn();
println!("\nOpened screenshot - please verify it looks correct!");

View File

@@ -4,23 +4,17 @@ use image::{ImageBuffer, RgbaImage};
fn main() {
let display = CGDisplay::main();
let image = display.image().expect("Failed to capture screen");
let width = image.width() as u32;
let height = image.height() as u32;
let bytes_per_row = image.bytes_per_row() as usize;
let data = image.data();
println!("Testing screenshot fix...");
println!(
"Image: {}x{}, bytes_per_row: {}",
width, height, bytes_per_row
);
println!("Image: {}x{}, bytes_per_row: {}", width, height, bytes_per_row);
println!("Expected bytes per row: {}", width * 4);
println!(
"Padding per row: {} bytes",
bytes_per_row - (width as usize * 4)
);
println!("Padding per row: {} bytes", bytes_per_row - (width as usize * 4));
// OLD METHOD (broken) - treating data as continuous
println!("\n=== OLD METHOD (BROKEN) ===");
let mut old_rgba = Vec::with_capacity(data.len() as usize);
@@ -32,14 +26,14 @@ fn main() {
}
println!("Converted {} pixels", old_rgba.len() / 4);
println!("Expected {} pixels", width * height);
// NEW METHOD (fixed) - handling row padding
println!("\n=== NEW METHOD (FIXED) ===");
let mut new_rgba = Vec::with_capacity((width * height * 4) as usize);
for row in 0..height as usize {
let row_start = row * bytes_per_row;
let row_end = row_start + (width as usize * 4);
for chunk in data[row_start..row_end].chunks_exact(4) {
new_rgba.push(chunk[2]); // R
new_rgba.push(chunk[1]); // G
@@ -49,34 +43,26 @@ fn main() {
}
println!("Converted {} pixels", new_rgba.len() / 4);
println!("Expected {} pixels", width * height);
// Save a small crop from both methods
let crop_size = 200;
// Old method crop
let old_crop: Vec<u8> = old_rgba
.iter()
.take((crop_size * crop_size * 4) as usize)
.copied()
.collect();
let old_crop: Vec<u8> = old_rgba.iter().take((crop_size * crop_size * 4) as usize).copied().collect();
if let Some(old_img) = ImageBuffer::from_raw(crop_size, crop_size, old_crop) {
let old_img: RgbaImage = old_img;
old_img.save("/tmp/screenshot_old_method.png").unwrap();
println!("\nSaved OLD method crop to: /tmp/screenshot_old_method.png");
}
// New method crop
let new_crop: Vec<u8> = new_rgba
.iter()
.take((crop_size * crop_size * 4) as usize)
.copied()
.collect();
let new_crop: Vec<u8> = new_rgba.iter().take((crop_size * crop_size * 4) as usize).copied().collect();
if let Some(new_img) = ImageBuffer::from_raw(crop_size, crop_size, new_crop) {
let new_img: RgbaImage = new_img;
new_img.save("/tmp/screenshot_new_method.png").unwrap();
println!("Saved NEW method crop to: /tmp/screenshot_new_method.png");
}
println!("\nOpen both images to compare:");
println!(" open /tmp/screenshot_old_method.png /tmp/screenshot_new_method.png");
}

View File

@@ -0,0 +1,48 @@
//! Test the new type_text functionality
use anyhow::Result;
use g3_computer_control::MacAxController;
#[tokio::main]
async fn main() -> Result<()> {
println!("🧪 Testing macax type_text functionality\n");
let controller = MacAxController::new()?;
println!("✅ Controller initialized\n");
// Test 1: Type simple text
println!("Test 1: Typing simple text into TextEdit");
println!(" Please open TextEdit and create a new document...");
std::thread::sleep(std::time::Duration::from_secs(3));
match controller.type_text("TextEdit", "Hello, World!") {
Ok(_) => println!(" ✅ Successfully typed simple text\n"),
Err(e) => println!(" ❌ Failed: {}\n", e),
}
std::thread::sleep(std::time::Duration::from_secs(1));
// Test 2: Type unicode and emojis
println!("Test 2: Typing unicode and emojis");
match controller.type_text("TextEdit", "\n🌟 Unicode test: café, naïve, 日本語 🎉") {
Ok(_) => println!(" ✅ Successfully typed unicode text\n"),
Err(e) => println!(" ❌ Failed: {}\n", e),
}
std::thread::sleep(std::time::Duration::from_secs(1));
// Test 3: Type special characters
println!("Test 3: Typing special characters");
match controller.type_text("TextEdit", "\nSpecial: @#$%^&*()_+-=[]{}|;':,.<>?/") {
Ok(_) => println!(" ✅ Successfully typed special characters\n"),
Err(e) => println!(" ❌ Failed: {}\n", e),
}
println!("\n✨ Tests complete!");
println!("\n💡 Now try with Things3:");
println!(" 1. Open Things3");
println!(" 2. Press Cmd+N to create a new task");
println!(" 3. Run: g3 --macax 'type \"🌟 My awesome task\" into Things'");
Ok(())
}

View File

@@ -0,0 +1,85 @@
use g3_computer_control::ocr::{OCREngine, DefaultOCR};
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
println!("🧪 Testing Apple Vision OCR");
println!("===========================\n");
// Initialize OCR engine
println!("📦 Initializing OCR engine...");
let ocr = DefaultOCR::new()?;
println!("✅ OCR engine: {}\n", ocr.name());
// Check if test image exists
let test_image = "/tmp/safari_test.png";
if !std::path::Path::new(test_image).exists() {
println!("⚠️ Test image not found: {}", test_image);
println!(" Creating a screenshot...");
let status = std::process::Command::new("screencapture")
.arg("-x")
.arg("-R")
.arg("0,0,1200,800")
.arg(test_image)
.status()?;
if !status.success() {
anyhow::bail!("Failed to create screenshot");
}
println!("✅ Screenshot created\n");
}
// Run OCR
println!("🔍 Running Apple Vision OCR on {}...", test_image);
let start = std::time::Instant::now();
let locations = ocr.extract_text_with_locations(test_image).await?;
let duration = start.elapsed();
println!("✅ OCR completed in {:.3}s\n", duration.as_secs_f64());
// Display results
println!("📊 Results:");
println!(" Found {} text elements\n", locations.len());
if locations.is_empty() {
println!("⚠️ No text found in image");
} else {
println!(" Top 20 results:");
println!(" {:<4} {:<40} {:<15} {:<12} {:<8}", "#", "Text", "Position", "Size", "Conf");
println!(" {}", "-".repeat(85));
for (i, loc) in locations.iter().take(20).enumerate() {
let text = if loc.text.len() > 37 {
format!("{}...", &loc.text[..37])
} else {
loc.text.clone()
};
println!(" {:<4} {:<40} ({:>4},{:>4}) {:>4}x{:<4} {:.2}",
i + 1,
text,
loc.x,
loc.y,
loc.width,
loc.height,
loc.confidence
);
}
if locations.len() > 20 {
println!("\n ... and {} more", locations.len() - 20);
}
// Performance comparison
println!("\n📈 Performance:");
println!(" OCR Speed: {:.3}s", duration.as_secs_f64());
println!(" Text elements: {}", locations.len());
println!(" Avg per element: {:.1}ms", duration.as_millis() as f64 / locations.len() as f64);
}
println!("\n✅ Test complete!");
Ok(())
}

View File

@@ -3,46 +3,36 @@ use g3_computer_control::create_controller;
#[tokio::main]
async fn main() {
println!("Testing window-specific screenshot capture...");
let controller = create_controller().expect("Failed to create controller");
// Test 1: Capture iTerm2 window
println!("\n1. Capturing iTerm2 window...");
match controller
.take_screenshot("/tmp/iterm_window.png", None, Some("iTerm2"))
.await
{
match controller.take_screenshot("/tmp/iterm_window.png", None, Some("iTerm2")).await {
Ok(_) => {
println!(" ✅ iTerm2 window captured to /tmp/iterm_window.png");
let _ = std::process::Command::new("open")
.arg("/tmp/iterm_window.png")
.spawn();
let _ = std::process::Command::new("open").arg("/tmp/iterm_window.png").spawn();
}
Err(e) => println!(" ❌ Failed: {}", e),
}
// Wait a moment for the image to open
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Test 2: Full screen capture for comparison
println!("\n2. Capturing full screen for comparison...");
match controller
.take_screenshot("/tmp/fullscreen.png", None, None)
.await
{
match controller.take_screenshot("/tmp/fullscreen.png", None, None).await {
Ok(_) => {
println!(" ✅ Full screen captured to /tmp/fullscreen.png");
let _ = std::process::Command::new("open")
.arg("/tmp/fullscreen.png")
.spawn();
let _ = std::process::Command::new("open").arg("/tmp/fullscreen.png").spawn();
}
Err(e) => println!(" ❌ Failed: {}", e),
}
println!("\n=== Comparison ===");
println!("iTerm window: /tmp/iterm_window.png (should show ONLY iTerm window)");
println!("Full screen: /tmp/fullscreen.png (should show entire desktop)");
// Show file sizes
if let Ok(meta1) = std::fs::metadata("/tmp/iterm_window.png") {
if let Ok(meta2) = std::fs::metadata("/tmp/fullscreen.png") {

View File

@@ -1,15 +1,17 @@
// Suppress warnings from objc crate macros
#![allow(unexpected_cfgs)]
pub mod platform;
pub mod types;
pub mod platform;
pub mod ocr;
pub mod webdriver;
pub mod macax;
// Re-export webdriver types for convenience
pub use webdriver::{
chrome::ChromeDriver, safari::SafariDriver, WebDriverController, WebElement,
diagnostics::{run_diagnostics as run_chrome_diagnostics, ChromeDiagnosticReport, DiagnosticStatus},
};
pub use webdriver::{WebDriverController, WebElement, safari::SafariDriver};
// Re-export macax types for convenience
pub use macax::{MacAxController, AXElement, AXApplication};
use anyhow::Result;
use async_trait::async_trait;
@@ -18,25 +20,30 @@ use types::*;
#[async_trait]
pub trait ComputerController: Send + Sync {
// Screen capture
async fn take_screenshot(
&self,
path: &str,
region: Option<Rect>,
window_id: Option<&str>,
) -> Result<()>;
async fn take_screenshot(&self, path: &str, region: Option<Rect>, window_id: Option<&str>) -> Result<()>;
// OCR operations
async fn extract_text_from_screen(&self, region: Rect, window_id: &str) -> Result<String>;
async fn extract_text_from_image(&self, path: &str) -> Result<String>;
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>>;
async fn find_text_in_app(&self, app_name: &str, search_text: &str) -> Result<Option<TextLocation>>;
// Mouse operations
fn move_mouse(&self, x: i32, y: i32) -> Result<()>;
fn click_at(&self, x: i32, y: i32, app_name: Option<&str>) -> Result<()>;
}
// Platform-specific constructor
pub fn create_controller() -> Result<Box<dyn ComputerController>> {
#[cfg(target_os = "macos")]
return Ok(Box::new(platform::macos::MacOSController::new()?));
#[cfg(target_os = "linux")]
return Ok(Box::new(platform::linux::LinuxController::new()?));
#[cfg(target_os = "windows")]
return Ok(Box::new(platform::windows::WindowsController::new()?));
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
anyhow::bail!("Unsupported platform")
}

View File

@@ -0,0 +1,822 @@
use super::{AXApplication, AXElement};
use anyhow::{Context, Result};
use std::collections::HashMap;
#[cfg(target_os = "macos")]
use accessibility::{AXUIElement, AXUIElementAttributes, ElementFinder, TreeVisitor, TreeWalker, TreeWalkerFlow};
#[cfg(target_os = "macos")]
use core_foundation::base::TCFType;
#[cfg(target_os = "macos")]
use core_foundation::string::CFString;
/// macOS Accessibility API controller using native APIs
pub struct MacAxController {
// Cache for application elements
app_cache: std::sync::Mutex<HashMap<String, AXUIElement>>,
}
impl MacAxController {
pub fn new() -> Result<Self> {
#[cfg(target_os = "macos")]
{
// Check if we have accessibility permissions by trying to get system-wide element
let _system = AXUIElement::system_wide();
Ok(Self {
app_cache: std::sync::Mutex::new(HashMap::new()),
})
}
#[cfg(not(target_os = "macos"))]
{
anyhow::bail!("macOS Accessibility API is only available on macOS")
}
}
/// List all running applications
#[cfg(target_os = "macos")]
pub fn list_applications(&self) -> Result<Vec<AXApplication>> {
let apps = Self::get_running_applications()?;
Ok(apps)
}
#[cfg(not(target_os = "macos"))]
pub fn list_applications(&self) -> Result<Vec<AXApplication>> {
anyhow::bail!("Not supported on this platform")
}
#[cfg(target_os = "macos")]
fn get_running_applications() -> Result<Vec<AXApplication>> {
use cocoa::appkit::NSApplicationActivationPolicy;
use cocoa::base::{id, nil};
use objc::{class, msg_send, sel, sel_impl};
unsafe {
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
let running_apps: id = msg_send![workspace, runningApplications];
let count: usize = msg_send![running_apps, count];
let mut apps = Vec::new();
for i in 0..count {
let app: id = msg_send![running_apps, objectAtIndex: i];
// Get app name
let localized_name: id = msg_send![app, localizedName];
if localized_name == nil {
continue;
}
let name_ptr: *const i8 = msg_send![localized_name, UTF8String];
let name = if !name_ptr.is_null() {
std::ffi::CStr::from_ptr(name_ptr)
.to_string_lossy()
.to_string()
} else {
continue;
};
// Get bundle ID
let bundle_id_obj: id = msg_send![app, bundleIdentifier];
let bundle_id = if bundle_id_obj != nil {
let bundle_id_ptr: *const i8 = msg_send![bundle_id_obj, UTF8String];
if !bundle_id_ptr.is_null() {
Some(
std::ffi::CStr::from_ptr(bundle_id_ptr)
.to_string_lossy()
.to_string(),
)
} else {
None
}
} else {
None
};
// Get PID
let pid: i32 = msg_send![app, processIdentifier];
// Skip background-only apps
let activation_policy: i64 = msg_send![app, activationPolicy];
if activation_policy == NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular as i64 {
apps.push(AXApplication {
name,
bundle_id,
pid,
});
}
}
Ok(apps)
}
}
/// Get the frontmost (active) application
#[cfg(target_os = "macos")]
pub fn get_frontmost_app(&self) -> Result<AXApplication> {
use cocoa::base::{id, nil};
use objc::{class, msg_send, sel, sel_impl};
unsafe {
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
let frontmost_app: id = msg_send![workspace, frontmostApplication];
if frontmost_app == nil {
anyhow::bail!("No frontmost application");
}
// Get app name
let localized_name: id = msg_send![frontmost_app, localizedName];
let name_ptr: *const i8 = msg_send![localized_name, UTF8String];
let name = std::ffi::CStr::from_ptr(name_ptr)
.to_string_lossy()
.to_string();
// Get bundle ID
let bundle_id_obj: id = msg_send![frontmost_app, bundleIdentifier];
let bundle_id = if bundle_id_obj != nil {
let bundle_id_ptr: *const i8 = msg_send![bundle_id_obj, UTF8String];
if !bundle_id_ptr.is_null() {
Some(
std::ffi::CStr::from_ptr(bundle_id_ptr)
.to_string_lossy()
.to_string(),
)
} else {
None
}
} else {
None
};
// Get PID
let pid: i32 = msg_send![frontmost_app, processIdentifier];
Ok(AXApplication {
name,
bundle_id,
pid,
})
}
}
#[cfg(not(target_os = "macos"))]
pub fn get_frontmost_app(&self) -> Result<AXApplication> {
anyhow::bail!("Not supported on this platform")
}
/// Get AXUIElement for an application by name or PID
#[cfg(target_os = "macos")]
fn get_app_element(&self, app_name: &str) -> Result<AXUIElement> {
// Check cache first
{
let cache = self.app_cache.lock().unwrap();
if let Some(element) = cache.get(app_name) {
return Ok(element.clone());
}
}
// Find the app by name
let apps = Self::get_running_applications()?;
let app = apps
.iter()
.find(|a| a.name == app_name)
.ok_or_else(|| anyhow::anyhow!("Application '{}' not found", app_name))?;
// Create AXUIElement for the app
let element = AXUIElement::application(app.pid);
// Cache it
{
let mut cache = self.app_cache.lock().unwrap();
cache.insert(app_name.to_string(), element.clone());
}
Ok(element)
}
/// Activate (bring to front) an application
#[cfg(target_os = "macos")]
pub fn activate_app(&self, app_name: &str) -> Result<()> {
use cocoa::base::id;
use objc::{class, msg_send, sel, sel_impl};
// Find the app
let apps = Self::get_running_applications()?;
let app = apps
.iter()
.find(|a| a.name == app_name)
.ok_or_else(|| anyhow::anyhow!("Application '{}' not found", app_name))?;
unsafe {
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
let running_apps: id = msg_send![workspace, runningApplications];
let count: usize = msg_send![running_apps, count];
for i in 0..count {
let running_app: id = msg_send![running_apps, objectAtIndex: i];
let pid: i32 = msg_send![running_app, processIdentifier];
if pid == app.pid {
let _: bool = msg_send![running_app, activateWithOptions: 0];
return Ok(());
}
}
}
anyhow::bail!("Failed to activate application")
}
#[cfg(not(target_os = "macos"))]
pub fn activate_app(&self, _app_name: &str) -> Result<()> {
anyhow::bail!("Not supported on this platform")
}
/// Get the UI hierarchy of an application
#[cfg(target_os = "macos")]
pub fn get_ui_tree(&self, app_name: &str, max_depth: usize) -> Result<String> {
let app_element = self.get_app_element(app_name)?;
let mut output = format!("Application: {}\n", app_name);
Self::build_ui_tree(&app_element, &mut output, 0, max_depth)?;
Ok(output)
}
#[cfg(not(target_os = "macos"))]
pub fn get_ui_tree(&self, _app_name: &str, _max_depth: usize) -> Result<String> {
anyhow::bail!("Not supported on this platform")
}
#[cfg(target_os = "macos")]
fn build_ui_tree(
element: &AXUIElement,
output: &mut String,
depth: usize,
max_depth: usize,
) -> Result<()> {
if depth >= max_depth {
return Ok(());
}
let indent = " ".repeat(depth);
// Get role
let role = element.role().ok().map(|s| s.to_string())
.unwrap_or_else(|| "Unknown".to_string());
// Get title
let title = element.title().ok()
.map(|s| s.to_string());
// Get identifier
let identifier = element.identifier().ok()
.map(|s| s.to_string());
// Format output
output.push_str(&format!("{}Role: {}", indent, role));
if let Some(t) = title {
output.push_str(&format!(", Title: {}", t));
}
if let Some(id) = identifier {
output.push_str(&format!(", ID: {}", id));
}
output.push('\n');
// Get children
if let Ok(children) = element.children() {
for i in 0..children.len() {
if let Some(child) = children.get(i) {
let _ = Self::build_ui_tree(&child, output, depth + 1, max_depth);
}
}
}
Ok(())
}
/// Find UI elements in an application
#[cfg(target_os = "macos")]
pub fn find_elements(
&self,
app_name: &str,
role: Option<&str>,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<Vec<AXElement>> {
let app_element = self.get_app_element(app_name)?;
let mut found_elements = Vec::new();
let visitor = ElementCollector {
role_filter: role.map(|s| s.to_string()),
title_filter: title.map(|s| s.to_string()),
identifier_filter: identifier.map(|s| s.to_string()),
results: std::cell::RefCell::new(&mut found_elements),
depth: std::cell::Cell::new(0),
};
let walker = TreeWalker::new();
walker.walk(&app_element, &visitor);
Ok(found_elements)
}
#[cfg(not(target_os = "macos"))]
pub fn find_elements(
&self,
_app_name: &str,
_role: Option<&str>,
_title: Option<&str>,
_identifier: Option<&str>,
) -> Result<Vec<AXElement>> {
anyhow::bail!("Not supported on this platform")
}
/// Find a single element (helper for click, set_value, etc.)
#[cfg(target_os = "macos")]
fn find_element(
&self,
app_name: &str,
role: &str,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<AXUIElement> {
let app_element = self.get_app_element(app_name)?;
let role_str = role.to_string();
let title_str = title.map(|s| s.to_string());
let identifier_str = identifier.map(|s| s.to_string());
let finder = ElementFinder::new(
&app_element,
move |element| {
// Check role
let elem_role = element.role()
.ok()
.map(|s| s.to_string());
if let Some(r) = elem_role {
if !r.contains(&role_str) {
return false;
}
} else {
return false;
}
// Check title if specified
if let Some(ref title_filter) = title_str {
let elem_title = element.title()
.ok()
.map(|s| s.to_string());
if let Some(t) = elem_title {
if !t.contains(title_filter) {
return false;
}
} else {
return false;
}
}
// Check identifier if specified
if let Some(ref id_filter) = identifier_str {
let elem_id = element.identifier()
.ok()
.map(|s| s.to_string());
if let Some(id) = elem_id {
if !id.contains(id_filter) {
return false;
}
} else {
return false;
}
}
true
},
Some(std::time::Duration::from_secs(2)),
);
finder.find().context("Element not found")
}
/// Click on a UI element
#[cfg(target_os = "macos")]
pub fn click_element(
&self,
app_name: &str,
role: &str,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<()> {
let element = self.find_element(app_name, role, title, identifier)?;
// Perform the press action
let action_name = CFString::new("AXPress");
element
.perform_action(&action_name)
.map_err(|e| anyhow::anyhow!("Failed to perform press action: {:?}", e))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn click_element(
&self,
_app_name: &str,
_role: &str,
_title: Option<&str>,
_identifier: Option<&str>,
) -> Result<()> {
anyhow::bail!("Not supported on this platform")
}
/// Set the value of a UI element
#[cfg(target_os = "macos")]
pub fn set_value(
&self,
app_name: &str,
role: &str,
value: &str,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<()> {
let element = self.find_element(app_name, role, title, identifier)?;
// Set the value - convert CFString to CFType
let cf_value = CFString::new(value);
element.set_value(cf_value.as_CFType())
.map_err(|e| anyhow::anyhow!("Failed to set value: {:?}", e))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn set_value(
&self,
_app_name: &str,
_role: &str,
_value: &str,
_title: Option<&str>,
_identifier: Option<&str>,
) -> Result<()> {
anyhow::bail!("Not supported on this platform")
}
/// Get the value of a UI element
#[cfg(target_os = "macos")]
pub fn get_value(
&self,
app_name: &str,
role: &str,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<String> {
let element = self.find_element(app_name, role, title, identifier)?;
// Get the value
let value_type = element.value()
.map_err(|e| anyhow::anyhow!("Failed to get value: {:?}", e))?;
// Try to downcast to CFString
if let Some(cf_string) = value_type.downcast::<CFString>() {
Ok(cf_string.to_string())
} else {
// For non-string values, try to get a description
Ok(format!("<non-string value>"))
}
}
#[cfg(not(target_os = "macos"))]
pub fn get_value(
&self,
_app_name: &str,
_role: &str,
_title: Option<&str>,
_identifier: Option<&str>,
) -> Result<String> {
anyhow::bail!("Not supported on this platform")
}
/// Type text into the currently focused element (uses system text input)
#[cfg(target_os = "macos")]
pub fn type_text(&self, app_name: &str, text: &str) -> Result<()> {
use cocoa::base::{id, nil};
use cocoa::foundation::NSString;
use objc::{class, msg_send, sel, sel_impl};
// First, make sure the app is active
self.activate_app(app_name)?;
// Wait for app to fully activate
std::thread::sleep(std::time::Duration::from_millis(500));
// Send a Tab key to try to focus on a text field
// This helps ensure something is focused before we paste
let _ = self.press_key(app_name, "tab", vec![]);
std::thread::sleep(std::time::Duration::from_millis(800));
// Save old clipboard, set new content, paste, then restore
let old_content: id;
unsafe {
// Get the general pasteboard
let pasteboard: id = msg_send![class!(NSPasteboard), generalPasteboard];
// Save current clipboard content
let ns_string_type = NSString::alloc(nil).init_str("public.utf8-plain-text");
old_content = msg_send![pasteboard, stringForType: ns_string_type];
// Clear and set new content
let _: () = msg_send![pasteboard, clearContents];
let ns_string = NSString::alloc(nil).init_str(text);
let ns_type = NSString::alloc(nil).init_str("public.utf8-plain-text");
let _: bool = msg_send![pasteboard, setString:ns_string forType:ns_type];
}
// Wait a moment for clipboard to update
std::thread::sleep(std::time::Duration::from_millis(200));
// Paste using Cmd+V (outside unsafe block)
self.press_key(app_name, "v", vec!["command"])?;
// Wait for paste to complete
std::thread::sleep(std::time::Duration::from_millis(300));
// Restore old clipboard content if it existed
unsafe {
if old_content != nil {
let pasteboard: id = msg_send![class!(NSPasteboard), generalPasteboard];
let _: () = msg_send![pasteboard, clearContents];
let ns_type = NSString::alloc(nil).init_str("public.utf8-plain-text");
let _: bool = msg_send![pasteboard, setString:old_content forType:ns_type];
}
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn type_text(&self, _app_name: &str, _text: &str) -> Result<()> {
anyhow::bail!("Not supported on this platform")
}
/// Focus on a text field or text area element
#[cfg(target_os = "macos")]
pub fn focus_element(
&self,
app_name: &str,
role: &str,
title: Option<&str>,
identifier: Option<&str>,
) -> Result<()> {
let element = self.find_element(app_name, role, title, identifier)?;
// Set focused attribute to true
use core_foundation::boolean::CFBoolean;
let cf_true = CFBoolean::true_value();
element.set_attribute(&accessibility::AXAttribute::focused(), cf_true)
.map_err(|e| anyhow::anyhow!("Failed to focus element: {:?}", e))?;
Ok(())
}
/// Press a keyboard shortcut
#[cfg(target_os = "macos")]
pub fn press_key(
&self,
app_name: &str,
key: &str,
modifiers: Vec<&str>,
) -> Result<()> {
use core_graphics::event::{
CGEvent, CGEventFlags, CGEventTapLocation,
};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
// First, make sure the app is active
self.activate_app(app_name)?;
// Wait a bit for activation
std::thread::sleep(std::time::Duration::from_millis(100));
// Map key string to key code
let key_code = Self::key_to_keycode(key)
.ok_or_else(|| anyhow::anyhow!("Unknown key: {}", key))?;
// Map modifiers to flags
let mut flags = CGEventFlags::CGEventFlagNull;
for modifier in modifiers {
match modifier.to_lowercase().as_str() {
"command" | "cmd" => flags |= CGEventFlags::CGEventFlagCommand,
"option" | "alt" => flags |= CGEventFlags::CGEventFlagAlternate,
"control" | "ctrl" => flags |= CGEventFlags::CGEventFlagControl,
"shift" => flags |= CGEventFlags::CGEventFlagShift,
_ => {}
}
}
// Create event source
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
.ok().context("Failed to create event source")?;
// Create key down event
let key_down = CGEvent::new_keyboard_event(source.clone(), key_code, true)
.ok().context("Failed to create key down event")?;
key_down.set_flags(flags);
// Create key up event
let key_up = CGEvent::new_keyboard_event(source, key_code, false)
.ok().context("Failed to create key up event")?;
key_up.set_flags(flags);
// Post events
key_down.post(CGEventTapLocation::HID);
std::thread::sleep(std::time::Duration::from_millis(50));
key_up.post(CGEventTapLocation::HID);
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn press_key(
&self,
_app_name: &str,
_key: &str,
_modifiers: Vec<&str>,
) -> Result<()> {
anyhow::bail!("Not supported on this platform")
}
#[cfg(target_os = "macos")]
fn key_to_keycode(key: &str) -> Option<u16> {
// Map common keys to keycodes
// See: https://eastmanreference.com/complete-list-of-applescript-key-codes
match key.to_lowercase().as_str() {
"a" => Some(0x00),
"s" => Some(0x01),
"d" => Some(0x02),
"f" => Some(0x03),
"h" => Some(0x04),
"g" => Some(0x05),
"z" => Some(0x06),
"x" => Some(0x07),
"c" => Some(0x08),
"v" => Some(0x09),
"b" => Some(0x0B),
"q" => Some(0x0C),
"w" => Some(0x0D),
"e" => Some(0x0E),
"r" => Some(0x0F),
"y" => Some(0x10),
"t" => Some(0x11),
"1" => Some(0x12),
"2" => Some(0x13),
"3" => Some(0x14),
"4" => Some(0x15),
"6" => Some(0x16),
"5" => Some(0x17),
"=" => Some(0x18),
"9" => Some(0x19),
"7" => Some(0x1A),
"-" => Some(0x1B),
"8" => Some(0x1C),
"0" => Some(0x1D),
"]" => Some(0x1E),
"o" => Some(0x1F),
"u" => Some(0x20),
"[" => Some(0x21),
"i" => Some(0x22),
"p" => Some(0x23),
"return" | "enter" => Some(0x24),
"l" => Some(0x25),
"j" => Some(0x26),
"'" => Some(0x27),
"k" => Some(0x28),
";" => Some(0x29),
"\\" => Some(0x2A),
"," => Some(0x2B),
"/" => Some(0x2C),
"n" => Some(0x2D),
"m" => Some(0x2E),
"." => Some(0x2F),
"tab" => Some(0x30),
"space" => Some(0x31),
"`" => Some(0x32),
"delete" | "backspace" => Some(0x33),
"escape" | "esc" => Some(0x35),
"f1" => Some(0x7A),
"f2" => Some(0x78),
"f3" => Some(0x63),
"f4" => Some(0x76),
"f5" => Some(0x60),
"f6" => Some(0x61),
"f7" => Some(0x62),
"f8" => Some(0x64),
"f9" => Some(0x65),
"f10" => Some(0x6D),
"f11" => Some(0x67),
"f12" => Some(0x6F),
"left" => Some(0x7B),
"right" => Some(0x7C),
"down" => Some(0x7D),
"up" => Some(0x7E),
_ => None,
}
}
}
#[cfg(target_os = "macos")]
struct ElementCollector<'a> {
role_filter: Option<String>,
title_filter: Option<String>,
identifier_filter: Option<String>,
results: std::cell::RefCell<&'a mut Vec<AXElement>>,
depth: std::cell::Cell<usize>,
}
#[cfg(target_os = "macos")]
impl<'a> TreeVisitor for ElementCollector<'a> {
fn enter_element(&self, element: &AXUIElement) -> TreeWalkerFlow {
self.depth.set(self.depth.get() + 1);
if self.depth.get() > 20 {
return TreeWalkerFlow::SkipSubtree;
}
// Get element properties
let role = element.role()
.ok()
.map(|s| s.to_string())
.unwrap_or_else(|| "Unknown".to_string());
let title = element.title()
.ok()
.map(|s| s.to_string());
let identifier = element.identifier()
.ok()
.map(|s| s.to_string());
// Check if this element matches the filters
let role_matches = self.role_filter.as_ref().map_or(true, |r| role.contains(r));
let title_matches = self.title_filter.as_ref().map_or(true, |t| {
title.as_ref().map_or(false, |title_str| title_str.contains(t))
});
let identifier_matches = self.identifier_filter.as_ref().map_or(true, |id| {
identifier.as_ref().map_or(false, |id_str| id_str.contains(id))
});
if role_matches && title_matches && identifier_matches {
// Get additional properties
let value = element.value()
.ok()
.and_then(|v| {
v.downcast::<CFString>().map(|s| s.to_string())
});
let label = element.description()
.ok()
.map(|s| s.to_string());
let enabled = element.enabled()
.ok()
.map(|b| b.into())
.unwrap_or(false);
let focused = element.focused()
.ok()
.map(|b| b.into())
.unwrap_or(false);
// Count children
let children_count = element.children()
.ok()
.map(|arr| arr.len() as usize)
.unwrap_or(0);
self.results.borrow_mut().push(AXElement {
role,
title,
value,
label,
identifier,
enabled,
focused,
position: None,
size: None,
children_count,
});
}
TreeWalkerFlow::Continue
}
fn exit_element(&self, _element: &AXUIElement) {
self.depth.set(self.depth.get() - 1);
}
}

View File

@@ -0,0 +1,65 @@
pub mod controller;
pub use controller::MacAxController;
use serde::{Deserialize, Serialize};
#[cfg(test)]
mod tests;
/// Represents an accessibility element in the UI hierarchy
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AXElement {
pub role: String,
pub title: Option<String>,
pub value: Option<String>,
pub label: Option<String>,
pub identifier: Option<String>,
pub enabled: bool,
pub focused: bool,
pub position: Option<(f64, f64)>,
pub size: Option<(f64, f64)>,
pub children_count: usize,
}
/// Represents a macOS application
#[derive(Debug, Clone)]
pub struct AXApplication {
pub name: String,
pub bundle_id: Option<String>,
pub pid: i32,
}
impl AXElement {
/// Convert to a human-readable string representation
pub fn to_string(&self) -> String {
let mut parts = vec![format!("Role: {}", self.role)];
if let Some(ref title) = self.title {
parts.push(format!("Title: {}", title));
}
if let Some(ref value) = self.value {
parts.push(format!("Value: {}", value));
}
if let Some(ref label) = self.label {
parts.push(format!("Label: {}", label));
}
if let Some(ref id) = self.identifier {
parts.push(format!("ID: {}", id));
}
parts.push(format!("Enabled: {}", self.enabled));
parts.push(format!("Focused: {}", self.focused));
if let Some((x, y)) = self.position {
parts.push(format!("Position: ({:.0}, {:.0})", x, y));
}
if let Some((w, h)) = self.size {
parts.push(format!("Size: ({:.0}, {:.0})", w, h));
}
parts.push(format!("Children: {}", self.children_count));
parts.join(", ")
}
}

View File

@@ -0,0 +1,37 @@
#[cfg(test)]
mod tests {
use crate::{AXElement, MacAxController};
#[test]
fn test_ax_element_to_string() {
let element = AXElement {
role: "button".to_string(),
title: Some("Click Me".to_string()),
value: None,
label: Some("Submit Button".to_string()),
identifier: Some("submitBtn".to_string()),
enabled: true,
focused: false,
position: Some((100.0, 200.0)),
size: Some((80.0, 30.0)),
children_count: 0,
};
let string_repr = element.to_string();
assert!(string_repr.contains("Role: button"));
assert!(string_repr.contains("Title: Click Me"));
assert!(string_repr.contains("Label: Submit Button"));
assert!(string_repr.contains("ID: submitBtn"));
assert!(string_repr.contains("Enabled: true"));
assert!(string_repr.contains("Position: (100, 200)"));
assert!(string_repr.contains("Size: (80, 30)"));
}
#[test]
fn test_controller_creation() {
// Just test that we can create a controller
// Actual functionality requires macOS and permissions
let result = MacAxController::new();
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,26 @@
use crate::types::TextLocation;
use anyhow::Result;
use async_trait::async_trait;
/// OCR engine trait for text recognition with bounding boxes
#[async_trait]
pub trait OCREngine: Send + Sync {
/// Extract text with locations from an image file
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>>;
/// Get the name of the OCR engine
fn name(&self) -> &str;
}
// Platform-specific modules
#[cfg(target_os = "macos")]
pub mod vision;
pub mod tesseract;
// Re-export the default OCR engine for the platform
#[cfg(target_os = "macos")]
pub use vision::AppleVisionOCR as DefaultOCR;
#[cfg(not(target_os = "macos"))]
pub use tesseract::TesseractOCR as DefaultOCR;

View File

@@ -0,0 +1,84 @@
use super::OCREngine;
use crate::types::TextLocation;
use anyhow::Result;
use async_trait::async_trait;
/// Tesseract OCR engine (fallback/cross-platform)
pub struct TesseractOCR;
impl TesseractOCR {
pub fn new() -> Result<Self> {
// Check if tesseract is available
let tesseract_check = std::process::Command::new("which")
.arg("tesseract")
.output();
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
To install tesseract:\n macOS: brew install tesseract\n \
Linux: sudo apt-get install tesseract-ocr (Ubuntu/Debian)\n \
sudo yum install tesseract (RHEL/CentOS)\n \
Windows: Download from https://github.com/UB-Mannheim/tesseract/wiki\n\n\
After installation, restart your terminal and try again.");
}
Ok(Self)
}
}
#[async_trait]
impl OCREngine for TesseractOCR {
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>> {
// Use tesseract CLI with TSV output to get bounding boxes
let output = std::process::Command::new("tesseract")
.arg(path)
.arg("stdout")
.arg("tsv")
.output()
.map_err(|e| anyhow::anyhow!("Failed to run tesseract: {}", e))?;
if !output.status.success() {
anyhow::bail!("Tesseract failed: {}", String::from_utf8_lossy(&output.stderr));
}
let tsv_text = String::from_utf8_lossy(&output.stdout);
let mut locations = Vec::new();
// Parse TSV output (skip header line)
for (i, line) in tsv_text.lines().enumerate() {
if i == 0 { continue; } // Skip header
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 12 {
// TSV format: level, page_num, block_num, par_num, line_num, word_num,
// left, top, width, height, conf, text
if let (Ok(x), Ok(y), Ok(w), Ok(h), Ok(conf), text) = (
parts[6].parse::<i32>(),
parts[7].parse::<i32>(),
parts[8].parse::<i32>(),
parts[9].parse::<i32>(),
parts[10].parse::<f32>(),
parts[11],
) {
let trimmed = text.trim();
if !trimmed.is_empty() && conf > 0.0 {
locations.push(TextLocation {
text: trimmed.to_string(),
x,
y,
width: w,
height: h,
confidence: conf / 100.0, // Convert from 0-100 to 0-1
});
}
}
}
}
Ok(locations)
}
fn name(&self) -> &str {
"Tesseract OCR"
}
}

View File

@@ -0,0 +1,103 @@
use super::OCREngine;
use crate::types::TextLocation;
use anyhow::{Result, Context};
use async_trait::async_trait;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_float, c_uint};
// FFI bindings to Swift VisionBridge
#[repr(C)]
struct VisionTextBox {
text: *const c_char,
text_len: c_uint,
x: i32,
y: i32,
width: i32,
height: i32,
confidence: c_float,
}
extern "C" {
fn vision_recognize_text(
image_path: *const c_char,
image_path_len: c_uint,
out_boxes: *mut *mut std::ffi::c_void,
out_count: *mut c_uint,
) -> bool;
fn vision_free_boxes(boxes: *mut std::ffi::c_void, count: c_uint);
}
/// Apple Vision Framework OCR engine
pub struct AppleVisionOCR;
impl AppleVisionOCR {
pub fn new() -> Result<Self> {
Ok(Self)
}
}
#[async_trait]
impl OCREngine for AppleVisionOCR {
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>> {
// Convert path to C string
let c_path = CString::new(path)
.context("Failed to convert path to C string")?;
let mut boxes_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
let mut count: c_uint = 0;
// Call Swift Vision API
let success = unsafe {
vision_recognize_text(
c_path.as_ptr(),
path.len() as c_uint,
&mut boxes_ptr,
&mut count,
)
};
if !success || boxes_ptr.is_null() {
anyhow::bail!("Apple Vision OCR failed");
}
// Convert C array to Rust Vec
let mut locations = Vec::new();
unsafe {
let typed_boxes = boxes_ptr as *const VisionTextBox;
let boxes_slice = std::slice::from_raw_parts(typed_boxes, count as usize);
for box_data in boxes_slice {
// Convert C string to Rust String
let text = if !box_data.text.is_null() {
CStr::from_ptr(box_data.text)
.to_string_lossy()
.into_owned()
} else {
String::new()
};
if !text.is_empty() {
locations.push(TextLocation {
text,
x: box_data.x,
y: box_data.y,
width: box_data.width,
height: box_data.height,
confidence: box_data.confidence,
});
}
}
// Free the C array
vision_free_boxes(boxes_ptr, count);
}
Ok(locations)
}
fn name(&self) -> &str {
"Apple Vision Framework"
}
}

View File

@@ -1,24 +1,166 @@
use crate::{types::Rect, ComputerController};
use crate::{ComputerController, types::*};
use anyhow::Result;
use async_trait::async_trait;
use tesseract::Tesseract;
use uuid::Uuid;
pub struct LinuxController;
pub struct LinuxController {
// Placeholder for X11 connection or other state
}
impl LinuxController {
pub fn new() -> Result<Self> {
// Initialize X11 connection
tracing::warn!("Linux computer control not fully implemented");
Ok(Self)
Ok(Self {})
}
}
#[async_trait]
impl ComputerController for LinuxController {
async fn take_screenshot(
&self,
_path: &str,
_region: Option<Rect>,
_window_id: Option<&str>,
) -> Result<()> {
anyhow::bail!("Linux screenshot implementation not yet available")
async fn move_mouse(&self, _x: i32, _y: i32) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn click(&self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn double_click(&self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn type_text(&self, _text: &str) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn press_key(&self, _key: &str) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn list_windows(&self) -> Result<Vec<Window>> {
anyhow::bail!("Linux implementation not yet available")
}
async fn focus_window(&self, _window_id: &str) -> Result<()> {
anyhow::bail!("Linux implementation not yet available")
}
async fn get_window_bounds(&self, _window_id: &str) -> Result<Rect> {
anyhow::bail!("Linux implementation not yet available")
}
async fn find_element(&self, _selector: &ElementSelector) -> Result<Option<UIElement>> {
anyhow::bail!("Linux implementation not yet available")
}
async fn get_element_text(&self, _element_id: &str) -> Result<String> {
anyhow::bail!("Linux implementation not yet available")
}
async fn get_element_bounds(&self, _element_id: &str) -> Result<Rect> {
anyhow::bail!("Linux implementation not yet available")
}
async fn take_screenshot(&self, _path: &str, _region: Option<Rect>, _window_id: Option<&str>) -> Result<()> {
// Enforce that window_id must be provided
if _window_id.is_none() {
anyhow::bail!("window_id is required. You must specify which window to capture (e.g., 'Firefox', 'Terminal', 'gedit'). Use list_windows to see available windows.");
}
anyhow::bail!("Linux implementation not yet available")
}
async fn extract_text_from_screen(&self, _region: Rect, _window_id: &str) -> Result<String> {
anyhow::bail!("Linux implementation not yet available")
}
async fn extract_text_from_image(&self, _path: &str) -> Result<OCRResult> {
// Check if tesseract is available on the system
let tesseract_check = std::process::Command::new("which")
.arg("tesseract")
.output();
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
To install tesseract:\n \
Ubuntu/Debian: sudo apt-get install tesseract-ocr\n \
RHEL/CentOS: sudo yum install tesseract\n \
Arch Linux: sudo pacman -S tesseract\n\n\
After installation, restart your terminal and try again.");
}
// Initialize Tesseract
let tess = Tesseract::new(None, Some("eng"))
.map_err(|e| {
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
This usually means:\n1. Tesseract is not properly installed\n\
2. Language data files are missing\n\nTo fix:\n \
Ubuntu/Debian: sudo apt-get install tesseract-ocr-eng\n \
RHEL/CentOS: sudo yum install tesseract-langpack-eng\n \
Arch Linux: sudo pacman -S tesseract-data-eng", e)
})?;
let text = tess.set_image(_path)
.map_err(|e| anyhow::anyhow!("Failed to load image '{}': {}", _path, e))?
.get_text()
.map_err(|e| anyhow::anyhow!("Failed to extract text from image: {}", e))?;
// Get confidence (simplified - would need more complex API calls for per-word confidence)
let confidence = 0.85; // Placeholder
Ok(OCRResult {
text,
confidence,
bounds: Rect { x: 0, y: 0, width: 0, height: 0 }, // Would need image dimensions
})
}
async fn find_text_on_screen(&self, _text: &str) -> Result<Option<Point>> {
// Check if tesseract is available on the system
let tesseract_check = std::process::Command::new("which")
.arg("tesseract")
.output();
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
To install tesseract:\n \
Ubuntu/Debian: sudo apt-get install tesseract-ocr\n \
RHEL/CentOS: sudo yum install tesseract\n \
Arch Linux: sudo pacman -S tesseract\n\n\
After installation, restart your terminal and try again.");
}
// Take full screen screenshot
let temp_path = format!("/tmp/g3_ocr_search_{}.png", uuid::Uuid::new_v4());
self.take_screenshot(&temp_path, None, None).await?;
// Use Tesseract to find text with bounding boxes
let tess = Tesseract::new(None, Some("eng"))
.map_err(|e| {
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
This usually means:\n1. Tesseract is not properly installed\n\
2. Language data files are missing\n\nTo fix:\n \
Ubuntu/Debian: sudo apt-get install tesseract-ocr-eng\n \
RHEL/CentOS: sudo yum install tesseract-langpack-eng\n \
Arch Linux: sudo pacman -S tesseract-data-eng", e)
})?;
let full_text = tess.set_image(temp_path.as_str())
.map_err(|e| anyhow::anyhow!("Failed to load screenshot: {}", e))?
.get_text()
.map_err(|e| anyhow::anyhow!("Failed to extract text from screen: {}", e))?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
// Simple text search - full implementation would use get_component_images
// to get bounding boxes for each word
if full_text.contains(_text) {
tracing::warn!("Text found but precise coordinates not available in simplified implementation");
Ok(Some(Point { x: 0, y: 0 }))
} else {
Ok(None)
}
}
}

View File

@@ -1,34 +1,32 @@
use crate::{
types::Rect, ComputerController,
};
use anyhow::Result;
use crate::{ComputerController, types::{Rect, TextLocation}};
use crate::ocr::{OCREngine, DefaultOCR};
use anyhow::{Result, Context};
use async_trait::async_trait;
use core_foundation::array::CFArray;
use core_foundation::base::{TCFType, ToVoid};
use std::path::Path;
use core_graphics::window::{kCGWindowListOptionOnScreenOnly, kCGNullWindowID, CGWindowListCopyWindowInfo};
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
use core_graphics::window::{
kCGNullWindowID, kCGWindowListOptionOnScreenOnly, CGWindowListCopyWindowInfo,
};
use std::path::Path;
use core_foundation::base::{TCFType, ToVoid};
use core_foundation::array::CFArray;
pub struct MacOSController;
pub struct MacOSController {
ocr_engine: Box<dyn OCREngine>,
#[allow(dead_code)]
ocr_name: String,
}
impl MacOSController {
pub fn new() -> Result<Self> {
tracing::debug!("Initialized macOS controller");
Ok(Self)
let ocr = Box::new(DefaultOCR::new()?);
let ocr_name = ocr.name().to_string();
tracing::info!("Initialized macOS controller with OCR engine: {}", ocr_name);
Ok(Self { ocr_engine: ocr, ocr_name })
}
}
#[async_trait]
impl ComputerController for MacOSController {
async fn take_screenshot(
&self,
path: &str,
region: Option<Rect>,
window_id: Option<&str>,
) -> Result<()> {
async fn take_screenshot(&self, path: &str, region: Option<Rect>, window_id: Option<&str>) -> Result<()> {
// Enforce that window_id must be provided
if window_id.is_none() {
return Err(anyhow::anyhow!("window_id is required. You must specify which window to capture (e.g., 'Safari', 'Terminal', 'Google Chrome'). Use list_windows to see available windows."));
@@ -38,38 +36,40 @@ impl ComputerController for MacOSController {
let temp_dir = std::env::var("TMPDIR")
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/tmp", h)))
.unwrap_or_else(|_| "/tmp".to_string());
// Ensure temp directory exists
std::fs::create_dir_all(&temp_dir)?;
// If path is relative or doesn't specify a directory, use temp_dir
let final_path = if path.starts_with('/') {
path.to_string()
} else {
format!("{}/{}", temp_dir.trim_end_matches('/'), path)
};
let path_obj = Path::new(&final_path);
if let Some(parent) = path_obj.parent() {
std::fs::create_dir_all(parent)?;
}
let app_name = window_id.unwrap(); // Safe because we checked is_none() above
// Get the window ID for the specified application
let cg_window_id = unsafe {
let window_list =
CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
let window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID
);
let array = CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
let count = array.len();
let mut found_window_id: Option<(u32, String)> = None; // (id, owner)
let app_name_lower = app_name.to_lowercase();
for i in 0..count {
let dict = array.get(i).unwrap();
// Get owner name
let owner_key = CFString::from_static_string("kCGWindowOwnerName");
let owner: String = if let Some(value) = dict.find(owner_key.to_void()) {
@@ -78,134 +78,430 @@ impl ComputerController for MacOSController {
} else {
continue;
};
tracing::debug!(
"Checking window: owner='{}', looking for '{}'",
owner,
app_name
);
tracing::debug!("Checking window: owner='{}', looking for '{}'", owner, app_name);
let owner_lower = owner.to_lowercase();
// Normalize by removing spaces for exact matching
let app_name_normalized = app_name_lower.replace(" ", "");
let owner_normalized = owner_lower.replace(" ", "");
// ONLY accept exact matches (case-insensitive, with or without spaces)
// This prevents "Goose" from matching "GooseStudio"
let is_match =
owner_lower == app_name_lower || owner_normalized == app_name_normalized;
let is_match = owner_lower == app_name_lower || owner_normalized == app_name_normalized;
if is_match {
// Get window ID
let window_id_key = CFString::from_static_string("kCGWindowNumber");
if let Some(value) = dict.find(window_id_key.to_void()) {
let num: core_foundation::number::CFNumber =
TCFType::wrap_under_get_rule(*value as *const _);
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
if let Some(id) = num.to_i64() {
// Get window layer to filter out menu bar windows
let layer_key = CFString::from_static_string("kCGWindowLayer");
let layer: i32 = if let Some(value) = dict.find(layer_key.to_void()) {
let num: core_foundation::number::CFNumber =
TCFType::wrap_under_get_rule(*value as *const _);
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
num.to_i32().unwrap_or(0)
} else {
0
};
// Get window bounds to verify it's a real window
let bounds_key = CFString::from_static_string("kCGWindowBounds");
let has_real_bounds =
if let Some(value) = dict.find(bounds_key.to_void()) {
let bounds_dict: CFDictionary =
TCFType::wrap_under_get_rule(*value as *const _);
let width_key = CFString::from_static_string("Width");
let height_key = CFString::from_static_string("Height");
if let (Some(w_val), Some(h_val)) = (
bounds_dict.find(width_key.to_void()),
bounds_dict.find(height_key.to_void()),
) {
let w_num: core_foundation::number::CFNumber =
TCFType::wrap_under_get_rule(*w_val as *const _);
let h_num: core_foundation::number::CFNumber =
TCFType::wrap_under_get_rule(*h_val as *const _);
let width = w_num.to_f64().unwrap_or(0.0);
let height = h_num.to_f64().unwrap_or(0.0);
// Real windows should be at least 100x100 pixels
width >= 100.0 && height >= 100.0
} else {
false
}
let has_real_bounds = if let Some(value) = dict.find(bounds_key.to_void()) {
let bounds_dict: CFDictionary = TCFType::wrap_under_get_rule(*value as *const _);
let width_key = CFString::from_static_string("Width");
let height_key = CFString::from_static_string("Height");
if let (Some(w_val), Some(h_val)) = (
bounds_dict.find(width_key.to_void()),
bounds_dict.find(height_key.to_void()),
) {
let w_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*w_val as *const _);
let h_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*h_val as *const _);
let width = w_num.to_f64().unwrap_or(0.0);
let height = h_num.to_f64().unwrap_or(0.0);
// Real windows should be at least 100x100 pixels
width >= 100.0 && height >= 100.0
} else {
false
};
}
} else {
false
};
// Only accept windows that are:
// 1. At layer 0 (normal windows, not menu bar)
// 2. Have real bounds (width and height >= 100)
if layer == 0 && has_real_bounds {
tracing::debug!("Found valid window: ID {} for app '{}' (layer={}, bounds valid)", id, owner, layer);
tracing::info!("Found valid window: ID {} for app '{}' (layer={}, bounds valid)", id, owner, layer);
found_window_id = Some((id as u32, owner.clone()));
break;
} else {
tracing::debug!(
"Skipping window ID {} for '{}': layer={}, has_real_bounds={}",
id,
owner,
layer,
has_real_bounds
);
tracing::debug!("Skipping window ID {} for '{}': layer={}, has_real_bounds={}", id, owner, layer, has_real_bounds);
}
}
}
}
}
found_window_id
};
let (cg_window_id, matched_owner) = cg_window_id.ok_or_else(|| {
anyhow::anyhow!("Could not find window for application '{}'. Use list_windows to see available windows.", app_name)
})?;
tracing::debug!(
"Taking screenshot of window ID {} for app '{}'",
cg_window_id,
matched_owner
);
tracing::info!("Taking screenshot of window ID {} for app '{}'", cg_window_id, matched_owner);
// Use screencapture with the window ID for now
// TODO: Implement direct CGWindowListCreateImage approach with proper image saving
let mut cmd = std::process::Command::new("screencapture");
cmd.arg("-x"); // No sound
cmd.arg("-l");
cmd.arg(cg_window_id.to_string());
if let Some(region) = region {
cmd.arg("-R");
cmd.arg(format!(
"{},{},{},{}",
region.x, region.y, region.width, region.height
));
cmd.arg(format!("{},{},{},{}", region.x, region.y, region.width, region.height));
}
cmd.arg(&final_path);
let screenshot_result = cmd.output()?;
if !screenshot_result.status.success() {
let stderr = String::from_utf8_lossy(&screenshot_result.stderr);
return Err(anyhow::anyhow!(
"screencapture failed for window {}: {}",
cg_window_id,
stderr
));
return Err(anyhow::anyhow!("screencapture failed for window {}: {}", cg_window_id, stderr));
}
Ok(())
}
async fn extract_text_from_screen(&self, region: Rect, window_id: &str) -> Result<String> {
// Take screenshot of region first
let temp_path = format!("/tmp/g3_ocr_{}.png", uuid::Uuid::new_v4());
self.take_screenshot(&temp_path, Some(region), Some(window_id)).await?;
// Extract text from the screenshot
let result = self.extract_text_from_image(&temp_path).await?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
Ok(result)
}
async fn extract_text_from_image(&self, path: &str) -> Result<String> {
// Extract all text and concatenate
let locations = self.ocr_engine.extract_text_with_locations(path).await?;
Ok(locations.iter().map(|loc| loc.text.as_str()).collect::<Vec<_>>().join(" "))
}
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>> {
// Use the OCR engine
self.ocr_engine.extract_text_with_locations(path).await
}
async fn find_text_in_app(&self, app_name: &str, search_text: &str) -> Result<Option<TextLocation>> {
// Take screenshot of specific app window
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let temp_path = format!("{}/tmp/g3_find_text_{}_{}.png", home, app_name, uuid::Uuid::new_v4());
self.take_screenshot(&temp_path, None, Some(app_name)).await?;
// Get screenshot dimensions before we delete it
let screenshot_dims = get_image_dimensions(&temp_path)?;
// Extract all text with locations
let locations = self.extract_text_with_locations(&temp_path).await?;
// Get window bounds to calculate coordinate transformation
let window_bounds = self.get_window_bounds(app_name)?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
// Find matching text (case-insensitive)
let search_lower = search_text.to_lowercase();
for location in locations {
if location.text.to_lowercase().contains(&search_lower) {
// Transform coordinates from screenshot space to screen space
let transformed = transform_screenshot_to_screen_coords(
location,
window_bounds,
screenshot_dims,
);
return Ok(Some(transformed));
}
}
Ok(None)
}
fn move_mouse(&self, x: i32, y: i32) -> Result<()> {
use core_graphics::event::{
CGEvent, CGEventTapLocation, CGEventType, CGMouseButton,
};
use core_graphics::event_source::{
CGEventSource, CGEventSourceStateID,
};
use core_graphics::geometry::CGPoint;
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
.ok().context("Failed to create event source")?;
let event = CGEvent::new_mouse_event(
source,
CGEventType::MouseMoved,
CGPoint::new(x as f64, y as f64),
CGMouseButton::Left,
).ok().context("Failed to create mouse event")?;
event.post(CGEventTapLocation::HID);
Ok(())
}
fn click_at(&self, x: i32, y: i32, _app_name: Option<&str>) -> Result<()> {
use core_graphics::event::{
CGEvent, CGEventTapLocation, CGEventType, CGMouseButton,
};
use core_graphics::event_source::{
CGEventSource, CGEventSourceStateID,
};
use core_graphics::geometry::CGPoint;
use core_graphics::display::CGDisplay;
// IMPORTANT: Coordinates passed here are in NSScreen/CGWindowListCopyWindowInfo space
// (Y=0 at BOTTOM, increases UPWARD)
// But CGEvent uses a different coordinate system (Y=0 at TOP, increases DOWNWARD)
// We need to convert: CGEvent.y = screenHeight - NSScreen.y
let screen_height = CGDisplay::main().pixels_high() as i32;
let cgevent_x = x;
let cgevent_y = screen_height - y;
tracing::debug!("click_at: NSScreen coords ({}, {}) -> CGEvent coords ({}, {}) [screen_height={}]",
x, y, cgevent_x, cgevent_y, screen_height);
let (global_x, global_y) = (cgevent_x, cgevent_y);
let point = CGPoint::new(global_x as f64, global_y as f64);
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
.ok().context("Failed to create event source")?;
// Move mouse to position first
let move_event = CGEvent::new_mouse_event(
source.clone(),
CGEventType::MouseMoved,
point,
CGMouseButton::Left,
).ok().context("Failed to create mouse move event")?;
move_event.post(CGEventTapLocation::HID);
std::thread::sleep(std::time::Duration::from_millis(100));
// Mouse down
let mouse_down = CGEvent::new_mouse_event(
source.clone(),
CGEventType::LeftMouseDown,
point,
CGMouseButton::Left,
).ok().context("Failed to create mouse down event")?;
mouse_down.post(CGEventTapLocation::HID);
std::thread::sleep(std::time::Duration::from_millis(50));
// Mouse up
let mouse_up = CGEvent::new_mouse_event(
source,
CGEventType::LeftMouseUp,
point,
CGMouseButton::Left,
).ok().context("Failed to create mouse up event")?;
mouse_up.post(CGEventTapLocation::HID);
Ok(())
}
}
impl MacOSController {
/// Get window bounds for an application (helper method)
fn get_window_bounds(&self, app_name: &str) -> Result<(i32, i32, i32, i32)> {
unsafe {
let window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID
);
let array = CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
let count = array.len();
let app_name_lower = app_name.to_lowercase();
for i in 0..count {
let dict = array.get(i).unwrap();
// Get owner name
let owner_key = CFString::from_static_string("kCGWindowOwnerName");
let owner: String = if let Some(value) = dict.find(owner_key.to_void()) {
let s: CFString = TCFType::wrap_under_get_rule(*value as *const _);
s.to_string()
} else {
continue;
};
let owner_lower = owner.to_lowercase();
// Normalize by removing spaces for exact matching
let app_name_normalized = app_name_lower.replace(" ", "");
let owner_normalized = owner_lower.replace(" ", "");
// ONLY accept exact matches (case-insensitive, with or without spaces)
// This prevents "Goose" from matching "GooseStudio"
let is_match = owner_lower == app_name_lower || owner_normalized == app_name_normalized;
if is_match {
// Get window layer to filter out menu bar windows
let layer_key = CFString::from_static_string("kCGWindowLayer");
let layer: i32 = if let Some(value) = dict.find(layer_key.to_void()) {
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
num.to_i32().unwrap_or(0)
} else {
0
};
// Skip menu bar windows (layer >= 20)
if layer >= 20 {
tracing::debug!("Skipping window for '{}' at layer {} (menu bar)", owner, layer);
continue;
}
// Get window bounds to verify it's a real window
let bounds_key = CFString::from_static_string("kCGWindowBounds");
if let Some(value) = dict.find(bounds_key.to_void()) {
let bounds_dict: CFDictionary = TCFType::wrap_under_get_rule(*value as *const _);
let x_key = CFString::from_static_string("X");
let y_key = CFString::from_static_string("Y");
let width_key = CFString::from_static_string("Width");
let height_key = CFString::from_static_string("Height");
if let (Some(x_val), Some(y_val), Some(w_val), Some(h_val)) = (
bounds_dict.find(x_key.to_void()),
bounds_dict.find(y_key.to_void()),
bounds_dict.find(width_key.to_void()),
bounds_dict.find(height_key.to_void()),
) {
let x_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*x_val as *const _);
let y_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*y_val as *const _);
let w_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*w_val as *const _);
let h_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*h_val as *const _);
let x: i32 = x_num.to_i64().unwrap_or(0) as i32;
let y: i32 = y_num.to_i64().unwrap_or(0) as i32;
let w: i32 = w_num.to_i64().unwrap_or(0) as i32;
let h: i32 = h_num.to_i64().unwrap_or(0) as i32;
// Only accept windows with real bounds (>= 100x100 pixels)
if w >= 100 && h >= 100 {
tracing::info!("Found valid window bounds for '{}': x={}, y={}, w={}, h={} (layer={})", owner, x, y, w, h, layer);
return Ok((x, y, w, h));
} else {
tracing::debug!("Skipping window for '{}': too small ({}x{})", owner, w, h);
continue;
}
} else {
continue;
}
}
}
}
}
Err(anyhow::anyhow!("Could not find window bounds for '{}'", app_name))
}
}
/// Get image dimensions from a PNG file
fn get_image_dimensions(path: &str) -> Result<(i32, i32)> {
use std::fs::File;
use std::io::Read;
let mut file = File::open(path)?;
let mut buffer = vec![0u8; 24];
file.read_exact(&mut buffer)?;
// PNG signature check
if &buffer[0..8] != b"\x89PNG\r\n\x1a\n" {
anyhow::bail!("Not a valid PNG file");
}
// Read IHDR chunk (width and height are at bytes 16-23)
let width = u32::from_be_bytes([buffer[16], buffer[17], buffer[18], buffer[19]]) as i32;
let height = u32::from_be_bytes([buffer[20], buffer[21], buffer[22], buffer[23]]) as i32;
Ok((width, height))
}
/// Transform coordinates from screenshot space to screen space
///
/// The screenshot is taken of a window, and Vision OCR returns coordinates
/// relative to the screenshot image. We need to transform these to actual
/// screen coordinates for clicking.
///
/// On Retina displays, screenshots are taken at 2x resolution, so we need
/// to account for this scaling factor.
fn transform_screenshot_to_screen_coords(
location: TextLocation,
window_bounds: (i32, i32, i32, i32), // (x, y, width, height) in screen space
screenshot_dims: (i32, i32), // (width, height) in pixels
) -> TextLocation {
let (win_x, win_y, win_width, win_height) = window_bounds;
let (screenshot_width, screenshot_height) = screenshot_dims;
// Calculate scale factors
// On Retina displays, screenshot is typically 2x the window size
let scale_x = win_width as f64 / screenshot_width as f64;
let scale_y = win_height as f64 / screenshot_height as f64;
tracing::debug!("Transform: screenshot={}x{}, window={}x{} at ({},{}), scale=({:.2},{:.2})",
screenshot_width, screenshot_height, win_width, win_height, win_x, win_y, scale_x, scale_y);
// Transform coordinates from image space to screen space
// IMPORTANT: macOS screen coordinates have origin at BOTTOM-LEFT (Y increases upward)
// Image coordinates have origin at TOP-LEFT (Y increases downward)
// win_y is the BOTTOM of the window in screen coordinates
// So we need to: (win_y + win_height) to get window TOP, then subtract screenshot_y
let window_top_y = win_y + win_height;
tracing::debug!("[transform] Input location in image space: x={}, y={}, width={}, height={}",
location.x, location.y, location.width, location.height);
tracing::debug!("[transform] Scale factors: scale_x={:.4}, scale_y={:.4}", scale_x, scale_y);
let transformed_x = win_x + (location.x as f64 * scale_x) as i32;
let transformed_y = window_top_y - (location.y as f64 * scale_y) as i32;
let transformed_width = (location.width as f64 * scale_x) as i32;
let transformed_height = (location.height as f64 * scale_y) as i32;
tracing::debug!("[transform] Calculation details:");
tracing::debug!(" - transformed_x = {} + ({} * {:.4}) = {} + {:.2} = {}", win_x, location.x, scale_x, win_x, location.x as f64 * scale_x, transformed_x);
tracing::debug!(" - transformed_width = ({} * {:.4}) = {:.2} -> {}", location.width, scale_x, location.width as f64 * scale_x, transformed_width);
tracing::debug!(" - transformed_height = ({} * {:.4}) = {:.2} -> {}", location.height, scale_y, location.height as f64 * scale_y, transformed_height);
tracing::debug!("Transformed location: screenshot=({},{}) {}x{} -> screen=({},{}) {}x{}",
location.x, location.y, location.width, location.height,
transformed_x, transformed_y, transformed_width, transformed_height);
TextLocation {
text: location.text,
x: transformed_x,
y: transformed_y,
width: transformed_width,
height: transformed_height,
confidence: location.confidence,
}
}
#[path = "macos_window_matching_test.rs"]
#[cfg(test)]
mod tests;
mod tests;

View File

@@ -1,11 +1,11 @@
#[cfg(test)]
mod window_matching_tests {
/// Test that window name matching handles spaces correctly
///
///
/// Issue: When a user requests a screenshot of "Goose Studio" but the actual
/// application name is "GooseStudio" (no space), the fuzzy matching should
/// still find the window.
///
///
/// The fix normalizes both names by removing spaces before comparing.
#[test]
fn test_space_normalization() {
@@ -16,25 +16,25 @@ mod window_matching_tests {
("Visual Studio Code", "VisualStudioCode", true),
("Google Chrome", "Google Chrome", true),
("Safari", "Safari", true),
("iTerm", "iTerm2", true), // fuzzy match
("iTerm", "iTerm2", true), // fuzzy match
("Code", "Visual Studio Code", true), // fuzzy match
];
for (user_input, app_name, should_match) in test_cases {
let user_lower = user_input.to_lowercase();
let app_lower = app_name.to_lowercase();
let user_normalized = user_lower.replace(" ", "");
let app_normalized = app_lower.replace(" ", "");
let is_exact = app_lower == user_lower || app_normalized == user_normalized;
let is_fuzzy = app_lower.contains(&user_lower)
let is_fuzzy = app_lower.contains(&user_lower)
|| user_lower.contains(&app_lower)
|| app_normalized.contains(&user_normalized)
|| user_normalized.contains(&app_normalized);
let matches = is_exact || is_fuzzy;
assert_eq!(
matches, should_match,
"Expected '{}' vs '{}' to match={}, but got match={}",

View File

@@ -1,24 +1,167 @@
use crate::{types::Rect, ComputerController};
use crate::{ComputerController, types::*};
use anyhow::Result;
use async_trait::async_trait;
use tesseract::Tesseract;
use uuid::Uuid;
pub struct WindowsController;
pub struct WindowsController {
// Placeholder for Windows-specific state
}
impl WindowsController {
pub fn new() -> Result<Self> {
tracing::warn!("Windows computer control not fully implemented");
Ok(Self)
Ok(Self {})
}
}
#[async_trait]
impl ComputerController for WindowsController {
async fn take_screenshot(
&self,
_path: &str,
_region: Option<Rect>,
_window_id: Option<&str>,
) -> Result<()> {
anyhow::bail!("Windows screenshot implementation not yet available")
async fn move_mouse(&self, _x: i32, _y: i32) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn click(&self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn double_click(&self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn type_text(&self, _text: &str) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn press_key(&self, _key: &str) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn list_windows(&self) -> Result<Vec<Window>> {
anyhow::bail!("Windows implementation not yet available")
}
async fn focus_window(&self, _window_id: &str) -> Result<()> {
anyhow::bail!("Windows implementation not yet available")
}
async fn get_window_bounds(&self, _window_id: &str) -> Result<Rect> {
anyhow::bail!("Windows implementation not yet available")
}
async fn find_element(&self, _selector: &ElementSelector) -> Result<Option<UIElement>> {
anyhow::bail!("Windows implementation not yet available")
}
async fn get_element_text(&self, _element_id: &str) -> Result<String> {
anyhow::bail!("Windows implementation not yet available")
}
async fn get_element_bounds(&self, _element_id: &str) -> Result<Rect> {
anyhow::bail!("Windows implementation not yet available")
}
async fn take_screenshot(&self, _path: &str, _region: Option<Rect>, _window_id: Option<&str>) -> Result<()> {
// Enforce that window_id must be provided
if _window_id.is_none() {
anyhow::bail!("window_id is required. You must specify which window to capture (e.g., 'Chrome', 'Terminal', 'Notepad'). Use list_windows to see available windows.");
}
anyhow::bail!("Windows implementation not yet available")
}
async fn extract_text_from_screen(&self, _region: Rect, _window_id: &str) -> Result<String> {
anyhow::bail!("Windows implementation not yet available")
}
async fn extract_text_from_image(&self, _path: &str) -> Result<OCRResult> {
// Check if tesseract is available on the system
let tesseract_check = std::process::Command::new("where")
.arg("tesseract")
.output();
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
To install tesseract on Windows:\n \
1. Download the installer from: https://github.com/UB-Mannheim/tesseract/wiki\n \
2. Run the installer and follow the instructions\n \
3. Add tesseract to your PATH environment variable\n \
4. Restart your terminal/command prompt\n\n\
After installation, restart your terminal and try again.");
}
// Initialize Tesseract
let tess = Tesseract::new(None, Some("eng"))
.map_err(|e| {
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
This usually means:\n1. Tesseract is not properly installed\n\
2. Language data files are missing\n\nTo fix:\n \
1. Reinstall tesseract from https://github.com/UB-Mannheim/tesseract/wiki\n \
2. Make sure to select 'Additional language data' during installation\n \
3. Ensure tesseract is in your PATH", e)
})?;
let text = tess.set_image(_path)
.map_err(|e| anyhow::anyhow!("Failed to load image '{}': {}", _path, e))?
.get_text()
.map_err(|e| anyhow::anyhow!("Failed to extract text from image: {}", e))?;
// Get confidence (simplified - would need more complex API calls for per-word confidence)
let confidence = 0.85; // Placeholder
Ok(OCRResult {
text,
confidence,
bounds: Rect { x: 0, y: 0, width: 0, height: 0 }, // Would need image dimensions
})
}
async fn find_text_on_screen(&self, _text: &str) -> Result<Option<Point>> {
// Check if tesseract is available on the system
let tesseract_check = std::process::Command::new("where")
.arg("tesseract")
.output();
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
To install tesseract on Windows:\n \
1. Download the installer from: https://github.com/UB-Mannheim/tesseract/wiki\n \
2. Run the installer and follow the instructions\n \
3. Add tesseract to your PATH environment variable\n \
4. Restart your terminal/command prompt\n\n\
After installation, restart your terminal and try again.");
}
// Take full screen screenshot
let temp_path = format!("C:\\\\Temp\\\\g3_ocr_search_{}.png", uuid::Uuid::new_v4());
self.take_screenshot(&temp_path, None, None).await?;
// Use Tesseract to find text with bounding boxes
let tess = Tesseract::new(None, Some("eng"))
.map_err(|e| {
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
This usually means:\n1. Tesseract is not properly installed\n\
2. Language data files are missing\n\nTo fix:\n \
1. Reinstall tesseract from https://github.com/UB-Mannheim/tesseract/wiki\n \
2. Make sure to select 'Additional language data' during installation\n \
3. Ensure tesseract is in your PATH", e)
})?;
let full_text = tess.set_image(temp_path.as_str())
.map_err(|e| anyhow::anyhow!("Failed to load screenshot: {}", e))?
.get_text()
.map_err(|e| anyhow::anyhow!("Failed to extract text from screen: {}", e))?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
// Simple text search - full implementation would use get_component_images
// to get bounding boxes for each word
if full_text.contains(_text) {
tracing::warn!("Text found but precise coordinates not available in simplified implementation");
Ok(Some(Point { x: 0, y: 0 }))
} else {
Ok(None)
}
}
}

View File

@@ -7,3 +7,13 @@ pub struct Rect {
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextLocation {
pub text: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub confidence: f32,
}

View File

@@ -1,424 +0,0 @@
use super::{WebDriverController, WebElement};
use anyhow::{Context, Result};
use async_trait::async_trait;
use fantoccini::{Client, ClientBuilder};
use serde_json::Value;
use std::time::Duration;
/// ChromeDriver WebDriver controller with headless support
pub struct ChromeDriver {
client: Client,
}
/// Stealth script to hide automation indicators from bot detection
const STEALTH_SCRIPT: &str = r#"
(function() {
'use strict';
// 1. Override navigator.webdriver to return undefined (like a real browser)
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
configurable: true
});
// 2. Add realistic chrome object that real Chrome has
if (!window.chrome) {
window.chrome = {};
}
window.chrome.runtime = {
connect: function() {},
sendMessage: function() {},
onMessage: { addListener: function() {} },
onConnect: { addListener: function() {} },
id: undefined
};
window.chrome.loadTimes = function() {
return {
commitLoadTime: Date.now() / 1000,
connectionInfo: 'h2',
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
firstPaintTime: Date.now() / 1000,
navigationType: 'Other',
npnNegotiatedProtocol: 'h2',
requestTime: Date.now() / 1000,
startLoadTime: Date.now() / 1000,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true,
wasNpnNegotiated: true
};
};
window.chrome.csi = function() {
return {
onloadT: Date.now(),
pageT: Date.now() - performance.timing.navigationStart,
startE: performance.timing.navigationStart,
tran: 15
};
};
// 3. Add realistic plugins array (headless Chrome has empty plugins)
Object.defineProperty(navigator, 'plugins', {
get: () => {
const plugins = [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }
];
plugins.item = (i) => plugins[i] || null;
plugins.namedItem = (name) => plugins.find(p => p.name === name) || null;
plugins.refresh = () => {};
Object.setPrototypeOf(plugins, PluginArray.prototype);
return plugins;
},
configurable: true
});
// 4. Add realistic mimeTypes
Object.defineProperty(navigator, 'mimeTypes', {
get: () => {
const mimeTypes = [
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
{ type: 'application/x-google-chrome-pdf', suffixes: 'pdf', description: 'Portable Document Format' }
];
mimeTypes.item = (i) => mimeTypes[i] || null;
mimeTypes.namedItem = (name) => mimeTypes.find(m => m.type === name) || null;
Object.setPrototypeOf(mimeTypes, MimeTypeArray.prototype);
return mimeTypes;
},
configurable: true
});
// 5. Fix permissions API to not reveal automation
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) => {
if (parameters.name === 'notifications') {
return Promise.resolve({ state: Notification.permission, onchange: null });
}
return originalQuery.call(window.navigator.permissions, parameters);
};
}
// 6. Override languages to have realistic values
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
configurable: true
});
// 7. Fix hardwareConcurrency (headless often shows different values)
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8,
configurable: true
});
// 8. Fix deviceMemory
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8,
configurable: true
});
// 9. Remove automation-related properties from window
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
// 10. Fix toString methods to not reveal native code modifications
const originalToString = Function.prototype.toString;
Function.prototype.toString = function() {
if (this === navigator.permissions.query) {
return 'function query() { [native code] }';
}
return originalToString.call(this);
};
})();
"#;
impl ChromeDriver {
/// Create a new ChromeDriver instance in headless mode
///
/// This will connect to ChromeDriver running on the default port (9515).
/// ChromeDriver must be installed and available in PATH.
pub async fn new_headless() -> Result<Self> {
Self::with_port_headless(9515).await
}
/// Create a new ChromeDriver instance with Chrome for Testing binary
pub async fn new_headless_with_binary(chrome_binary: &str) -> Result<Self> {
Self::with_port_headless_and_binary(9515, Some(chrome_binary)).await
}
/// Create a new ChromeDriver instance with a custom port in headless mode
pub async fn with_port_headless(port: u16) -> Result<Self> {
Self::with_port_headless_and_binary(port, None).await
}
/// Create a new ChromeDriver instance with a custom port and optional Chrome binary path
pub async fn with_port_headless_and_binary(port: u16, chrome_binary: Option<&str>) -> Result<Self> {
let url = format!("http://localhost:{}", port);
let mut caps = serde_json::Map::new();
caps.insert(
"browserName".to_string(),
Value::String("chrome".to_string()),
);
// Set up Chrome options for headless mode
let mut chrome_options = serde_json::Map::new();
chrome_options.insert(
"args".to_string(),
Value::Array(vec![
// Use a unique temp directory to avoid conflicts with running Chrome instances
Value::String(format!("--user-data-dir=/tmp/g3-chrome-{}", std::process::id())),
Value::String("--headless=new".to_string()),
Value::String("--disable-gpu".to_string()),
Value::String("--no-sandbox".to_string()),
Value::String("--disable-dev-shm-usage".to_string()),
Value::String("--window-size=1920,1080".to_string()),
Value::String("--disable-blink-features=AutomationControlled".to_string()),
// Stealth: Set a realistic user-agent (removes HeadlessChrome identifier)
Value::String("--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".to_string()),
// Stealth: Disable automation-related info bars
Value::String("--disable-infobars".to_string()),
// Stealth: Set realistic language
Value::String("--lang=en-US,en".to_string()),
// Stealth: Disable extensions to avoid detection
Value::String("--disable-extensions".to_string()),
]),
);
// Exclude automation switches to hide webdriver detection
chrome_options.insert(
"excludeSwitches".to_string(),
Value::Array(vec![
Value::String("enable-automation".to_string()),
]),
);
// Disable automation extension
chrome_options.insert(
"useAutomationExtension".to_string(),
Value::Bool(false),
);
// If a custom Chrome binary is specified, use it
if let Some(binary) = chrome_binary {
chrome_options.insert("binary".to_string(), Value::String(binary.to_string()));
}
caps.insert(
"goog:chromeOptions".to_string(),
Value::Object(chrome_options),
);
// Use a timeout for the connection attempt to avoid hanging indefinitely
let mut builder = ClientBuilder::native();
let connect_future = builder
.capabilities(caps)
.connect(&url);
let client = tokio::time::timeout(Duration::from_secs(30), connect_future)
.await
.context("Connection to ChromeDriver timed out after 30 seconds")?
.context("Failed to connect to ChromeDriver")?;
let driver = Self { client };
// Inject stealth script immediately after connection
// This ensures it runs before any navigation and on every new document
// Ignore errors as this is best-effort stealth
let _ = driver.client.execute(STEALTH_SCRIPT, vec![]).await;
Ok(driver)
}
/// Go back in browser history
pub async fn back(&mut self) -> Result<()> {
self.client.back().await?;
Ok(())
}
/// Go forward in browser history
pub async fn forward(&mut self) -> Result<()> {
self.client.forward().await?;
Ok(())
}
/// Refresh the current page
pub async fn refresh(&mut self) -> Result<()> {
self.client.refresh().await?;
Ok(())
}
/// Get all window handles
pub async fn window_handles(&mut self) -> Result<Vec<String>> {
let handles = self.client.windows().await?;
Ok(handles.into_iter().map(|h| h.into()).collect())
}
/// Switch to a window by handle
pub async fn switch_to_window(&mut self, handle: &str) -> Result<()> {
let window_handle: fantoccini::wd::WindowHandle = handle.to_string().try_into()?;
self.client.switch_to_window(window_handle).await?;
Ok(())
}
/// Get the current window handle
pub async fn current_window_handle(&mut self) -> Result<String> {
Ok(self.client.window().await?.into())
}
/// Close the current window
pub async fn close_window(&mut self) -> Result<()> {
self.client.close_window().await?;
Ok(())
}
/// Create a new window/tab
pub async fn new_window(&mut self, is_tab: bool) -> Result<String> {
let response = self.client.new_window(is_tab).await?;
Ok(response.handle.into())
}
/// Get cookies
pub async fn get_cookies(&mut self) -> Result<Vec<fantoccini::cookies::Cookie<'static>>> {
Ok(self.client.get_all_cookies().await?)
}
/// Add a cookie
pub async fn add_cookie(&mut self, cookie: fantoccini::cookies::Cookie<'static>) -> Result<()> {
self.client.add_cookie(cookie).await?;
Ok(())
}
/// Delete all cookies
pub async fn delete_all_cookies(&mut self) -> Result<()> {
self.client.delete_all_cookies().await?;
Ok(())
}
/// Wait for an element to appear (with timeout)
pub async fn wait_for_element(
&mut self,
selector: &str,
timeout: Duration,
) -> Result<WebElement> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
if let Ok(elem) = self.find_element(selector).await {
return Ok(elem);
}
if start.elapsed() >= timeout {
anyhow::bail!("Timeout waiting for element: {}", selector);
}
tokio::time::sleep(poll_interval).await;
}
}
/// Wait for an element to be visible (with timeout)
pub async fn wait_for_visible(
&mut self,
selector: &str,
timeout: Duration,
) -> Result<WebElement> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
if let Ok(elem) = self.find_element(selector).await {
if elem.is_displayed().await.unwrap_or(false) {
return Ok(elem);
}
}
if start.elapsed() >= timeout {
anyhow::bail!("Timeout waiting for element to be visible: {}", selector);
}
tokio::time::sleep(poll_interval).await;
}
}
}
#[async_trait]
impl WebDriverController for ChromeDriver {
async fn navigate(&mut self, url: &str) -> Result<()> {
self.client.goto(url).await?;
// Inject stealth script after navigation to hide automation indicators
// Ignore errors as some pages may have strict CSP
let _ = self.client.execute(STEALTH_SCRIPT, vec![]).await;
Ok(())
}
async fn current_url(&self) -> Result<String> {
Ok(self.client.current_url().await?.to_string())
}
async fn title(&self) -> Result<String> {
Ok(self.client.title().await?)
}
async fn find_element(&mut self, selector: &str) -> Result<WebElement> {
let elem = self
.client
.find(fantoccini::Locator::Css(selector))
.await
.context(format!(
"Failed to find element with selector: {}",
selector
))?;
Ok(WebElement { inner: elem })
}
async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>> {
let elems = self
.client
.find_all(fantoccini::Locator::Css(selector))
.await?;
Ok(elems
.into_iter()
.map(|inner| WebElement { inner })
.collect())
}
async fn execute_script(&mut self, script: &str, args: Vec<Value>) -> Result<Value> {
Ok(self.client.execute(script, args).await?)
}
async fn page_source(&self) -> Result<String> {
Ok(self.client.source().await?)
}
async fn screenshot(&mut self, path: &str) -> Result<()> {
let screenshot_data = self.client.screenshot().await?;
// Expand tilde in path
let expanded_path = shellexpand::tilde(path);
let path_str = expanded_path.as_ref();
// Create parent directories if needed
if let Some(parent) = std::path::Path::new(path_str).parent() {
std::fs::create_dir_all(parent)
.context("Failed to create parent directories for screenshot")?;
}
std::fs::write(path_str, screenshot_data).context("Failed to write screenshot to file")?;
Ok(())
}
async fn close(&mut self) -> Result<()> {
self.client.close_window().await?;
Ok(())
}
async fn quit(mut self) -> Result<()> {
self.client.close().await?;
Ok(())
}
}

View File

@@ -1,541 +0,0 @@
//! Chrome WebDriver diagnostics module
//!
//! Checks for common setup issues and provides detailed fix suggestions.
use std::path::PathBuf;
use std::process::Command;
/// Result of a diagnostic check
#[derive(Debug, Clone)]
pub struct DiagnosticResult {
pub name: String,
pub status: DiagnosticStatus,
pub message: String,
pub fix_suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiagnosticStatus {
Ok,
Warning,
Error,
}
/// Full diagnostic report for Chrome headless setup
#[derive(Debug)]
pub struct ChromeDiagnosticReport {
pub results: Vec<DiagnosticResult>,
pub chrome_version: Option<String>,
pub chromedriver_version: Option<String>,
pub chrome_path: Option<PathBuf>,
pub chromedriver_path: Option<PathBuf>,
pub config_chrome_binary: Option<String>,
}
impl ChromeDiagnosticReport {
/// Check if all diagnostics passed
pub fn all_ok(&self) -> bool {
self.results.iter().all(|r| r.status == DiagnosticStatus::Ok)
}
/// Check if there are any errors (not just warnings)
pub fn has_errors(&self) -> bool {
self.results.iter().any(|r| r.status == DiagnosticStatus::Error)
}
/// Format the report as a human-readable string
pub fn format_report(&self) -> String {
let mut output = String::new();
output.push_str("\n╔══════════════════════════════════════════════════════════════╗\n");
output.push_str("║ Chrome Headless Diagnostic Report ║\n");
output.push_str("╚══════════════════════════════════════════════════════════════╝\n\n");
// Summary section
output.push_str("📋 **Summary**\n");
if let Some(ref path) = self.chrome_path {
output.push_str(&format!(" Chrome: {}\n", path.display()));
}
if let Some(ref ver) = self.chrome_version {
output.push_str(&format!(" Chrome Version: {}\n", ver));
}
if let Some(ref path) = self.chromedriver_path {
output.push_str(&format!(" ChromeDriver: {}\n", path.display()));
}
if let Some(ref ver) = self.chromedriver_version {
output.push_str(&format!(" ChromeDriver Version: {}\n", ver));
}
if let Some(ref binary) = self.config_chrome_binary {
output.push_str(&format!(" Config chrome_binary: {}\n", binary));
}
output.push_str("\n");
// Results section
output.push_str("🔍 **Diagnostic Results**\n\n");
for result in &self.results {
let icon = match result.status {
DiagnosticStatus::Ok => "",
DiagnosticStatus::Warning => "⚠️",
DiagnosticStatus::Error => "",
};
output.push_str(&format!("{} **{}**\n", icon, result.name));
output.push_str(&format!(" {}\n", result.message));
if let Some(ref fix) = result.fix_suggestion {
output.push_str(&format!(" 💡 Fix: {}\n", fix));
}
output.push_str("\n");
}
// Overall status
if self.all_ok() {
output.push_str("🎉 **All checks passed!** Chrome headless is ready to use.\n");
} else if self.has_errors() {
output.push_str("\n🛠️ **Action Required**\n");
output.push_str(" Some issues need to be fixed before Chrome headless will work.\n");
output.push_str(" You can ask me to help fix these issues.\n");
} else {
output.push_str("\n⚠️ **Warnings Present**\n");
output.push_str(" Chrome headless may work, but there are potential issues.\n");
}
output
}
}
/// Run all Chrome headless diagnostics
pub fn run_diagnostics(config_chrome_binary: Option<&str>) -> ChromeDiagnosticReport {
let mut results = Vec::new();
let mut chrome_version = None;
let mut chromedriver_version = None;
let mut chrome_path = None;
let mut chromedriver_path = None;
// 1. Check for ChromeDriver in PATH
let chromedriver_check = check_chromedriver_installed();
if chromedriver_check.status == DiagnosticStatus::Ok {
chromedriver_path = find_chromedriver_path();
chromedriver_version = get_chromedriver_version();
}
results.push(chromedriver_check);
// 2. Check for Chrome installation
let chrome_check = check_chrome_installed(config_chrome_binary);
if chrome_check.status == DiagnosticStatus::Ok {
chrome_path = find_chrome_path(config_chrome_binary);
chrome_version = get_chrome_version(config_chrome_binary);
}
results.push(chrome_check);
// 3. Check version compatibility
if chrome_version.is_some() && chromedriver_version.is_some() {
results.push(check_version_compatibility(
chrome_version.as_deref(),
chromedriver_version.as_deref(),
));
}
// 4. Check config.toml chrome_binary setting
results.push(check_config_chrome_binary(config_chrome_binary, chrome_path.as_ref()));
// 5. Check for Chrome for Testing installation
results.push(check_chrome_for_testing());
// 6. Check ChromeDriver is executable (macOS quarantine)
if chromedriver_path.is_some() {
results.push(check_chromedriver_executable());
}
ChromeDiagnosticReport {
results,
chrome_version,
chromedriver_version,
chrome_path,
chromedriver_path,
config_chrome_binary: config_chrome_binary.map(String::from),
}
}
/// Check if ChromeDriver is installed and in PATH
fn check_chromedriver_installed() -> DiagnosticResult {
match Command::new("which").arg("chromedriver").output() {
Ok(output) if output.status.success() => {
DiagnosticResult {
name: "ChromeDriver Installation".to_string(),
status: DiagnosticStatus::Ok,
message: "ChromeDriver found in PATH".to_string(),
fix_suggestion: None,
}
}
_ => {
// Check common locations
let common_paths = [
dirs::home_dir().map(|h| h.join(".chrome-for-testing/chromedriver-mac-arm64/chromedriver")),
dirs::home_dir().map(|h| h.join(".chrome-for-testing/chromedriver-mac-x64/chromedriver")),
Some(PathBuf::from("/usr/local/bin/chromedriver")),
Some(PathBuf::from("/opt/homebrew/bin/chromedriver")),
];
for path in common_paths.iter().flatten() {
if path.exists() {
return DiagnosticResult {
name: "ChromeDriver Installation".to_string(),
status: DiagnosticStatus::Warning,
message: format!("ChromeDriver found at {} but not in PATH", path.display()),
fix_suggestion: Some(format!(
"Add to your shell config (~/.zshrc or ~/.bashrc):\nexport PATH=\"{}:$PATH\"",
path.parent().unwrap().display()
)),
};
}
}
DiagnosticResult {
name: "ChromeDriver Installation".to_string(),
status: DiagnosticStatus::Error,
message: "ChromeDriver not found".to_string(),
fix_suggestion: Some(
"Install ChromeDriver using one of these methods:\n\
1. Run: ./scripts/setup-chrome-for-testing.sh (recommended)\n\
2. Or: brew install chromedriver".to_string()
),
}
}
}
}
/// Check if Chrome is installed
fn check_chrome_installed(config_binary: Option<&str>) -> DiagnosticResult {
// First check configured binary
if let Some(binary) = config_binary {
if PathBuf::from(binary).exists() {
return DiagnosticResult {
name: "Chrome Installation".to_string(),
status: DiagnosticStatus::Ok,
message: format!("Chrome found at configured path: {}", binary),
fix_suggestion: None,
};
} else {
return DiagnosticResult {
name: "Chrome Installation".to_string(),
status: DiagnosticStatus::Error,
message: format!("Configured chrome_binary not found: {}", binary),
fix_suggestion: Some(
"Update chrome_binary in ~/.config/g3/config.toml to a valid Chrome path,\n\
or remove it to use system Chrome".to_string()
),
};
}
}
// Check common Chrome locations
let chrome_paths = get_chrome_search_paths();
for path in &chrome_paths {
if path.exists() {
return DiagnosticResult {
name: "Chrome Installation".to_string(),
status: DiagnosticStatus::Ok,
message: format!("Chrome found at: {}", path.display()),
fix_suggestion: None,
};
}
}
DiagnosticResult {
name: "Chrome Installation".to_string(),
status: DiagnosticStatus::Error,
message: "Chrome/Chromium not found".to_string(),
fix_suggestion: Some(
"Install Chrome using one of these methods:\n\
1. Run: ./scripts/setup-chrome-for-testing.sh (recommended)\n\
2. Download from: https://www.google.com/chrome/\n\
3. Or: brew install --cask google-chrome".to_string()
),
}
}
/// Check Chrome and ChromeDriver version compatibility
fn check_version_compatibility(
chrome_ver: Option<&str>,
chromedriver_ver: Option<&str>,
) -> DiagnosticResult {
let chrome_major = chrome_ver.and_then(extract_major_version);
let driver_major = chromedriver_ver.and_then(extract_major_version);
match (chrome_major, driver_major) {
(Some(cv), Some(dv)) if cv == dv => {
DiagnosticResult {
name: "Version Compatibility".to_string(),
status: DiagnosticStatus::Ok,
message: format!("Chrome ({}) and ChromeDriver ({}) versions match", cv, dv),
fix_suggestion: None,
}
}
(Some(cv), Some(dv)) => {
DiagnosticResult {
name: "Version Compatibility".to_string(),
status: DiagnosticStatus::Error,
message: format!(
"Version mismatch! Chrome is v{} but ChromeDriver is v{}",
cv, dv
),
fix_suggestion: Some(
"Fix version mismatch:\n\
1. Run: ./scripts/setup-chrome-for-testing.sh (installs matching versions)\n\
2. Or update ChromeDriver: brew upgrade chromedriver".to_string()
),
}
}
_ => {
DiagnosticResult {
name: "Version Compatibility".to_string(),
status: DiagnosticStatus::Warning,
message: "Could not determine version compatibility".to_string(),
fix_suggestion: None,
}
}
}
}
/// Check config.toml chrome_binary setting
fn check_config_chrome_binary(
config_binary: Option<&str>,
detected_chrome: Option<&PathBuf>,
) -> DiagnosticResult {
match (config_binary, detected_chrome) {
(Some(binary), _) if PathBuf::from(binary).exists() => {
DiagnosticResult {
name: "Config chrome_binary".to_string(),
status: DiagnosticStatus::Ok,
message: "chrome_binary is configured and valid".to_string(),
fix_suggestion: None,
}
}
(Some(binary), _) => {
DiagnosticResult {
name: "Config chrome_binary".to_string(),
status: DiagnosticStatus::Error,
message: format!("chrome_binary path does not exist: {}", binary),
fix_suggestion: Some(
"Update ~/.config/g3/config.toml with a valid chrome_binary path".to_string()
),
}
}
(None, Some(chrome)) => {
// Check if it's Chrome for Testing - recommend configuring it
let chrome_str = chrome.to_string_lossy();
if chrome_str.contains("chrome-for-testing") || chrome_str.contains("Chrome for Testing") {
DiagnosticResult {
name: "Config chrome_binary".to_string(),
status: DiagnosticStatus::Warning,
message: "Chrome for Testing detected but not configured in config.toml".to_string(),
fix_suggestion: Some(format!(
"Add to ~/.config/g3/config.toml:\n\
[webdriver]\n\
chrome_binary = \"{}\"",
chrome.display()
)),
}
} else {
DiagnosticResult {
name: "Config chrome_binary".to_string(),
status: DiagnosticStatus::Ok,
message: "Using system Chrome (no chrome_binary configured)".to_string(),
fix_suggestion: None,
}
}
}
(None, None) => {
DiagnosticResult {
name: "Config chrome_binary".to_string(),
status: DiagnosticStatus::Warning,
message: "No chrome_binary configured and no Chrome detected".to_string(),
fix_suggestion: Some(
"Install Chrome and optionally configure chrome_binary in config.toml".to_string()
),
}
}
}
}
/// Check for Chrome for Testing installation
fn check_chrome_for_testing() -> DiagnosticResult {
let cft_dir = dirs::home_dir().map(|h| h.join(".chrome-for-testing"));
match cft_dir {
Some(dir) if dir.exists() => {
// Check for both Chrome and ChromeDriver
let has_chrome = dir.join("chrome-mac-arm64").exists()
|| dir.join("chrome-mac-x64").exists();
let has_driver = dir.join("chromedriver-mac-arm64").exists()
|| dir.join("chromedriver-mac-x64").exists();
if has_chrome && has_driver {
DiagnosticResult {
name: "Chrome for Testing".to_string(),
status: DiagnosticStatus::Ok,
message: "Chrome for Testing is installed with matching ChromeDriver".to_string(),
fix_suggestion: None,
}
} else if has_chrome {
DiagnosticResult {
name: "Chrome for Testing".to_string(),
status: DiagnosticStatus::Warning,
message: "Chrome for Testing found but ChromeDriver is missing".to_string(),
fix_suggestion: Some(
"Run: ./scripts/setup-chrome-for-testing.sh to install matching ChromeDriver".to_string()
),
}
} else {
DiagnosticResult {
name: "Chrome for Testing".to_string(),
status: DiagnosticStatus::Warning,
message: "Chrome for Testing directory exists but is incomplete".to_string(),
fix_suggestion: Some(
"Run: ./scripts/setup-chrome-for-testing.sh to reinstall".to_string()
),
}
}
}
_ => {
DiagnosticResult {
name: "Chrome for Testing".to_string(),
status: DiagnosticStatus::Ok,
message: "Chrome for Testing not installed (using system Chrome)".to_string(),
fix_suggestion: None,
}
}
}
}
/// Check if ChromeDriver is executable (macOS quarantine issue)
fn check_chromedriver_executable() -> DiagnosticResult {
match Command::new("chromedriver").arg("--version").output() {
Ok(output) if output.status.success() => {
DiagnosticResult {
name: "ChromeDriver Executable".to_string(),
status: DiagnosticStatus::Ok,
message: "ChromeDriver is executable".to_string(),
fix_suggestion: None,
}
}
Ok(_) => {
DiagnosticResult {
name: "ChromeDriver Executable".to_string(),
status: DiagnosticStatus::Error,
message: "ChromeDriver found but failed to execute".to_string(),
fix_suggestion: Some(
"Remove macOS quarantine attribute:\n\
xattr -d com.apple.quarantine $(which chromedriver)".to_string()
),
}
}
Err(_) => {
DiagnosticResult {
name: "ChromeDriver Executable".to_string(),
status: DiagnosticStatus::Error,
message: "ChromeDriver not executable or not in PATH".to_string(),
fix_suggestion: Some(
"Ensure ChromeDriver is in PATH and executable:\n\
chmod +x $(which chromedriver)".to_string()
),
}
}
}
}
// Helper functions
fn find_chromedriver_path() -> Option<PathBuf> {
Command::new("which")
.arg("chromedriver")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()))
}
fn find_chrome_path(config_binary: Option<&str>) -> Option<PathBuf> {
if let Some(binary) = config_binary {
let path = PathBuf::from(binary);
if path.exists() {
return Some(path);
}
}
for path in get_chrome_search_paths() {
if path.exists() {
return Some(path);
}
}
None
}
fn get_chrome_search_paths() -> Vec<PathBuf> {
let mut paths = vec![
// macOS paths
PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
];
// Chrome for Testing paths
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".chrome-for-testing/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"));
paths.push(home.join(".chrome-for-testing/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"));
}
// Linux paths
paths.extend([
PathBuf::from("/usr/bin/google-chrome"),
PathBuf::from("/usr/bin/google-chrome-stable"),
PathBuf::from("/usr/bin/chromium"),
PathBuf::from("/usr/bin/chromium-browser"),
]);
paths
}
fn get_chromedriver_version() -> Option<String> {
Command::new("chromedriver")
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn get_chrome_version(config_binary: Option<&str>) -> Option<String> {
let chrome_path = find_chrome_path(config_binary)?;
Command::new(&chrome_path)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn extract_major_version(version_str: &str) -> Option<u32> {
// Extract version number from strings like:
// "Google Chrome 120.0.6099.109"
// "ChromeDriver 120.0.6099.109"
version_str
.split_whitespace()
.find(|s| s.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false))
.and_then(|v| v.split('.').next())
.and_then(|v| v.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_major_version() {
assert_eq!(extract_major_version("Google Chrome 120.0.6099.109"), Some(120));
assert_eq!(extract_major_version("ChromeDriver 120.0.6099.109"), Some(120));
assert_eq!(extract_major_version("120.0.6099.109"), Some(120));
assert_eq!(extract_major_version("invalid"), None);
}
}

View File

@@ -1,6 +1,4 @@
pub mod safari;
pub mod chrome;
pub mod diagnostics;
use anyhow::Result;
use async_trait::async_trait;
@@ -11,31 +9,31 @@ use serde_json::Value;
pub trait WebDriverController: Send + Sync {
/// Navigate to a URL
async fn navigate(&mut self, url: &str) -> Result<()>;
/// Get the current URL
async fn current_url(&self) -> Result<String>;
/// Get the page title
async fn title(&self) -> Result<String>;
/// Find an element by CSS selector
async fn find_element(&mut self, selector: &str) -> Result<WebElement>;
/// Find multiple elements by CSS selector
async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>>;
/// Execute JavaScript in the browser
async fn execute_script(&mut self, script: &str, args: Vec<Value>) -> Result<Value>;
/// Get the page source (HTML)
async fn page_source(&self) -> Result<String>;
/// Take a screenshot and save to path
async fn screenshot(&mut self, path: &str) -> Result<()>;
/// Close the current window/tab
async fn close(&mut self) -> Result<()>;
/// Quit the browser session
async fn quit(self) -> Result<()>;
}
@@ -51,69 +49,63 @@ impl WebElement {
self.inner.click().await?;
Ok(())
}
/// Send keys/text to the element
pub async fn send_keys(&mut self, text: &str) -> Result<()> {
self.inner.send_keys(text).await?;
Ok(())
}
/// Clear the element's content (for input fields)
pub async fn clear(&mut self) -> Result<()> {
self.inner.clear().await?;
Ok(())
}
/// Get the element's text content
pub async fn text(&self) -> Result<String> {
Ok(self.inner.text().await?)
}
/// Get an attribute value
pub async fn attr(&self, name: &str) -> Result<Option<String>> {
Ok(self.inner.attr(name).await?)
}
/// Get a property value
pub async fn prop(&self, name: &str) -> Result<Option<String>> {
Ok(self.inner.prop(name).await?)
}
/// Get the element's HTML
pub async fn html(&self, inner: bool) -> Result<String> {
Ok(self.inner.html(inner).await?)
}
/// Check if element is displayed
pub async fn is_displayed(&self) -> Result<bool> {
Ok(self.inner.is_displayed().await?)
}
/// Check if element is enabled
pub async fn is_enabled(&self) -> Result<bool> {
Ok(self.inner.is_enabled().await?)
}
/// Check if element is selected (for checkboxes/radio buttons)
pub async fn is_selected(&self) -> Result<bool> {
Ok(self.inner.is_selected().await?)
}
/// Find a child element by CSS selector
pub async fn find_element(&mut self, selector: &str) -> Result<WebElement> {
let elem = self.inner.find(fantoccini::Locator::Css(selector)).await?;
Ok(WebElement { inner: elem })
}
/// Find multiple child elements by CSS selector
pub async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>> {
let elems = self
.inner
.find_all(fantoccini::Locator::Css(selector))
.await?;
Ok(elems
.into_iter()
.map(|inner| WebElement { inner })
.collect())
let elems = self.inner.find_all(fantoccini::Locator::Css(selector)).await?;
Ok(elems.into_iter().map(|inner| WebElement { inner }).collect())
}
}

View File

@@ -12,10 +12,10 @@ pub struct SafariDriver {
impl SafariDriver {
/// Create a new SafariDriver instance
///
///
/// This will connect to SafariDriver running on the default port (4444).
/// Make sure to enable "Allow Remote Automation" in Safari's Develop menu first.
///
///
/// You can start SafariDriver manually with:
/// ```bash
/// /usr/bin/safaridriver --enable
@@ -23,134 +23,125 @@ impl SafariDriver {
pub async fn new() -> Result<Self> {
Self::with_port(4444).await
}
/// Create a new SafariDriver instance with a custom port
pub async fn with_port(port: u16) -> Result<Self> {
let url = format!("http://localhost:{}", port);
let mut caps = serde_json::Map::new();
caps.insert(
"browserName".to_string(),
Value::String("safari".to_string()),
);
caps.insert("browserName".to_string(), Value::String("safari".to_string()));
let client = ClientBuilder::native()
.capabilities(caps)
.connect(&url)
.await
.context("Failed to connect to SafariDriver. Make sure SafariDriver is running and 'Allow Remote Automation' is enabled in Safari's Develop menu.")?;
Ok(Self { client })
}
/// Go back in browser history
pub async fn back(&mut self) -> Result<()> {
self.client.back().await?;
Ok(())
}
/// Go forward in browser history
pub async fn forward(&mut self) -> Result<()> {
self.client.forward().await?;
Ok(())
}
/// Refresh the current page
pub async fn refresh(&mut self) -> Result<()> {
self.client.refresh().await?;
Ok(())
}
/// Get all window handles
pub async fn window_handles(&mut self) -> Result<Vec<String>> {
let handles = self.client.windows().await?;
Ok(handles.into_iter().map(|h| h.into()).collect())
Ok(handles.into_iter()
.map(|h| h.into())
.collect())
}
/// Switch to a window by handle
pub async fn switch_to_window(&mut self, handle: &str) -> Result<()> {
let window_handle: fantoccini::wd::WindowHandle = handle.to_string().try_into()?;
self.client.switch_to_window(window_handle).await?;
Ok(())
}
/// Get the current window handle
pub async fn current_window_handle(&mut self) -> Result<String> {
Ok(self.client.window().await?.into())
}
/// Close the current window
pub async fn close_window(&mut self) -> Result<()> {
self.client.close_window().await?;
Ok(())
}
/// Create a new window/tab
pub async fn new_window(&mut self, is_tab: bool) -> Result<String> {
let window_type = if is_tab { "tab" } else { "window" };
let response = self.client.new_window(window_type == "tab").await?;
Ok(response.handle.into())
}
/// Get cookies
pub async fn get_cookies(&mut self) -> Result<Vec<fantoccini::cookies::Cookie<'static>>> {
Ok(self.client.get_all_cookies().await?)
}
/// Add a cookie
pub async fn add_cookie(&mut self, cookie: fantoccini::cookies::Cookie<'static>) -> Result<()> {
self.client.add_cookie(cookie).await?;
Ok(())
}
/// Delete all cookies
pub async fn delete_all_cookies(&mut self) -> Result<()> {
self.client.delete_all_cookies().await?;
Ok(())
}
/// Wait for an element to appear (with timeout)
pub async fn wait_for_element(
&mut self,
selector: &str,
timeout: Duration,
) -> Result<WebElement> {
pub async fn wait_for_element(&mut self, selector: &str, timeout: Duration) -> Result<WebElement> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
if let Ok(elem) = self.find_element(selector).await {
return Ok(elem);
}
if start.elapsed() >= timeout {
anyhow::bail!("Timeout waiting for element: {}", selector);
}
tokio::time::sleep(poll_interval).await;
}
}
/// Wait for an element to be visible (with timeout)
pub async fn wait_for_visible(
&mut self,
selector: &str,
timeout: Duration,
) -> Result<WebElement> {
pub async fn wait_for_visible(&mut self, selector: &str, timeout: Duration) -> Result<WebElement> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
if let Ok(elem) = self.find_element(selector).await {
if elem.is_displayed().await.unwrap_or(false) {
return Ok(elem);
}
}
if start.elapsed() >= timeout {
anyhow::bail!("Timeout waiting for element to be visible: {}", selector);
}
tokio::time::sleep(poll_interval).await;
}
}
@@ -162,69 +153,58 @@ impl WebDriverController for SafariDriver {
self.client.goto(url).await?;
Ok(())
}
async fn current_url(&self) -> Result<String> {
Ok(self.client.current_url().await?.to_string())
}
async fn title(&self) -> Result<String> {
Ok(self.client.title().await?)
}
async fn find_element(&mut self, selector: &str) -> Result<WebElement> {
let elem = self
.client
.find(fantoccini::Locator::Css(selector))
.await
.context(format!(
"Failed to find element with selector: {}",
selector
))?;
let elem = self.client.find(fantoccini::Locator::Css(selector)).await
.context(format!("Failed to find element with selector: {}", selector))?;
Ok(WebElement { inner: elem })
}
async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>> {
let elems = self
.client
.find_all(fantoccini::Locator::Css(selector))
.await?;
Ok(elems
.into_iter()
.map(|inner| WebElement { inner })
.collect())
let elems = self.client.find_all(fantoccini::Locator::Css(selector)).await?;
Ok(elems.into_iter().map(|inner| WebElement { inner }).collect())
}
async fn execute_script(&mut self, script: &str, args: Vec<Value>) -> Result<Value> {
Ok(self.client.execute(script, args).await?)
}
async fn page_source(&self) -> Result<String> {
Ok(self.client.source().await?)
}
async fn screenshot(&mut self, path: &str) -> Result<()> {
let screenshot_data = self.client.screenshot().await?;
// Expand tilde in path
let expanded_path = shellexpand::tilde(path);
let path_str = expanded_path.as_ref();
// Create parent directories if needed
if let Some(parent) = std::path::Path::new(path_str).parent() {
std::fs::create_dir_all(parent)
.context("Failed to create parent directories for screenshot")?;
}
std::fs::write(path_str, screenshot_data).context("Failed to write screenshot to file")?;
std::fs::write(path_str, screenshot_data)
.context("Failed to write screenshot to file")?;
Ok(())
}
async fn close(&mut self) -> Result<()> {
self.client.close_window().await?;
Ok(())
}
async fn quit(mut self) -> Result<()> {
self.client.close().await?;
Ok(())

View File

@@ -3,35 +3,15 @@ use g3_computer_control::*;
#[tokio::test]
async fn test_screenshot() {
let controller = create_controller().expect("Failed to create controller");
// Test that screenshot without window_id fails with appropriate error
// Take screenshot
let path = "/tmp/test_screenshot.png";
let result = controller.take_screenshot(path, None, None).await;
assert!(
result.is_err(),
"Expected error when window_id is not provided"
);
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("window_id is required"),
"Expected error message about window_id being required, got: {}",
error_msg
);
}
#[tokio::test]
async fn test_screenshot_with_window() {
let controller = create_controller().expect("Failed to create controller");
// Take screenshot of Finder (should always be available on macOS)
let path = "/tmp/test_screenshot_finder.png";
let result = controller.take_screenshot(path, None, Some("Finder")).await;
// This test may fail if Finder is not running, so we just check it doesn't panic
// and returns a proper Result
let _ = result; // Don't assert success since Finder might not be visible
assert!(result.is_ok(), "Failed to take screenshot: {:?}", result.err());
// Verify file exists
assert!(std::path::Path::new(path).exists(), "Screenshot file was not created");
// Clean up
let _ = std::fs::remove_file(path);
}

View File

@@ -0,0 +1,24 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "VisionBridge",
platforms: [
.macOS(.v11)
],
products: [
.library(
name: "VisionBridge",
type: .dynamic,
targets: ["VisionBridge"]
),
],
targets: [
.target(
name: "VisionBridge",
dependencies: [],
path: "Sources/VisionBridge",
publicHeadersPath: "."
),
]
)

View File

@@ -0,0 +1,39 @@
#ifndef VisionBridge_h
#define VisionBridge_h
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Text box structure for FFI
typedef struct {
const char* text;
uint32_t text_len;
int32_t x;
int32_t y;
int32_t width;
int32_t height;
float confidence;
} VisionTextBox;
// Recognize text in an image and return bounding boxes
// Returns true on success, false on failure
// Caller must free the returned boxes using vision_free_boxes
bool vision_recognize_text(
const char* image_path,
uint32_t image_path_len,
VisionTextBox** out_boxes,
uint32_t* out_count
);
// Free memory allocated by vision_recognize_text
void vision_free_boxes(VisionTextBox* boxes, uint32_t count);
#ifdef __cplusplus
}
#endif
#endif /* VisionBridge_h */

View File

@@ -0,0 +1,145 @@
import Foundation
import Vision
import AppKit
import CoreGraphics
// MARK: - C Bridge Functions
@_cdecl("vision_recognize_text")
public func vision_recognize_text(
_ imagePath: UnsafePointer<CChar>,
_ imagePathLen: UInt32,
_ outBoxes: UnsafeMutablePointer<UnsafeMutableRawPointer?>,
_ outCount: UnsafeMutablePointer<UInt32>
) -> Bool {
// Convert C string to Swift String
guard let pathData = Data(bytes: imagePath, count: Int(imagePathLen)).withUnsafeBytes({
String(bytes: $0, encoding: .utf8)
}) else {
return false
}
let path = pathData.trimmingCharacters(in: .whitespaces)
// Load image
guard let image = NSImage(contentsOfFile: path),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return false
}
// Perform OCR
var textBoxes: [CTextBox] = []
let semaphore = DispatchSemaphore(value: 0)
var success = false
let request = VNRecognizeTextRequest { request, error in
defer { semaphore.signal() }
if let error = error {
print("Vision OCR error: \(error.localizedDescription)")
return
}
guard let observations = request.results as? [VNRecognizedTextObservation] else {
return
}
let imageSize = CGSize(width: cgImage.width, height: cgImage.height)
for observation in observations {
guard let candidate = observation.topCandidates(1).first else { continue }
let text = candidate.string
let boundingBox = observation.boundingBox
// Convert normalized coordinates (bottom-left origin) to pixel coordinates (top-left origin)
let x = Int32(boundingBox.origin.x * imageSize.width)
let y = Int32((1.0 - boundingBox.origin.y - boundingBox.height) * imageSize.height)
let width = Int32(boundingBox.width * imageSize.width)
let height = Int32(boundingBox.height * imageSize.height)
// Allocate C string for text
let cString = strdup(text)
textBoxes.append(CTextBox(
text: cString,
text_len: UInt32(text.utf8.count),
x: x,
y: y,
width: width,
height: height,
confidence: observation.confidence
))
}
success = true
}
// Configure request for best accuracy
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US"]
// Perform request
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
print("Vision request failed: \(error.localizedDescription)")
return false
}
// Wait for completion
semaphore.wait()
if !success {
return false
}
// Allocate array for results
let boxesPtr = UnsafeMutablePointer<CTextBox>.allocate(capacity: textBoxes.count)
for (index, box) in textBoxes.enumerated() {
boxesPtr[index] = box
}
outBoxes.pointee = UnsafeMutableRawPointer(boxesPtr)
outCount.pointee = UInt32(textBoxes.count)
return true
}
@_cdecl("vision_free_boxes")
public func vision_free_boxes(
_ boxes: UnsafeMutableRawPointer,
_ count: UInt32
) {
let typedBoxes = boxes.assumingMemoryBound(to: CTextBox.self)
for i in 0..<Int(count) {
if let text = typedBoxes[i].text {
free(UnsafeMutableRawPointer(mutating: text))
}
}
typedBoxes.deallocate()
}
// MARK: - C-Compatible Structure
public struct CTextBox {
public let text: UnsafePointer<CChar>?
public let text_len: UInt32
public let x: Int32
public let y: Int32
public let width: Int32
public let height: Int32
public let confidence: Float
public init(text: UnsafePointer<CChar>?, text_len: UInt32, x: Int32, y: Int32, width: Int32, height: Int32, confidence: Float) {
self.text = text
self.text_len = text_len
self.x = x
self.y = y
self.width = width
self.height = height
self.confidence = confidence
}
}

View File

@@ -15,4 +15,3 @@ dirs = "5.0"
[dev-dependencies]
tempfile = "3.8"
serde_json = { workspace = true }

View File

@@ -1,54 +1,25 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use anyhow::Result;
use std::path::Path;
/// Main configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub providers: ProvidersConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub computer_control: ComputerControlConfig,
#[serde(default)]
pub webdriver: WebDriverConfig,
pub macax: MacAxConfig,
}
/// Provider configuration with named configs per provider type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
/// Default provider in format "<provider_type>.<config_name>"
pub openai: Option<OpenAIConfig>,
pub anthropic: Option<AnthropicConfig>,
pub databricks: Option<DatabricksConfig>,
pub embedded: Option<EmbeddedConfig>,
pub default_provider: String,
/// Provider for planner mode (optional, falls back to default_provider)
pub planner: Option<String>,
/// Provider for coach in autonomous mode (optional, falls back to default_provider)
pub coach: Option<String>,
/// Provider for player in autonomous mode (optional, falls back to default_provider)
pub player: Option<String>,
/// Named Anthropic provider configs
#[serde(default)]
pub anthropic: HashMap<String, AnthropicConfig>,
/// Named OpenAI provider configs
#[serde(default)]
pub openai: HashMap<String, OpenAIConfig>,
/// Named Databricks provider configs
#[serde(default)]
pub databricks: HashMap<String, DatabricksConfig>,
/// Named embedded provider configs
#[serde(default)]
pub embedded: HashMap<String, EmbeddedConfig>,
/// Multiple named OpenAI-compatible providers (e.g., openrouter, groq, etc.)
#[serde(default)]
pub openai_compatible: HashMap<String, OpenAIConfig>,
pub coach: Option<String>, // Provider to use for coach in autonomous mode
pub player: Option<String>, // Provider to use for player in autonomous mode
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -66,140 +37,76 @@ pub struct AnthropicConfig {
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub cache_config: Option<String>,
pub enable_1m_context: Option<bool>,
pub thinking_budget_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabricksConfig {
pub host: String,
pub token: Option<String>,
pub token: Option<String>, // Optional - will use OAuth if not provided
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub use_oauth: Option<bool>,
pub use_oauth: Option<bool>, // Default to true if token not provided
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddedConfig {
pub model_path: String,
pub model_type: String,
pub model_type: String, // e.g., "llama", "mistral", "codellama"
pub context_length: Option<u32>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub gpu_layers: Option<u32>,
pub threads: Option<u32>,
pub gpu_layers: Option<u32>, // Number of layers to offload to GPU
pub threads: Option<u32>, // Number of CPU threads to use
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub max_context_length: Option<u32>,
#[serde(default = "default_fallback_max_tokens")]
pub fallback_default_max_tokens: usize,
#[serde(default = "default_true")]
pub max_context_length: usize,
pub enable_streaming: bool,
#[serde(default = "default_timeout_seconds")]
pub timeout_seconds: u64,
#[serde(default = "default_true")]
pub auto_compact: bool,
#[serde(default = "default_max_retry_attempts")]
pub max_retry_attempts: u32,
#[serde(default = "default_autonomous_max_retry_attempts")]
pub autonomous_max_retry_attempts: u32,
#[serde(default = "default_check_todo_staleness")]
pub check_todo_staleness: bool,
}
fn default_fallback_max_tokens() -> usize {
32000
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_timeout_seconds() -> u64 {
120
}
fn default_max_retry_attempts() -> u32 {
3
}
fn default_autonomous_max_retry_attempts() -> u32 {
6
}
fn default_max_actions_per_second() -> u32 {
5
}
fn default_check_todo_staleness() -> bool {
true
}
fn default_safari_port() -> u16 {
4444
}
fn default_chrome_port() -> u16 {
9515
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputerControlConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_false")]
pub require_confirmation: bool,
#[serde(default = "default_max_actions_per_second")]
pub max_actions_per_second: u32,
}
/// Browser type for WebDriver
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum WebDriverBrowser {
Safari,
#[default]
#[serde(rename = "chrome-headless")]
ChromeHeadless,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebDriverConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_safari_port")]
pub safari_port: u16,
#[serde(default = "default_chrome_port")]
pub chrome_port: u16,
#[serde(default)]
/// Optional path to Chrome binary (e.g., Chrome for Testing)
/// If not set, ChromeDriver will use the default Chrome installation
pub chrome_binary: Option<String>,
#[serde(default)]
/// Optional path to ChromeDriver binary
/// If not set, looks for 'chromedriver' in PATH
pub chromedriver_binary: Option<String>,
#[serde(default)]
pub browser: WebDriverBrowser,
}
impl Default for AgentConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacAxConfig {
pub enabled: bool,
}
impl Default for MacAxConfig {
fn default() -> Self {
Self {
max_context_length: None,
fallback_default_max_tokens: 32000,
enable_streaming: true,
timeout_seconds: 120,
auto_compact: true,
max_retry_attempts: 3,
autonomous_max_retry_attempts: 6,
check_todo_staleness: true,
enabled: false,
}
}
}
impl Default for WebDriverConfig {
fn default() -> Self {
Self {
enabled: false,
safari_port: 4444,
}
}
}
impl Default for ComputerControlConfig {
fn default() -> Self {
Self {
enabled: true,
require_confirmation: false,
enabled: false, // Disabled by default for safety
require_confirmation: true,
max_actions_per_second: 5,
}
}
@@ -207,95 +114,59 @@ impl Default for ComputerControlConfig {
impl Default for Config {
fn default() -> Self {
let mut databricks_configs = HashMap::new();
databricks_configs.insert(
"default".to_string(),
DatabricksConfig {
host: "https://your-workspace.cloud.databricks.com".to_string(),
token: None,
model: "databricks-claude-sonnet-4".to_string(),
max_tokens: Some(4096),
temperature: Some(0.1),
use_oauth: Some(true),
},
);
Self {
providers: ProvidersConfig {
default_provider: "databricks.default".to_string(),
planner: None,
coach: None,
player: None,
anthropic: HashMap::new(),
openai: HashMap::new(),
databricks: databricks_configs,
embedded: HashMap::new(),
openai_compatible: HashMap::new(),
openai: None,
anthropic: None,
databricks: Some(DatabricksConfig {
host: "https://your-workspace.cloud.databricks.com".to_string(),
token: None, // Will use OAuth by default
model: "databricks-claude-sonnet-4".to_string(),
max_tokens: Some(4096),
temperature: Some(0.1),
use_oauth: Some(true),
}),
embedded: None,
default_provider: "databricks".to_string(),
coach: None, // Will use default_provider if not specified
player: None, // Will use default_provider if not specified
},
agent: AgentConfig {
max_context_length: None,
fallback_default_max_tokens: 32000,
max_context_length: 8192,
enable_streaming: true,
timeout_seconds: 60,
auto_compact: true,
max_retry_attempts: 3,
autonomous_max_retry_attempts: 6,
check_todo_staleness: true,
},
computer_control: ComputerControlConfig::default(),
webdriver: WebDriverConfig::default(),
macax: MacAxConfig::default(),
}
}
}
/// Error message for old config format
const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
Please update your configuration to use the new provider format:
```toml
[providers]
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
planner = "anthropic.planner" # Optional: specific provider for planner
coach = "anthropic.default" # Optional: specific provider for coach
player = "openai.player" # Optional: specific provider for player
# Named configs per provider type
[providers.anthropic.default]
api_key = "your-api-key"
model = "claude-sonnet-4-5"
max_tokens = 64000
[providers.anthropic.planner]
api_key = "your-api-key"
model = "claude-opus-4-5"
thinking_budget_tokens = 16000
[providers.openai.player]
api_key = "your-api-key"
model = "gpt-5"
```
Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
If not specified, they fall back to `default_provider`."#;
impl Config {
pub fn load(config_path: Option<&str>) -> Result<Self> {
// Check if any config file exists
let config_exists = if let Some(path) = config_path {
Path::new(path).exists()
} else {
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
// Check default locations
let default_paths = [
"./g3.toml",
"~/.config/g3/config.toml",
"~/.g3.toml",
];
default_paths.iter().any(|path| {
let expanded_path = shellexpand::tilde(path);
Path::new(expanded_path.as_ref()).exists()
})
};
// If no config exists, create and save a default config
// If no config exists, create and save a default Databricks config
if !config_exists {
let default_config = Self::default();
let databricks_config = Self::default();
// Save to default location
let config_dir = dirs::home_dir()
.map(|mut path| {
path.push(".config");
@@ -303,388 +174,214 @@ impl Config {
path
})
.unwrap_or_else(|| std::path::PathBuf::from("."));
// Create directory if it doesn't exist
std::fs::create_dir_all(&config_dir).ok();
let config_file = config_dir.join("config.toml");
if let Err(e) = default_config.save(config_file.to_str().unwrap()) {
if let Err(e) = databricks_config.save(config_file.to_str().unwrap()) {
eprintln!("Warning: Could not save default config: {}", e);
} else {
println!(
"Created default configuration at: {}",
config_file.display()
);
println!("Created default Databricks configuration at: {}", config_file.display());
}
return Ok(default_config);
return Ok(databricks_config);
}
// Load config from file
let config_path_to_load = if let Some(path) = config_path {
Some(path.to_string())
// Existing config loading logic
let mut settings = config::Config::builder();
// Load default configuration
settings = settings.add_source(config::Config::try_from(&Config::default())?);
// Load from config file if provided
if let Some(path) = config_path {
if Path::new(path).exists() {
settings = settings.add_source(config::File::with_name(path));
}
} else {
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
default_paths.iter().find_map(|path| {
// Try to load from default locations
let default_paths = [
"./g3.toml",
"~/.config/g3/config.toml",
"~/.g3.toml",
];
for path in &default_paths {
let expanded_path = shellexpand::tilde(path);
if Path::new(expanded_path.as_ref()).exists() {
Some(expanded_path.to_string())
} else {
None
settings = settings.add_source(config::File::with_name(expanded_path.as_ref()));
break;
}
})
};
if let Some(path) = config_path_to_load {
// Read and parse the config file
let config_content = std::fs::read_to_string(&path)?;
// Check for old format (direct provider config without named configs)
if Self::is_old_format(&config_content) {
anyhow::bail!("{}", OLD_CONFIG_FORMAT_ERROR);
}
let config: Config = toml::from_str(&config_content)?;
// Validate the default_provider format
config.validate_provider_reference(&config.providers.default_provider)?;
return Ok(config);
}
Ok(Self::default())
// Override with environment variables
settings = settings.add_source(
config::Environment::with_prefix("G3")
.separator("_")
);
let config = settings.build()?.try_deserialize()?;
Ok(config)
}
/// Check if the config content uses the old format
fn is_old_format(content: &str) -> bool {
// Old format has [providers.anthropic] with api_key directly
// New format has [providers.anthropic.<name>] with api_key
// Parse as TOML value to inspect structure
if let Ok(value) = content.parse::<toml::Value>() {
if let Some(providers) = value.get("providers") {
if let Some(providers_table) = providers.as_table() {
// Check anthropic section
if let Some(anthropic) = providers_table.get("anthropic") {
if let Some(anthropic_table) = anthropic.as_table() {
// If anthropic has api_key directly, it's old format
if anthropic_table.contains_key("api_key") {
return true;
}
}
}
// Check databricks section
if let Some(databricks) = providers_table.get("databricks") {
if let Some(databricks_table) = databricks.as_table() {
// If databricks has host directly, it's old format
if databricks_table.contains_key("host") {
return true;
}
}
}
// Check openai section
if let Some(openai) = providers_table.get("openai") {
if let Some(openai_table) = openai.as_table() {
// If openai has api_key directly, it's old format
if openai_table.contains_key("api_key") {
return true;
}
}
}
}
}
#[allow(dead_code)]
fn default_qwen_config() -> Self {
Self {
providers: ProvidersConfig {
openai: None,
anthropic: None,
databricks: None,
embedded: Some(EmbeddedConfig {
model_path: "~/.cache/g3/models/qwen2.5-7b-instruct-q3_k_m.gguf".to_string(),
model_type: "qwen".to_string(),
context_length: Some(32768), // Qwen2.5 supports 32k context
max_tokens: Some(2048),
temperature: Some(0.1),
gpu_layers: Some(32),
threads: Some(8),
}),
default_provider: "embedded".to_string(),
coach: None, // Will use default_provider if not specified
player: None, // Will use default_provider if not specified
},
agent: AgentConfig {
max_context_length: 8192,
enable_streaming: true,
timeout_seconds: 60,
},
computer_control: ComputerControlConfig::default(),
webdriver: WebDriverConfig::default(),
macax: MacAxConfig::default(),
}
false
}
/// Validate a provider reference (format: "<provider_type>.<config_name>")
fn validate_provider_reference(&self, reference: &str) -> Result<()> {
let parts: Vec<&str> = reference.split('.').collect();
if parts.len() != 2 {
anyhow::bail!(
"Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
reference
);
}
let (provider_type, config_name) = (parts[0], parts[1]);
match provider_type {
"anthropic" => {
if !self.providers.anthropic.contains_key(config_name) {
anyhow::bail!(
"Provider config 'anthropic.{}' not found. Available: {:?}",
config_name,
self.providers.anthropic.keys().collect::<Vec<_>>()
);
}
}
"openai" => {
if !self.providers.openai.contains_key(config_name) {
anyhow::bail!(
"Provider config 'openai.{}' not found. Available: {:?}",
config_name,
self.providers.openai.keys().collect::<Vec<_>>()
);
}
}
"databricks" => {
if !self.providers.databricks.contains_key(config_name) {
anyhow::bail!(
"Provider config 'databricks.{}' not found. Available: {:?}",
config_name,
self.providers.databricks.keys().collect::<Vec<_>>()
);
}
}
"embedded" => {
if !self.providers.embedded.contains_key(config_name) {
anyhow::bail!(
"Provider config 'embedded.{}' not found. Available: {:?}",
config_name,
self.providers.embedded.keys().collect::<Vec<_>>()
);
}
}
_ => {
// Check openai_compatible providers
if !self.providers.openai_compatible.contains_key(provider_type) {
anyhow::bail!(
"Unknown provider type '{}'. Valid types: anthropic, openai, databricks, embedded, or openai_compatible names",
provider_type
);
}
}
}
Ok(())
}
/// Parse a provider reference into (provider_type, config_name)
pub fn parse_provider_reference(reference: &str) -> Result<(String, String)> {
let parts: Vec<&str> = reference.split('.').collect();
if parts.len() != 2 {
anyhow::bail!(
"Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
reference
);
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
pub fn save(&self, path: &str) -> Result<()> {
let toml_string = toml::to_string_pretty(self)?;
std::fs::write(path, toml_string)?;
Ok(())
}
pub fn load_with_overrides(
config_path: Option<&str>,
provider_override: Option<String>,
model_override: Option<String>,
) -> Result<Self> {
// Load the base configuration
let mut config = Self::load(config_path)?;
// Apply provider override
if let Some(provider) = provider_override {
// If provider doesn't contain '.', assume '.default'
let provider = if provider.contains('.') {
provider
} else {
format!("{}.default", provider)
};
config.validate_provider_reference(&provider)?;
config.providers.default_provider = provider;
}
// Apply model override to the active provider
if let Some(model) = model_override {
let (provider_type, config_name) =
Self::parse_provider_reference(&config.providers.default_provider)?;
match provider_type.as_str() {
match config.providers.default_provider.as_str() {
"anthropic" => {
if let Some(ref mut anthropic_config) =
config.providers.anthropic.get_mut(&config_name)
{
anthropic_config.model = model;
if let Some(ref mut anthropic) = config.providers.anthropic {
anthropic.model = model;
} else {
return Err(anyhow::anyhow!(
"Provider config 'anthropic.{}' not found.",
config_name
"Provider 'anthropic' is not configured. Please add anthropic configuration to your config file."
));
}
}
"databricks" => {
if let Some(ref mut databricks_config) =
config.providers.databricks.get_mut(&config_name)
{
databricks_config.model = model;
if let Some(ref mut databricks) = config.providers.databricks {
databricks.model = model;
} else {
return Err(anyhow::anyhow!(
"Provider config 'databricks.{}' not found.",
config_name
"Provider 'databricks' is not configured. Please add databricks configuration to your config file."
));
}
}
"embedded" => {
if let Some(ref mut embedded_config) =
config.providers.embedded.get_mut(&config_name)
{
embedded_config.model_path = model;
if let Some(ref mut embedded) = config.providers.embedded {
embedded.model_path = model;
} else {
return Err(anyhow::anyhow!(
"Provider config 'embedded.{}' not found.",
config_name
"Provider 'embedded' is not configured. Please add embedded configuration to your config file."
));
}
}
"openai" => {
if let Some(ref mut openai_config) =
config.providers.openai.get_mut(&config_name)
{
openai_config.model = model;
if let Some(ref mut openai) = config.providers.openai {
openai.model = model;
} else {
return Err(anyhow::anyhow!(
"Provider config 'openai.{}' not found.",
config_name
"Provider 'openai' is not configured. Please add openai configuration to your config file."
));
}
}
_ => {
// Check openai_compatible
if let Some(ref mut compat_config) =
config.providers.openai_compatible.get_mut(&provider_type)
{
compat_config.model = model;
} else {
return Err(anyhow::anyhow!("Unknown provider type: {}", provider_type));
}
}
_ => return Err(anyhow::anyhow!("Unknown provider: {}",
config.providers.default_provider)),
}
}
Ok(config)
}
/// Get the provider reference for planner mode
pub fn get_planner_provider(&self) -> &str {
self.providers
.planner
.as_deref()
.unwrap_or(&self.providers.default_provider)
}
/// Get the provider reference for coach mode in autonomous execution
/// Get the provider to use for coach mode in autonomous execution
pub fn get_coach_provider(&self) -> &str {
self.providers
.coach
self.providers.coach
.as_deref()
.unwrap_or(&self.providers.default_provider)
}
/// Get the provider reference for player mode in autonomous execution
/// Get the provider to use for player mode in autonomous execution
pub fn get_player_provider(&self) -> &str {
self.providers
.player
self.providers.player
.as_deref()
.unwrap_or(&self.providers.default_provider)
}
/// Create a copy of the config with a different default provider
pub fn with_provider_override(&self, provider_ref: &str) -> Result<Self> {
pub fn with_provider_override(&self, provider: &str) -> Result<Self> {
// Validate that the provider is configured
self.validate_provider_reference(provider_ref)?;
match provider {
"anthropic" if self.providers.anthropic.is_none() => {
return Err(anyhow::anyhow!(
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
provider, provider
));
}
"databricks" if self.providers.databricks.is_none() => {
return Err(anyhow::anyhow!(
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
provider, provider
));
}
"embedded" if self.providers.embedded.is_none() => {
return Err(anyhow::anyhow!(
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
provider, provider
));
}
"openai" if self.providers.openai.is_none() => {
return Err(anyhow::anyhow!(
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
provider, provider
));
}
_ => {} // Provider is configured or unknown (will be caught later)
}
let mut config = self.clone();
config.providers.default_provider = provider_ref.to_string();
config.providers.default_provider = provider.to_string();
Ok(config)
}
/// Create a copy of the config for planner mode
pub fn for_planner(&self) -> Result<Self> {
self.with_provider_override(self.get_planner_provider())
}
/// Create a copy of the config for coach mode in autonomous execution
pub fn for_coach(&self) -> Result<Self> {
self.with_provider_override(self.get_coach_provider())
}
/// Create a copy of the config for player mode in autonomous execution
pub fn for_player(&self) -> Result<Self> {
self.with_provider_override(self.get_player_provider())
}
/// Get Anthropic config by name
pub fn get_anthropic_config(&self, name: &str) -> Option<&AnthropicConfig> {
self.providers.anthropic.get(name)
}
/// Get OpenAI config by name
pub fn get_openai_config(&self, name: &str) -> Option<&OpenAIConfig> {
self.providers.openai.get(name)
}
/// Get Databricks config by name
pub fn get_databricks_config(&self, name: &str) -> Option<&DatabricksConfig> {
self.providers.databricks.get(name)
}
/// Get Embedded config by name
pub fn get_embedded_config(&self, name: &str) -> Option<&EmbeddedConfig> {
self.providers.embedded.get(name)
}
/// Get the current default provider's config
pub fn get_default_provider_config(&self) -> Result<ProviderConfigRef<'_>> {
let (provider_type, config_name) =
Self::parse_provider_reference(&self.providers.default_provider)?;
match provider_type.as_str() {
"anthropic" => self
.providers
.anthropic
.get(&config_name)
.map(ProviderConfigRef::Anthropic)
.ok_or_else(|| anyhow::anyhow!("Anthropic config '{}' not found", config_name)),
"openai" => self
.providers
.openai
.get(&config_name)
.map(ProviderConfigRef::OpenAI)
.ok_or_else(|| anyhow::anyhow!("OpenAI config '{}' not found", config_name)),
"databricks" => self
.providers
.databricks
.get(&config_name)
.map(ProviderConfigRef::Databricks)
.ok_or_else(|| anyhow::anyhow!("Databricks config '{}' not found", config_name)),
"embedded" => self
.providers
.embedded
.get(&config_name)
.map(ProviderConfigRef::Embedded)
.ok_or_else(|| anyhow::anyhow!("Embedded config '{}' not found", config_name)),
_ => self
.providers
.openai_compatible
.get(&provider_type)
.map(ProviderConfigRef::OpenAICompatible)
.ok_or_else(|| {
anyhow::anyhow!("OpenAI compatible config '{}' not found", provider_type)
}),
}
}
}
/// Reference to a provider configuration
#[derive(Debug)]
pub enum ProviderConfigRef<'a> {
Anthropic(&'a AnthropicConfig),
OpenAI(&'a OpenAIConfig),
Databricks(&'a DatabricksConfig),
Embedded(&'a EmbeddedConfig),
OpenAICompatible(&'a OpenAIConfig),
}
#[cfg(test)]

View File

@@ -4,264 +4,128 @@ mod tests {
use std::fs;
use tempfile::TempDir;
fn test_config_footer() -> &'static str {
r#"
[computer_control]
enabled = false
require_confirmation = true
max_actions_per_second = 10
[webdriver]
enabled = false
safari_port = 4444
"#
}
#[test]
fn test_coach_player_providers() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration with coach and player providers (new format)
let config_content = format!(r#"
// Write a test configuration with coach and player providers
let config_content = r#"
[providers]
default_provider = "databricks.default"
coach = "anthropic.default"
player = "embedded.local"
default_provider = "databricks"
coach = "anthropic"
player = "embedded"
[providers.databricks.default]
[providers.databricks]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[providers.anthropic.default]
api_key = "test-key"
model = "claude-3"
[providers.embedded.local]
model_path = "test.gguf"
model_type = "llama"
[agent]
fallback_default_max_tokens = 32000
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that the providers are correctly identified
assert_eq!(config.providers.default_provider, "databricks.default");
assert_eq!(config.get_coach_provider(), "anthropic.default");
assert_eq!(config.get_player_provider(), "embedded.local");
// Test creating coach config
let coach_config = config.for_coach().unwrap();
assert_eq!(coach_config.providers.default_provider, "anthropic.default");
// Test creating player config
let player_config = config.for_player().unwrap();
assert_eq!(player_config.providers.default_provider, "embedded.local");
}
#[test]
fn test_coach_player_fallback_to_default() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration WITHOUT coach and player providers (new format)
let config_content = format!(r#"
[providers]
default_provider = "databricks.default"
[providers.databricks.default]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[agent]
fallback_default_max_tokens = 32000
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that coach and player fall back to default provider
assert_eq!(config.get_coach_provider(), "databricks.default");
assert_eq!(config.get_player_provider(), "databricks.default");
// Test creating coach config (should use default)
let coach_config = config.for_coach().unwrap();
assert_eq!(coach_config.providers.default_provider, "databricks.default");
// Test creating player config (should use default)
let player_config = config.for_player().unwrap();
assert_eq!(player_config.providers.default_provider, "databricks.default");
}
#[test]
fn test_invalid_provider_error() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration with an unconfigured provider (new format)
let config_content = format!(r#"
[providers]
default_provider = "databricks.default"
coach = "openai.default" # OpenAI default is not configured
[providers.databricks.default]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[agent]
fallback_default_max_tokens = 32000
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that trying to create a coach config with unconfigured provider fails
let result = config.for_coach();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not found") || err_msg.contains("not configured"),
"Expected error message to contain 'not found' or 'not configured', got: {}", err_msg);
}
#[test]
fn test_old_format_detection() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration with OLD format (api_key directly under [providers.anthropic])
let config_content = format!(r#"
[providers]
default_provider = "anthropic"
[providers.anthropic]
api_key = "test-key"
model = "claude-3"
[providers.embedded]
model_path = "test.gguf"
model_type = "llama"
[agent]
fallback_default_max_tokens = 32000
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
"#;
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that the providers are correctly identified
assert_eq!(config.providers.default_provider, "databricks");
assert_eq!(config.get_coach_provider(), "anthropic");
assert_eq!(config.get_player_provider(), "embedded");
// Test creating coach config
let coach_config = config.for_coach().unwrap();
assert_eq!(coach_config.providers.default_provider, "anthropic");
// Test creating player config
let player_config = config.for_player().unwrap();
assert_eq!(player_config.providers.default_provider, "embedded");
}
#[test]
fn test_coach_player_fallback_to_default() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration WITHOUT coach and player providers
let config_content = r#"
[providers]
default_provider = "databricks"
// Loading should fail with old format error
let result = Config::load(Some(config_path.to_str().unwrap()));
[providers.databricks]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[agent]
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
"#;
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that coach and player fall back to default provider
assert_eq!(config.get_coach_provider(), "databricks");
assert_eq!(config.get_player_provider(), "databricks");
// Test creating coach config (should use default)
let coach_config = config.for_coach().unwrap();
assert_eq!(coach_config.providers.default_provider, "databricks");
// Test creating player config (should use default)
let player_config = config.for_player().unwrap();
assert_eq!(player_config.providers.default_provider, "databricks");
}
#[test]
fn test_invalid_provider_error() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration with an unconfigured provider
let config_content = r#"
[providers]
default_provider = "databricks"
coach = "openai" # OpenAI is not configured
[providers.databricks]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[agent]
max_context_length = 8192
enable_streaming = true
timeout_seconds = 60
"#;
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that trying to create a coach config with unconfigured provider fails
let result = config.for_coach();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("old format") || err_msg.contains("no longer supported"),
"Expected error about old format, got: {}", err_msg);
assert!(result.unwrap_err().to_string().contains("not configured"));
}
#[test]
fn test_planner_provider() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration with planner provider (new format)
let config_content = format!(r#"
[providers]
default_provider = "databricks.default"
planner = "anthropic.planner"
[providers.databricks.default]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[providers.anthropic.planner]
api_key = "test-key"
model = "claude-opus"
thinking_budget_tokens = 16000
[agent]
fallback_default_max_tokens = 32000
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that the planner provider is correctly identified
assert_eq!(config.get_planner_provider(), "anthropic.planner");
// Test creating planner config
let planner_config = config.for_planner().unwrap();
assert_eq!(planner_config.providers.default_provider, "anthropic.planner");
}
#[test]
fn test_planner_fallback_to_default() {
// Create a temporary directory for the test config
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
// Write a test configuration WITHOUT planner provider
let config_content = format!(r#"
[providers]
default_provider = "databricks.default"
[providers.databricks.default]
host = "https://test.databricks.com"
token = "test-token"
model = "test-model"
[agent]
fallback_default_max_tokens = 32000
enable_streaming = true
timeout_seconds = 60
auto_compact = true
max_retry_attempts = 3
autonomous_max_retry_attempts = 6
{}"#, test_config_footer());
fs::write(&config_path, config_content).unwrap();
// Load the configuration
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
// Test that planner falls back to default provider
assert_eq!(config.get_planner_provider(), "databricks.default");
}
}
}

View File

@@ -4,9 +4,6 @@ version = "0.1.0"
edition = "2021"
description = "Core engine for G3 AI coding agent"
[features]
test-support = []
[dependencies]
g3-providers = { path = "../g3-providers" }
g3-config = { path = "../g3-config" }
@@ -28,27 +25,3 @@ chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
regex = "1.0"
shellexpand = "3.1"
serde_yaml = "0.9"
# tree-sitter for embedded code search
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-go = "0.23"
tree-sitter-java = "0.23"
tree-sitter-c = "0.23"
tree-sitter-cpp = "0.23"
# tree-sitter-kotlin = "0.3" # Temporarily disabled - incompatible with tree-sitter 0.24
tree-sitter-haskell = { git = "https://github.com/tree-sitter/tree-sitter-haskell" }
tree-sitter-scheme = "0.24"
tree-sitter-racket = "0.24"
streaming-iterator = "0.1"
walkdir = "2.4"
base64 = "0.22.1"
[dev-dependencies]
tempfile = "3.8"
serial_test = "3.0"

View File

@@ -1,58 +0,0 @@
//! Inspect tree-sitter AST structure for Rust code
use tree_sitter::{Language, Parser};
fn print_tree(node: tree_sitter::Node, source: &str, indent: usize) {
let indent_str = " ".repeat(indent);
let node_text = &source[node.byte_range()];
let preview = if node_text.len() > 50 {
format!("{}...", &node_text[..50])
} else {
node_text.to_string()
};
println!(
"{}{} [{}:{}] '{}'",
indent_str,
node.kind(),
node.start_position().row + 1,
node.start_position().column + 1,
preview.replace('\n', "\\n")
);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
print_tree(child, source, indent + 1);
}
}
fn main() -> anyhow::Result<()> {
let source_code = r#"
pub async fn example_async() {
println!("Hello");
}
fn regular_function() {
println!("Regular");
}
pub async fn another_async(x: i32) -> Result<(), ()> {
Ok(())
}
"#;
println!("Source code:");
println!("{}", source_code);
println!("\n{}", "=".repeat(80));
println!("AST Structure:");
println!("{}\n", "=".repeat(80));
let mut parser = Parser::new();
let language: Language = tree_sitter_rust::LANGUAGE.into();
parser.set_language(&language)?;
let tree = parser.parse(source_code, None).unwrap();
print_tree(tree.root_node(), source_code, 0);
Ok(())
}

View File

@@ -1,56 +0,0 @@
//! Inspect tree-sitter AST structure for Python code
use tree_sitter::{Language, Parser};
fn print_tree(node: tree_sitter::Node, source: &str, indent: usize) {
let indent_str = " ".repeat(indent);
let node_text = &source[node.byte_range()];
let preview = if node_text.len() > 50 {
format!("{}...", &node_text[..50])
} else {
node_text.to_string()
};
println!(
"{}{} [{}:{}] '{}'",
indent_str,
node.kind(),
node.start_position().row + 1,
node.start_position().column + 1,
preview.replace('\n', "\\n")
);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
print_tree(child, source, indent + 1);
}
}
fn main() -> anyhow::Result<()> {
let source_code = r#"
def regular_function():
pass
async def async_function():
pass
class MyClass:
def method(self):
pass
"#;
println!("Source code:");
println!("{}", source_code);
println!("\n{}", "=".repeat(80));
println!("AST Structure:");
println!("{}\n", "=".repeat(80));
let mut parser = Parser::new();
let language: Language = tree_sitter_python::LANGUAGE.into();
parser.set_language(&language)?;
let tree = parser.parse(source_code, None).unwrap();
print_tree(tree.root_node(), source_code, 0);
Ok(())
}

View File

@@ -1,44 +0,0 @@
//! Test Python async query
use streaming_iterator::StreamingIterator;
use tree_sitter::{Language, Parser, Query, QueryCursor};
fn main() -> anyhow::Result<()> {
let source_code = r#"
def regular_function():
pass
async def async_function():
pass
"#;
let mut parser = Parser::new();
let language: Language = tree_sitter_python::LANGUAGE.into();
parser.set_language(&language)?;
let tree = parser.parse(source_code, None).unwrap();
// Try different queries
let queries = vec![
"(function_definition (async) name: (identifier) @name)",
"(function_definition (async))",
"(async)",
];
for query_str in queries {
println!("\nTrying query: {}", query_str);
match Query::new(&language, query_str) {
Ok(query) => {
let mut cursor = QueryCursor::new();
let matches = cursor.matches(&query, tree.root_node(), source_code.as_bytes());
let count = matches.count();
println!(" ✓ Valid query, found {} matches", count);
}
Err(e) => {
println!(" ✗ Invalid query: {}", e);
}
}
}
Ok(())
}

View File

@@ -1,663 +0,0 @@
//! Aggressive Context Dehydration (ACD) module.
//!
//! This module provides functionality for dehydrating conversation history
//! into persistent fragments that can be rehydrated on demand. This allows
//! for much longer effective sessions by saving context to disk and replacing
//! it with compact stubs.
//!
//! ## Design
//!
//! When ACD is enabled (`--acd` flag), after every compaction/summary:
//! 1. All messages before the summary are saved to a fragment file
//! 2. Those messages are replaced with a compact stub in the context
//! 3. The stub contains metadata to help decide if rehydration is worthwhile
//! 4. Fragments form a linked list via `preceding_fragment_id`
//!
//! ## Fragment Storage
//!
//! Fragments are stored in `.g3/sessions/<session_id>/fragments/`
//! as JSON files named `fragment_<id>.json`.
use anyhow::{Context, Result};
use g3_providers::Message;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, warn};
use crate::paths::get_fragments_dir;
use crate::ToolCall;
/// A dehydrated context fragment containing saved conversation history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fragment {
/// Unique identifier for this fragment
pub fragment_id: String,
/// When this fragment was created
pub created_at: String,
/// The dehydrated messages
pub messages: Vec<Message>,
/// Total number of messages
pub message_count: usize,
/// Number of user messages
pub user_message_count: usize,
/// Number of assistant messages
pub assistant_message_count: usize,
/// Summary of tool calls by tool name
pub tool_call_summary: HashMap<String, usize>,
/// Estimated token count for this fragment
pub estimated_tokens: u32,
/// Brief topic hints extracted from the conversation
pub topics: Vec<String>,
/// ID of the preceding fragment in the chain (None for first fragment)
pub preceding_fragment_id: Option<String>,
/// The first user message (task) in full for forensics
pub first_user_message: Option<String>,
}
impl Fragment {
/// Create a new fragment from a slice of messages.
///
/// # Arguments
/// * `messages` - The messages to dehydrate
/// * `preceding_fragment_id` - ID of the previous fragment in the chain
pub fn new(messages: Vec<Message>, preceding_fragment_id: Option<String>) -> Self {
let fragment_id = generate_fragment_id();
let created_at = chrono::Utc::now().to_rfc3339();
// Count messages by role
let mut user_count = 0;
let mut assistant_count = 0;
for msg in &messages {
match msg.role {
g3_providers::MessageRole::User => user_count += 1,
g3_providers::MessageRole::Assistant => assistant_count += 1,
g3_providers::MessageRole::System => {}
}
}
// Extract tool call summary
let tool_call_summary = extract_tool_call_summary(&messages);
// Estimate tokens
let estimated_tokens = estimate_fragment_tokens(&messages);
// Extract topics
let topics = extract_topics(&messages);
// Extract first user message for forensics
let first_user_message = messages
.iter()
.find(|m| matches!(m.role, g3_providers::MessageRole::User))
.filter(|m| !m.content.starts_with("Tool result"))
.map(|m| m.content.clone());
Self {
fragment_id,
created_at,
message_count: messages.len(),
user_message_count: user_count,
assistant_message_count: assistant_count,
tool_call_summary,
estimated_tokens,
topics,
preceding_fragment_id,
first_user_message,
messages,
}
}
/// Generate the stub message content for this fragment.
pub fn generate_stub(&self) -> String {
let mut stub = String::new();
stub.push_str("---\n");
// Include the first user message for context
if let Some(ref task) = self.first_user_message {
stub.push_str(&format!("{}\n\n", task));
}
// Tool call summary
let tool_part = if !self.tool_call_summary.is_empty() {
let total_calls: usize = self.tool_call_summary.values().sum();
let tool_details: Vec<String> = self
.tool_call_summary
.iter()
.map(|(tool, count)| format!("{} x{}", tool, count))
.collect();
format!("{} tool calls ({})", total_calls, tool_details.join(", "))
} else {
"no tool calls".to_string()
};
stub.push_str(&format!(
"⚡ DEHYDRATED CONTEXT: {}, {} total msgs. To restore, call: rehydrate(fragment_id: \"{}\")\n",
tool_part, self.message_count, self.fragment_id
));
stub.push_str("---");
stub
}
/// Get the file path for this fragment.
pub fn file_path(&self, session_id: &str) -> PathBuf {
get_fragments_dir(session_id).join(format!("fragment_{}.json", self.fragment_id))
}
/// Save this fragment to disk.
pub fn save(&self, session_id: &str) -> Result<PathBuf> {
let fragments_dir = get_fragments_dir(session_id);
std::fs::create_dir_all(&fragments_dir)
.context("Failed to create fragments directory")?;
let file_path = self.file_path(session_id);
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize fragment")?;
std::fs::write(&file_path, json)
.context("Failed to write fragment file")?;
debug!("Saved fragment {} to {:?}", self.fragment_id, file_path);
Ok(file_path)
}
/// Load a fragment from disk.
pub fn load(session_id: &str, fragment_id: &str) -> Result<Self> {
let file_path = get_fragments_dir(session_id)
.join(format!("fragment_{}.json", fragment_id));
if !file_path.exists() {
anyhow::bail!("Fragment not found: {}", fragment_id);
}
let json = std::fs::read_to_string(&file_path)
.context("Failed to read fragment file")?;
let fragment: Fragment = serde_json::from_str(&json)
.context("Failed to deserialize fragment")?;
debug!("Loaded fragment {} from {:?}", fragment_id, file_path);
Ok(fragment)
}
}
/// Generate a unique fragment ID.
fn generate_fragment_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
// Use first 12 hex chars of timestamp hash for brevity
format!("{:x}", timestamp).chars().take(12).collect()
}
/// Extract a summary of tool calls from messages.
fn extract_tool_call_summary(messages: &[Message]) -> HashMap<String, usize> {
let mut summary = HashMap::new();
for msg in messages {
if matches!(msg.role, g3_providers::MessageRole::Assistant) {
// Try to parse tool calls from the message content
if let Some(tool_name) = extract_tool_name_from_content(&msg.content) {
*summary.entry(tool_name).or_insert(0) += 1;
}
}
}
summary
}
/// Extract tool name from assistant message content.
fn extract_tool_name_from_content(content: &str) -> Option<String> {
// Look for JSON tool call pattern
if let Some(start) = content.find(r#""tool""#).or_else(|| content.find(r#""tool" "#)) {
let after_tool = &content[start..];
// Find the tool name value
if let Some(colon_pos) = after_tool.find(':') {
let after_colon = &after_tool[colon_pos + 1..];
let trimmed = after_colon.trim_start();
if trimmed.starts_with('"') {
let name_start = 1;
if let Some(name_end) = trimmed[name_start..].find('"') {
return Some(trimmed[name_start..name_start + name_end].to_string());
}
}
}
}
// Also try parsing as JSON
if let Ok(tool_call) = serde_json::from_str::<ToolCall>(content) {
return Some(tool_call.tool);
}
// Try to find embedded JSON
if let Some(start) = content.find('{') {
if let Some(end) = find_json_end(&content[start..]) {
let json_str = &content[start..start + end + 1];
if let Ok(tool_call) = serde_json::from_str::<ToolCall>(json_str) {
return Some(tool_call.tool);
}
}
}
None
}
/// Find the end of a JSON object (matching braces).
fn find_json_end(json_str: &str) -> Option<usize> {
let mut brace_count = 0;
let mut in_string = false;
let mut escape_next = false;
for (i, ch) in json_str.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' => escape_next = true,
'"' if !escape_next => in_string = !in_string,
'{' if !in_string => brace_count += 1,
'}' if !in_string => {
brace_count -= 1;
if brace_count == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// Estimate token count for messages.
fn estimate_fragment_tokens(messages: &[Message]) -> u32 {
let total_chars: usize = messages.iter().map(|m| m.content.len()).sum();
// Use same heuristic as ContextWindow: ~4 chars per token with 10% buffer
((total_chars as f32 / 4.0) * 1.1).ceil() as u32
}
/// Extract topic hints from messages using heuristics.
fn extract_topics(messages: &[Message]) -> Vec<String> {
let mut topics = Vec::new();
let mut seen = std::collections::HashSet::new();
for msg in messages {
match msg.role {
g3_providers::MessageRole::User => {
// Extract first meaningful part of user messages
if !msg.content.starts_with("Tool result") {
let topic = extract_topic_from_text(&msg.content);
if !topic.is_empty() && seen.insert(topic.clone()) {
topics.push(topic);
}
}
}
g3_providers::MessageRole::Assistant => {
// Look for file paths in tool calls
if let Some(path) = extract_file_path(&msg.content) {
if seen.insert(path.clone()) {
topics.push(format!("edited {}", path));
}
}
}
_ => {}
}
// Limit topics to keep stub concise
if topics.len() >= 5 {
break;
}
}
topics
}
/// Extract a brief topic from text.
fn extract_topic_from_text(text: &str) -> String {
// Take first line, truncate to ~50 chars
let first_line = text.lines().next().unwrap_or("");
let cleaned = first_line.trim();
if cleaned.chars().count() <= 50 {
cleaned.to_string()
} else {
// Find a good break point (UTF-8 safe)
let truncated: String = cleaned.chars().take(50).collect();
if let Some(last_space) = truncated.rfind(' ') {
// last_space is a byte index into truncated, which is safe since truncated is a new String
format!("{}...", &truncated[..last_space])
} else {
format!("{}...", truncated)
}
}
}
/// Extract file path from tool call content.
fn extract_file_path(content: &str) -> Option<String> {
// Look for file_path in JSON
if let Some(start) = content.find(r#""file_path""#) {
let after = &content[start..];
if let Some(colon) = after.find(':') {
let after_colon = &after[colon + 1..];
let trimmed = after_colon.trim_start();
if trimmed.starts_with('"') {
if let Some(end) = trimmed[1..].find('"') {
let path = &trimmed[1..1 + end];
// Return just the filename for brevity
return Some(
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string(),
);
}
}
}
}
None
}
/// List all fragments for a session, ordered by creation time.
pub fn list_fragments(session_id: &str) -> Result<Vec<Fragment>> {
let fragments_dir = get_fragments_dir(session_id);
if !fragments_dir.exists() {
return Ok(Vec::new());
}
let mut fragments = Vec::new();
for entry in std::fs::read_dir(&fragments_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |e| e == "json") {
match std::fs::read_to_string(&path) {
Ok(json) => match serde_json::from_str::<Fragment>(&json) {
Ok(fragment) => fragments.push(fragment),
Err(e) => warn!("Failed to parse fragment {:?}: {}", path, e),
},
Err(e) => warn!("Failed to read fragment {:?}: {}", path, e),
}
}
}
// Sort by creation time
fragments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(fragments)
}
/// Get the most recent fragment ID for a session (the tail of the linked list).
pub fn get_latest_fragment_id(session_id: &str) -> Result<Option<String>> {
let fragments = list_fragments(session_id)?;
Ok(fragments.last().map(|f| f.fragment_id.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
use g3_providers::MessageRole;
fn make_message(role: MessageRole, content: &str) -> Message {
Message::new(role, content.to_string())
}
#[test]
fn test_fragment_creation() {
let messages = vec![
make_message(MessageRole::User, "Hello, can you help me?"),
make_message(MessageRole::Assistant, "Of course! What do you need?"),
make_message(MessageRole::User, "Write a function"),
make_message(
MessageRole::Assistant,
r#"{"tool": "write_file", "args": {"file_path": "test.rs", "content": "fn main() {}"}}"#,
),
];
let fragment = Fragment::new(messages, None);
assert_eq!(fragment.message_count, 4);
assert_eq!(fragment.user_message_count, 2);
assert_eq!(fragment.assistant_message_count, 2);
assert!(fragment.fragment_id.len() > 0);
assert!(fragment.preceding_fragment_id.is_none());
}
#[test]
fn test_fragment_with_preceding() {
let messages = vec![make_message(MessageRole::User, "Test")];
let fragment = Fragment::new(messages, Some("abc123".to_string()));
assert_eq!(fragment.preceding_fragment_id, Some("abc123".to_string()));
}
#[test]
fn test_tool_call_extraction() {
let messages = vec![
make_message(
MessageRole::Assistant,
r#"{"tool": "shell", "args": {"command": "ls"}}"#,
),
make_message(
MessageRole::Assistant,
r#"{"tool": "read_file", "args": {"file_path": "test.rs"}}"#,
),
make_message(
MessageRole::Assistant,
r#"{"tool": "shell", "args": {"command": "pwd"}}"#,
),
];
let summary = extract_tool_call_summary(&messages);
assert_eq!(summary.get("shell"), Some(&2));
assert_eq!(summary.get("read_file"), Some(&1));
}
#[test]
fn test_stub_generation() {
let messages = vec![
make_message(MessageRole::User, "implement auth module"),
make_message(
MessageRole::Assistant,
r#"{"tool": "write_file", "args": {"file_path": "auth.rs", "content": "// auth"}}"#,
),
];
let fragment = Fragment::new(messages, None);
let stub = fragment.generate_stub();
assert!(stub.contains("DEHYDRATED CONTEXT"));
assert!(stub.contains(&fragment.fragment_id));
assert!(stub.contains("2 total msgs"));
assert!(stub.contains("1 tool calls"));
assert!(stub.contains("rehydrate"));
}
#[test]
fn test_topic_extraction() {
let messages = vec![
make_message(MessageRole::User, "Please fix the login bug"),
make_message(MessageRole::User, "Tool result: success"),
make_message(MessageRole::User, "Now add tests for it"),
];
let topics = extract_topics(&messages);
assert!(topics.contains(&"Please fix the login bug".to_string()));
assert!(topics.contains(&"Now add tests for it".to_string()));
// Tool results should be skipped
assert!(!topics.iter().any(|t| t.contains("Tool result")));
}
#[test]
fn test_topic_truncation() {
let long_text = "This is a very long message that should be truncated because it exceeds the maximum length we want for topic hints";
let topic = extract_topic_from_text(long_text);
assert!(topic.len() <= 55); // 50 + "..."
assert!(topic.ends_with("..."));
}
#[test]
fn test_file_path_extraction() {
let content = r#"{"tool": "write_file", "args": {"file_path": "src/auth/login.rs", "content": "..."}}"#;
let path = extract_file_path(content);
assert_eq!(path, Some("login.rs".to_string()));
}
#[test]
fn test_fragment_save_load_roundtrip() {
let temp_dir = std::env::temp_dir();
let test_session_id = format!("test_acd_{}", std::process::id());
// Create a fragment
let messages = vec![
make_message(MessageRole::User, "Test message"),
make_message(MessageRole::Assistant, "Test response"),
];
let fragment = Fragment::new(messages.clone(), None);
let original_id = fragment.fragment_id.clone();
// Temporarily override the g3 dir for testing
let fragments_dir = temp_dir.join(".g3").join("sessions").join(&test_session_id).join("fragments");
std::fs::create_dir_all(&fragments_dir).unwrap();
// Save directly to temp location
let file_path = fragments_dir.join(format!("fragment_{}.json", original_id));
let json = serde_json::to_string_pretty(&fragment).unwrap();
std::fs::write(&file_path, &json).unwrap();
// Load it back
let loaded_json = std::fs::read_to_string(&file_path).unwrap();
let loaded: Fragment = serde_json::from_str(&loaded_json).unwrap();
assert_eq!(loaded.fragment_id, original_id);
assert_eq!(loaded.message_count, 2);
assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.messages[0].content, "Test message");
// Cleanup
let _ = std::fs::remove_dir_all(temp_dir.join(".g3").join("sessions").join(&test_session_id));
}
#[test]
fn test_empty_fragment() {
let fragment = Fragment::new(vec![], None);
assert_eq!(fragment.message_count, 0);
assert_eq!(fragment.user_message_count, 0);
assert_eq!(fragment.assistant_message_count, 0);
assert!(fragment.tool_call_summary.is_empty());
assert!(fragment.topics.is_empty());
}
#[test]
fn test_fragment_id_uniqueness() {
let id1 = generate_fragment_id();
std::thread::sleep(std::time::Duration::from_millis(1));
let id2 = generate_fragment_id();
assert_ne!(id1, id2);
}
#[test]
fn test_linked_list_chain() {
let frag1 = Fragment::new(
vec![make_message(MessageRole::User, "First")],
None,
);
let frag2 = Fragment::new(
vec![make_message(MessageRole::User, "Second")],
Some(frag1.fragment_id.clone()),
);
let frag3 = Fragment::new(
vec![make_message(MessageRole::User, "Third")],
Some(frag2.fragment_id.clone()),
);
// Verify chain
assert!(frag1.preceding_fragment_id.is_none());
assert_eq!(frag2.preceding_fragment_id, Some(frag1.fragment_id.clone()));
assert_eq!(frag3.preceding_fragment_id, Some(frag2.fragment_id.clone()));
}
#[test]
fn test_stub_with_no_tools() {
let messages = vec![
make_message(MessageRole::User, "Just chatting"),
make_message(MessageRole::Assistant, "Sure, let's chat!"),
];
let fragment = Fragment::new(messages, None);
let stub = fragment.generate_stub();
// Should have "no tool calls" in the compact format
assert!(stub.contains("no tool calls"));
}
#[test]
fn test_stub_with_multiple_tools() {
let messages = vec![
make_message(
MessageRole::Assistant,
r#"{"tool": "shell", "args": {"command": "ls"}}"#,
),
make_message(
MessageRole::Assistant,
r#"{"tool": "read_file", "args": {"file_path": "a.rs"}}"#,
),
make_message(
MessageRole::Assistant,
r#"{"tool": "write_file", "args": {"file_path": "b.rs", "content": "x"}}"#,
),
];
let fragment = Fragment::new(messages, None);
let stub = fragment.generate_stub();
assert!(stub.contains("3 tool calls"));
assert!(stub.contains("shell"));
assert!(stub.contains("read_file"));
assert!(stub.contains("write_file"));
}
#[test]
fn test_token_estimation() {
let messages = vec![
make_message(MessageRole::User, "Hello"), // 5 chars
make_message(MessageRole::Assistant, "World"), // 5 chars
];
let tokens = estimate_fragment_tokens(&messages);
// 10 chars / 4 * 1.1 ≈ 3 tokens
assert!(tokens > 0);
assert!(tokens < 10);
}
#[test]
fn test_extract_tool_name_embedded_json() {
let content = "Let me check that file for you.
{\"tool\": \"read_file\", \"args\": {\"file_path\": \"test.rs\"}}";
let tool_name = extract_tool_name_from_content(content);
assert_eq!(tool_name, Some("read_file".to_string()));
}
#[test]
fn test_extract_tool_name_no_tool() {
let content = "This is just regular text without any tool calls.";
let tool_name = extract_tool_name_from_content(content);
assert!(tool_name.is_none());
}
}

View File

@@ -1,279 +0,0 @@
//! Background process management for long-running tasks like game servers.
//!
//! This module provides a way to launch processes in the background with:
//! - Automatic log capture to files (stdout/stderr combined)
//! - Named process tracking for easy reference
//! - Process lifecycle management (start, stop via shell)
//!
//! The design is intentionally minimal - only one tool (`background_process`) is exposed.
//! Users can use the regular `shell` tool to:
//! - Read logs: `cat /path/to/logs.txt` or `tail -100 /path/to/logs.txt`
//! - Stop processes: `kill <pid>` or `pkill -f <name>`
//! - Check status: `ps aux | grep <name>`
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::debug;
/// Information about a running background process
#[derive(Debug, Clone)]
pub struct ProcessInfo {
/// User-provided name for the process
pub name: String,
/// The command that was executed
pub command: String,
/// Process ID
pub pid: u32,
/// Path to the log file (combined stdout/stderr)
pub log_file: PathBuf,
/// Timestamp when the process was started
pub started_at: u64,
/// Working directory where the process was started
pub working_dir: PathBuf,
}
/// Manages background processes launched by the agent
#[derive(Debug)]
pub struct BackgroundProcessManager {
/// Map of process name -> process info
processes: Arc<Mutex<HashMap<String, ProcessInfo>>>,
/// Map of process name -> child handle (for cleanup)
children: Arc<Mutex<HashMap<String, Child>>>,
/// Directory where log files are stored
log_dir: PathBuf,
}
impl BackgroundProcessManager {
/// Create a new background process manager
pub fn new(log_dir: PathBuf) -> Self {
// Ensure log directory exists
if let Err(e) = fs::create_dir_all(&log_dir) {
debug!("Failed to create log directory {:?}: {}", log_dir, e);
}
Self {
processes: Arc::new(Mutex::new(HashMap::new())),
children: Arc::new(Mutex::new(HashMap::new())),
log_dir,
}
}
/// Start a new background process
///
/// # Arguments
/// * `name` - A unique name for this process (used to reference it later)
/// * `command` - The shell command to execute
/// * `working_dir` - The directory to run the command in
///
/// # Returns
/// ProcessInfo on success, or an error message
pub fn start(
&self,
name: &str,
command: &str,
working_dir: &PathBuf,
) -> Result<ProcessInfo, String> {
// Check if a process with this name already exists
{
let processes = self.processes.lock().unwrap();
if processes.contains_key(name) {
return Err(format!(
"A process named '{}' is already running. Stop it first or use a different name.",
name
));
}
}
// Create log file with timestamp
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let log_filename = format!("{}_{}.log", name, timestamp);
let log_file = self.log_dir.join(&log_filename);
// Open log file for writing
let log_handle = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_file)
.map_err(|e| format!("Failed to create log file: {}", e))?;
// Write header to log file
{
let mut file = &log_handle;
writeln!(file, "=== Background Process Log ===").ok();
writeln!(file, "Name: {}", name).ok();
writeln!(file, "Command: {}", command).ok();
writeln!(file, "Working Directory: {:?}", working_dir).ok();
writeln!(file, "Started: {}", timestamp).ok();
writeln!(file, "================================\n").ok();
}
// Clone the file handle for stderr
let log_handle_stderr = log_handle
.try_clone()
.map_err(|e| format!("Failed to clone log file handle: {}", e))?;
// Spawn the process
let child = Command::new("bash")
.arg("-c")
.arg(command)
.current_dir(working_dir)
.stdout(Stdio::from(log_handle))
.stderr(Stdio::from(log_handle_stderr))
.spawn()
.map_err(|e| format!("Failed to spawn process: {}", e))?;
let pid = child.id();
let info = ProcessInfo {
name: name.to_string(),
command: command.to_string(),
pid,
log_file: log_file.clone(),
started_at: timestamp,
working_dir: working_dir.clone(),
};
// Store process info and child handle
{
let mut processes = self.processes.lock().unwrap();
processes.insert(name.to_string(), info.clone());
}
{
let mut children = self.children.lock().unwrap();
children.insert(name.to_string(), child);
}
debug!(
"Started background process '{}' (PID: {}) with logs at {:?}",
name, pid, log_file
);
Ok(info)
}
/// List all tracked background processes
pub fn list(&self) -> Vec<ProcessInfo> {
let processes = self.processes.lock().unwrap();
processes.values().cloned().collect()
}
/// Get info about a specific process by name
pub fn get(&self, name: &str) -> Option<ProcessInfo> {
let processes = self.processes.lock().unwrap();
processes.get(name).cloned()
}
/// Check if a process is still running
pub fn is_running(&self, name: &str) -> bool {
let mut children = self.children.lock().unwrap();
if let Some(child) = children.get_mut(name) {
match child.try_wait() {
Ok(Some(_)) => false, // Process has exited
Ok(None) => true, // Still running
Err(_) => false, // Error checking, assume not running
}
} else {
false
}
}
/// Remove a process from tracking (call after it has been killed)
pub fn remove(&self, name: &str) -> Option<ProcessInfo> {
let info = {
let mut processes = self.processes.lock().unwrap();
processes.remove(name)
};
{
let mut children = self.children.lock().unwrap();
children.remove(name);
}
info
}
/// Clean up all processes on shutdown
pub fn cleanup(&self) {
let mut children = self.children.lock().unwrap();
for (name, mut child) in children.drain() {
debug!("Cleaning up background process '{}'", name);
let _ = child.kill();
}
}
}
impl Drop for BackgroundProcessManager {
fn drop(&mut self) {
self.cleanup();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_start_and_list_process() {
let temp_dir = std::env::temp_dir().join("g3_bg_test");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let manager = BackgroundProcessManager::new(temp_dir.clone());
// Start a simple process that sleeps
let result = manager.start("test_sleep", "sleep 10", &temp_dir);
assert!(result.is_ok());
let info = result.unwrap();
assert_eq!(info.name, "test_sleep");
assert!(info.pid > 0);
assert!(info.log_file.exists());
// List should contain our process
let list = manager.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "test_sleep");
// Should be running
assert!(manager.is_running("test_sleep"));
// Cleanup
manager.cleanup();
// Give it a moment to clean up
thread::sleep(Duration::from_millis(100));
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_duplicate_name_rejected() {
let temp_dir = std::env::temp_dir().join("g3_bg_test_dup");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let manager = BackgroundProcessManager::new(temp_dir.clone());
// Start first process
let result1 = manager.start("my_game", "sleep 10", &temp_dir);
assert!(result1.is_ok());
// Try to start another with same name
let result2 = manager.start("my_game", "sleep 5", &temp_dir);
assert!(result2.is_err());
assert!(result2.unwrap_err().contains("already running"));
// Cleanup
manager.cleanup();
let _ = fs::remove_dir_all(&temp_dir);
}
}

Some files were not shown because too many files have changed in this diff Show More