From cff32bf0ba1b84e398a4732347f53205e5f7cc5e Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Thu, 5 Feb 2026 14:22:17 +1100 Subject: [PATCH] Make research skill self-contained without external scripts - Rewrite SKILL.md with inline instructions to spawn g3 --agent scout directly - Extend read_file to handle embedded skill paths (/SKILL.md) - Remove scripts field from EmbeddedSkill struct (no longer needed) - Delete extraction.rs module (was only for script extraction) - Delete g3-research bash script - Remove obsolete Async Research Tool section from workspace memory Skills are now fully portable - they work when g3 is installed as a binary without access to source files. Agents can read embedded skill content via read_file with the special path syntax. --- analysis/memory.md | 31 --- crates/g3-core/src/skills/embedded.rs | 8 +- crates/g3-core/src/skills/extraction.rs | 234 ---------------- crates/g3-core/src/skills/mod.rs | 1 - crates/g3-core/src/tools/file_ops.rs | 50 +++- skills/research/SKILL.md | 178 ++++++------- skills/research/g3-research | 338 ------------------------ 7 files changed, 130 insertions(+), 710 deletions(-) delete mode 100644 crates/g3-core/src/skills/extraction.rs delete mode 100755 skills/research/g3-research diff --git a/analysis/memory.md b/analysis/memory.md index 8c34033..ac27a35 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -211,37 +211,6 @@ max_tokens = 4096 gpu_layers = 99 ``` -### Async Research Tool -Research tool is asynchronous - spawns scout agent in background, returns immediately with research_id. - -- `crates/g3-core/src/pending_research.rs` - - `PendingResearchManager` [80..100] - thread-safe task storage (Arc>) - - `ResearchTask` [40..75] - id, query, status, result, started_at, injected - - `ResearchStatus` [20..35] - Pending, Complete, Failed enum - - `register()` [110..125] - creates task, returns research_id - - `complete()` / `fail()` [130..150] - update task status - - `take_completed()` [180..200] - returns completed tasks, marks as injected - - `list_all()` [165..170] - returns all tasks for /research command - -- `crates/g3-core/src/tools/research.rs` - - `execute_research()` [150..210] - spawns scout in tokio::spawn, returns placeholder - - `run_scout_agent()` [215..300] - async fn that runs in background task - - `execute_research_status()` [305..380] - check status of pending research - -- `crates/g3-core/src/lib.rs` - - `inject_completed_research()` [1080..1120] - injects completed research into context - - Called at start of each tool iteration and before user prompt in interactive mode - -- `crates/g3-cli/src/commands.rs` - - `/research` command [125..160] - lists all research tasks with status - -**Flow:** -1. Agent calls `research(query)` → returns immediately with research_id -2. Scout agent runs in background tokio task -3. On completion, `PendingResearchManager.complete()` stores result -4. At next iteration start or user prompt, `inject_completed_research()` adds to context -5. Agent can check status with `research_status` tool or user with `/research` command - ### Plan Mode (replaces TODO system) Structured task planning with cognitive forcing - requires happy/negative/boundary checks. diff --git a/crates/g3-core/src/skills/embedded.rs b/crates/g3-core/src/skills/embedded.rs index 908ce46..8e1a271 100644 --- a/crates/g3-core/src/skills/embedded.rs +++ b/crates/g3-core/src/skills/embedded.rs @@ -12,15 +12,13 @@ use std::collections::HashMap; -/// An embedded skill with its SKILL.md content and optional scripts. +/// An embedded skill with its SKILL.md content. #[derive(Debug, Clone)] pub struct EmbeddedSkill { /// Skill name (must match the name in SKILL.md frontmatter) pub name: &'static str, /// Content of SKILL.md pub skill_md: &'static str, - /// Scripts bundled with the skill: (filename, content) - pub scripts: &'static [(&'static str, &'static str)], } /// All embedded skills, compiled into the binary. @@ -32,9 +30,6 @@ static EMBEDDED_SKILLS: &[EmbeddedSkill] = &[ EmbeddedSkill { name: "research", skill_md: include_str!("../../../../skills/research/SKILL.md"), - scripts: &[ - ("g3-research", include_str!("../../../../skills/research/g3-research")), - ], }, ]; @@ -70,7 +65,6 @@ mod tests { let skill = skill.unwrap(); assert!(skill.skill_md.contains("name: research"), "SKILL.md should have name field"); - assert!(!skill.scripts.is_empty(), "Research skill should have scripts"); } #[test] diff --git a/crates/g3-core/src/skills/extraction.rs b/crates/g3-core/src/skills/extraction.rs deleted file mode 100644 index a2cbe57..0000000 --- a/crates/g3-core/src/skills/extraction.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Script extraction for embedded skills. -//! -//! Extracts embedded scripts to `.g3/bin/` on first use. -//! Scripts are re-extracted if the embedded version changes. - -use anyhow::{Context, Result}; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use tracing::{debug, info}; - -use super::embedded::get_embedded_skill; - -/// Directory where extracted scripts are placed (relative to workspace) -const BIN_DIR: &str = ".g3/bin"; - -/// Version file to track when scripts need re-extraction -const VERSION_FILE: &str = ".version"; - -/// Extract a script from an embedded skill to the bin directory. -/// -/// Returns the path to the extracted script. -/// -/// # Arguments -/// * `skill_name` - Name of the skill containing the script -/// * `script_name` - Name of the script file to extract -/// * `workspace_dir` - Workspace root directory -/// -/// # Returns -/// Path to the extracted script, ready to execute. -pub fn extract_script( - skill_name: &str, - script_name: &str, - workspace_dir: &Path, -) -> Result { - let skill = get_embedded_skill(skill_name) - .with_context(|| format!("Embedded skill '{}' not found", skill_name))?; - - let script_content = skill - .scripts - .iter() - .find(|(name, _)| *name == script_name) - .map(|(_, content)| *content) - .with_context(|| format!("Script '{}' not found in skill '{}'", script_name, skill_name))?; - - let bin_dir = workspace_dir.join(BIN_DIR); - fs::create_dir_all(&bin_dir) - .with_context(|| format!("Failed to create bin directory: {}", bin_dir.display()))?; - - let script_path = bin_dir.join(script_name); - let version_path = bin_dir.join(format!("{}{}", script_name, VERSION_FILE)); - - // Check if we need to extract (script missing or version changed) - let needs_extraction = if !script_path.exists() { - debug!("Script {} does not exist, extracting", script_path.display()); - true - } else if needs_update(&version_path, script_content)? { - debug!("Script {} is outdated, re-extracting", script_path.display()); - true - } else { - debug!("Script {} is up to date", script_path.display()); - false - }; - - if needs_extraction { - // Write the script - fs::write(&script_path, script_content) - .with_context(|| format!("Failed to write script: {}", script_path.display()))?; - - // Make it executable (Unix only) - #[cfg(unix)] - { - let mut perms = fs::metadata(&script_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms)?; - } - - // Write version file (content hash) - let hash = compute_hash(script_content); - fs::write(&version_path, hash) - .with_context(|| format!("Failed to write version file: {}", version_path.display()))?; - - info!("Extracted {} to {}", script_name, script_path.display()); - } - - Ok(script_path) -} - -/// Extract all scripts from an embedded skill. -/// -/// Returns a vector of (script_name, script_path) pairs. -pub fn extract_all_scripts( - skill_name: &str, - workspace_dir: &Path, -) -> Result> { - let skill = get_embedded_skill(skill_name) - .with_context(|| format!("Embedded skill '{}' not found", skill_name))?; - - let mut extracted = Vec::new(); - - for (script_name, _) in skill.scripts { - let path = extract_script(skill_name, script_name, workspace_dir)?; - extracted.push((script_name.to_string(), path)); - } - - Ok(extracted) -} - -/// Check if a script needs to be updated based on version file. -fn needs_update(version_path: &Path, current_content: &str) -> Result { - if !version_path.exists() { - return Ok(true); - } - - let stored_hash = fs::read_to_string(version_path) - .with_context(|| format!("Failed to read version file: {}", version_path.display()))?; - - let current_hash = compute_hash(current_content); - - Ok(stored_hash.trim() != current_hash) -} - -/// Compute a simple hash of content for version tracking. -/// Uses a fast non-cryptographic hash. -fn compute_hash(content: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - content.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -/// Get the path where a script would be extracted. -/// Does not actually extract the script. -pub fn get_script_path(script_name: &str, workspace_dir: &Path) -> PathBuf { - workspace_dir.join(BIN_DIR).join(script_name) -} - -/// Check if a script has been extracted. -pub fn is_script_extracted(script_name: &str, workspace_dir: &Path) -> bool { - get_script_path(script_name, workspace_dir).exists() -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_extract_research_script() { - let temp = TempDir::new().unwrap(); - - let result = extract_script("research", "g3-research", temp.path()); - assert!(result.is_ok(), "Should extract research script: {:?}", result.err()); - - let script_path = result.unwrap(); - assert!(script_path.exists(), "Script should exist after extraction"); - - // Check it's executable - #[cfg(unix)] - { - let metadata = fs::metadata(&script_path).unwrap(); - let mode = metadata.permissions().mode(); - assert!(mode & 0o111 != 0, "Script should be executable"); - } - - // Check content - let content = fs::read_to_string(&script_path).unwrap(); - assert!(content.starts_with("#!/bin/bash"), "Should be a bash script"); - } - - #[test] - fn test_extract_idempotent() { - let temp = TempDir::new().unwrap(); - - // Extract twice - let path1 = extract_script("research", "g3-research", temp.path()).unwrap(); - let path2 = extract_script("research", "g3-research", temp.path()).unwrap(); - - assert_eq!(path1, path2, "Should return same path"); - } - - #[test] - fn test_version_tracking() { - let temp = TempDir::new().unwrap(); - - // Extract - extract_script("research", "g3-research", temp.path()).unwrap(); - - // Version file should exist - let version_path = temp.path().join(".g3/bin/g3-research.version"); - assert!(version_path.exists(), "Version file should exist"); - - let hash = fs::read_to_string(&version_path).unwrap(); - assert!(!hash.is_empty(), "Version file should contain hash"); - } - - #[test] - fn test_nonexistent_skill() { - let temp = TempDir::new().unwrap(); - - let result = extract_script("nonexistent", "script", temp.path()); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not found")); - } - - #[test] - fn test_nonexistent_script() { - let temp = TempDir::new().unwrap(); - - let result = extract_script("research", "nonexistent", temp.path()); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not found")); - } - - #[test] - fn test_get_script_path() { - let workspace = Path::new("/workspace"); - let path = get_script_path("g3-research", workspace); - assert_eq!(path, PathBuf::from("/workspace/.g3/bin/g3-research")); - } - - #[test] - fn test_compute_hash() { - let hash1 = compute_hash("hello world"); - let hash2 = compute_hash("hello world"); - let hash3 = compute_hash("different content"); - - assert_eq!(hash1, hash2, "Same content should produce same hash"); - assert_ne!(hash1, hash3, "Different content should produce different hash"); - assert_eq!(hash1.len(), 16, "Hash should be 16 hex chars"); - } -} diff --git a/crates/g3-core/src/skills/mod.rs b/crates/g3-core/src/skills/mod.rs index 9152f04..f983d53 100644 --- a/crates/g3-core/src/skills/mod.rs +++ b/crates/g3-core/src/skills/mod.rs @@ -39,7 +39,6 @@ mod parser; mod discovery; mod prompt; mod embedded; -pub mod extraction; pub use parser::Skill; pub use discovery::discover_skills; diff --git a/crates/g3-core/src/tools/file_ops.rs b/crates/g3-core/src/tools/file_ops.rs index e1dc6de..29b2e52 100644 --- a/crates/g3-core/src/tools/file_ops.rs +++ b/crates/g3-core/src/tools/file_ops.rs @@ -9,6 +9,7 @@ use tracing::debug; use crate::ui_writer::UiWriter; use crate::utils::resolve_path_with_unicode_fallback; use crate::utils::apply_unified_diff_to_string; +use crate::skills::get_embedded_skill; use crate::ToolCall; use super::executor::ToolContext; @@ -68,6 +69,28 @@ fn calculate_read_limit(file_bytes: usize, total_tokens: u32, used_tokens: u32) Some(max_bytes) } +/// Try to read an embedded skill by path. +/// +/// Recognizes paths like: +/// - `/SKILL.md` +/// - `/SKILL.md` +/// +/// Returns the skill content if found, None otherwise. +fn try_read_embedded_skill(path: &str) -> Option<&'static str> { + // Check for embedded skill path pattern: /SKILL.md + if !path.starts_with("/SKILL.md" + let after_prefix = path.strip_prefix("').next()?; + + // Look up the embedded skill + let skill = get_embedded_skill(skill_name)?; + Some(skill.skill_md) +} + /// Execute the `read_file` tool. pub async fn execute_read_file( tool_call: &ToolCall, @@ -80,13 +103,7 @@ pub async fn execute_read_file( None => return Ok("āŒ Missing file_path argument".to_string()), }; - // Expand tilde (~) to home directory - let expanded_path = shellexpand::tilde(file_path); - // Try to resolve with Unicode space fallback (macOS uses U+202F in screenshot names) - let resolved_path = resolve_path_with_unicode_fallback(expanded_path.as_ref()); - let path_str = resolved_path.as_ref(); - - // Extract optional start and end positions + // Extract optional start and end positions (needed for both embedded and file reads) let start_char = tool_call .args .get("start") @@ -98,6 +115,25 @@ pub async fn execute_read_file( .and_then(|v| v.as_u64()) .map(|n| n as usize); + // Check for embedded skill paths (e.g., "/SKILL.md") + if let Some(content) = try_read_embedded_skill(file_path) { + let total_len = content.len(); + let start = start_char.unwrap_or(0); + let end = end_char.unwrap_or(total_len).min(total_len); + if start >= total_len { + return Ok(format!("āŒ Start position {} exceeds embedded skill length {}", start, total_len)); + } + let slice = &content[start..end]; + let line_count = slice.lines().count(); + return Ok(format!("{}\nšŸ” {} lines read (embedded skill)", slice, line_count)); + } + + // Expand tilde (~) to home directory + let expanded_path = shellexpand::tilde(file_path); + // Try to resolve with Unicode space fallback (macOS uses U+202F in screenshot names) + let resolved_path = resolve_path_with_unicode_fallback(expanded_path.as_ref()); + let path_str = resolved_path.as_ref(); + debug!( "Reading file: {}, start={:?}, end={:?}", path_str, start_char, end_char diff --git a/skills/research/SKILL.md b/skills/research/SKILL.md index 1f503e3..5413c1c 100644 --- a/skills/research/SKILL.md +++ b/skills/research/SKILL.md @@ -5,115 +5,114 @@ license: Apache-2.0 compatibility: Requires g3 binary in PATH. WebDriver (Safari or Chrome) recommended for best results. metadata: author: g3 - version: "1.0" + version: "2.0" --- # Research Skill -Perform asynchronous web research without blocking your current work. Research runs in the background and saves results to disk for you to read when ready. +Perform asynchronous web research without blocking your current work. Research runs in the background and results are saved to disk. ## Quick Start ```bash -# Start research (ALWAYS use background_process, never blocking shell) -background_process("research-", ".g3/bin/g3-research 'Your research question here'") +# 1. Create research directory and status file +RESEARCH_ID="research_$(date +%s)_$(head -c 3 /dev/urandom | xxd -p)" +mkdir -p ".g3/research/$RESEARCH_ID" +echo '{"id":"'$RESEARCH_ID'","status":"running","query":"YOUR QUERY"}' > ".g3/research/$RESEARCH_ID/status.json" -# Check status -shell(".g3/bin/g3-research --status ") -# Or list all: -shell(".g3/bin/g3-research --list") +# 2. Start research in background +background_process("research-topic", "g3 --agent scout --new-session --quiet 'Your research question' > .g3/research/$RESEARCH_ID/report.md 2>&1 && sed -i '' 's/running/complete/' .g3/research/$RESEARCH_ID/status.json || sed -i '' 's/running/failed/' .g3/research/$RESEARCH_ID/status.json") -# Read the report when complete -read_file(".g3/research//report.md") +# 3. Check status +cat .g3/research/$RESEARCH_ID/status.json + +# 4. Read report when complete +read_file(".g3/research/$RESEARCH_ID/report.md") ``` -## How It Works +## Step-by-Step Instructions -1. **Start research** - The `g3-research` script spawns a scout agent that performs web research -2. **Background execution** - Research runs asynchronously; you can continue other work -3. **Filesystem handoff** - Results are written to `.g3/research//` with machine-readable status -4. **Read when ready** - Use `read_file` to load the report into context only when needed +### 1. Generate a Unique Research ID + +Use shell to create a unique ID and directory: + +```bash +shell("RESEARCH_ID=\"research_$(date +%s)_$(head -c 3 /dev/urandom | xxd -p)\" && mkdir -p \".g3/research/$RESEARCH_ID\" && echo $RESEARCH_ID") +``` + +Save the returned ID for later use. + +### 2. Write Initial Status File + +```bash +shell("echo '{\"id\":\"\",\"status\":\"running\",\"query\":\"\",\"started_at\":\"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'\"}' > .g3/research//status.json") +``` + +### 3. Start the Scout Agent + +Use `background_process` to run the scout agent (NEVER use blocking `shell`): + +```bash +background_process("research-", "g3 --agent scout --new-session --quiet '' > .g3/research//report.md 2>&1; if [ $? -eq 0 ]; then sed -i '' 's/running/complete/' .g3/research//status.json; else sed -i '' 's/running/failed/' .g3/research//status.json; fi") +``` + +**Important flags:** +- `--agent scout` - Uses the scout agent optimized for web research +- `--new-session` - Starts a fresh session +- `--quiet` - Reduces UI noise in output + +### 4. Check Research Status + +```bash +shell("cat .g3/research//status.json") +``` + +Status values: +- `running` - Research in progress +- `complete` - Report ready to read +- `failed` - Error occurred + +### 5. Read the Report + +Once status is `complete`: + +```bash +read_file(".g3/research//report.md") +``` ## Directory Structure ``` .g3/research/ -ā”œā”€ā”€ research_1738700000_a1b2c3/ -│ ā”œā”€ā”€ status.json # Machine-readable status -│ └── report.md # The research brief (when complete) -└── research_1738700100_d4e5f6/ - ā”œā”€ā”€ status.json - └── report.md +└── research_1738700000_a1b2c3/ + ā”œā”€ā”€ status.json # Machine-readable status + └── report.md # The research brief (when complete) ``` -## status.json Schema - -```json -{ - "id": "research_1738700000_a1b2c3", - "query": "What are the best Rust async runtimes?", - "status": "complete", - "started_at": "2026-02-04T12:00:00Z", - "completed_at": "2026-02-04T12:01:30Z", - "report_path": ".g3/research/research_1738700000_a1b2c3/report.md", - "error": null -} -``` - -**Status values:** -- `running` - Research in progress -- `complete` - Report ready to read -- `failed` - Error occurred (check `error` field) - -## Commands - -### Start Research +## Example: Complete Workflow ```bash -.g3/bin/g3-research "" -``` +# Step 1: Create research task +shell("RESEARCH_ID=\"research_$(date +%s)_$(head -c 3 /dev/urandom | xxd -p)\" && mkdir -p \".g3/research/$RESEARCH_ID\" && echo '{\"id\":\"'$RESEARCH_ID'\",\"status\":\"running\",\"query\":\"Rust async runtimes comparison\"}' > \".g3/research/$RESEARCH_ID/status.json\" && echo $RESEARCH_ID") +# Returns: research_1738700000_a1b2c3 -Outputs the research ID and path on success. **Always run via `background_process`**, not `shell`. +# Step 2: Start scout in background +background_process("research-rust-async", "g3 --agent scout --new-session --quiet 'Compare Tokio vs async-std vs smol for Rust async runtimes. Include performance, ecosystem, and ease of use.' > .g3/research/research_1738700000_a1b2c3/report.md 2>&1; [ $? -eq 0 ] && sed -i '' 's/running/complete/' .g3/research/research_1738700000_a1b2c3/status.json || sed -i '' 's/running/failed/' .g3/research/research_1738700000_a1b2c3/status.json") -### Check Status - -```bash -# Check specific research -.g3/bin/g3-research --status - -# List all research tasks -.g3/bin/g3-research --list -``` - -Outputs JSON for machine parsing. - -### Read Report - -Once status is `complete`, read the report: - -```bash -read_file(".g3/research//report.md") -``` - -**Tip:** If the report is large, use partial reads: -```bash -read_file(".g3/research//report.md", start=0, end=2000) -``` - -## Example Workflow - -``` -# 1. Start research on async runtimes -background_process("research-async", ".g3/bin/g3-research 'Compare Tokio vs async-std vs smol for Rust async runtimes'") - -# 2. Continue with other work while research runs... +# Step 3: Continue other work... shell("cargo check") -# 3. Check if research is done -shell(".g3/bin/g3-research --list") +# Step 4: Check if done +shell("cat .g3/research/research_1738700000_a1b2c3/status.json") -# 4. Read the report -read_file(".g3/research/research_1738700000_abc123/report.md") +# Step 5: Read report +read_file(".g3/research/research_1738700000_a1b2c3/report.md") +``` + +## Listing All Research Tasks + +```bash +shell("for f in .g3/research/*/status.json; do cat \"$f\" 2>/dev/null; echo; done") ``` ## Best Practices @@ -121,7 +120,7 @@ read_file(".g3/research/research_1738700000_abc123/report.md") 1. **Always use `background_process`** - Never run research with blocking `shell` 2. **Be specific** - Narrow queries get better results faster 3. **Read selectively** - Only load reports into context when you need them -4. **Check status first** - Don't try to read reports that aren't complete +4. **Check status first** - Don't try to read reports that aren't complete yet ## Troubleshooting @@ -129,16 +128,11 @@ read_file(".g3/research/research_1738700000_abc123/report.md") - Try a more specific query - Complex topics may take 1-2 minutes -### WebDriver not available +### WebDriver not available - Research will still work but may have limited web access -- Install Safari WebDriver or Chrome for best results +- The scout agent will fall back to shell-based methods ### Report is empty or failed -- Check `status.json` for error details +- Check status.json for the status +- Look at the report.md file for any error output - The query may be too broad or the topic too obscure - -## Notes - -- Research results accumulate in `.g3/research/` - they are not auto-cleaned -- Each research task gets a unique ID based on timestamp -- Multiple concurrent research tasks are supported diff --git a/skills/research/g3-research b/skills/research/g3-research deleted file mode 100755 index 1f8ea46..0000000 --- a/skills/research/g3-research +++ /dev/null @@ -1,338 +0,0 @@ -#!/bin/bash -# -# g3-research - Perform web research via scout agent with filesystem handoff -# -# Usage: -# g3-research "" Start new research -# g3-research --status Check status of specific research -# g3-research --list List all research tasks -# g3-research --help Show this help -# -# Research results are stored in .g3/research// -# - status.json: Machine-readable status -# - report.md: The research brief (when complete) - -set -euo pipefail - -# Configuration -RESEARCH_DIR=".g3/research" -SCOUT_AGENT="scout" - -# Report markers (must match scout agent output) -REPORT_START_MARKER="---SCOUT_REPORT_START---" -REPORT_END_MARKER="---SCOUT_REPORT_END---" - -####################################### -# Generate a unique research ID -####################################### -generate_id() { - local timestamp - local random_suffix - timestamp=$(date +%s) - random_suffix=$(head -c 6 /dev/urandom | xxd -p | head -c 6) - echo "research_${timestamp}_${random_suffix}" -} - -####################################### -# Get current ISO 8601 timestamp -####################################### -get_timestamp() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -####################################### -# Write status.json file -# Arguments: -# $1 - research directory -# $2 - id -# $3 - query -# $4 - status (running|complete|failed) -# $5 - started_at -# $6 - completed_at (optional, use "null" for running) -# $7 - error (optional, use "null" for success) -####################################### -write_status() { - local dir="$1" - local id="$2" - local query="$3" - local status="$4" - local started_at="$5" - local completed_at="$6" - local error="$7" - - # Escape query for JSON (handle quotes and newlines) - local escaped_query - escaped_query=$(echo -n "$query" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\n/\\n/g') - - # Format completed_at and error as JSON values - local completed_json - local error_json - if [[ "$completed_at" == "null" ]]; then - completed_json="null" - else - completed_json="\"$completed_at\"" - fi - if [[ "$error" == "null" ]]; then - error_json="null" - else - # Escape error message for JSON - local escaped_error - escaped_error=$(echo -n "$error" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\n/\\n/g' | head -c 1000) - error_json="\"$escaped_error\"" - fi - - cat > "${dir}/status.json" << EOF -{ - "id": "${id}", - "query": "${escaped_query}", - "status": "${status}", - "started_at": "${started_at}", - "completed_at": ${completed_json}, - "report_path": "${dir}/report.md", - "error": ${error_json} -} -EOF -} - -####################################### -# Extract report from scout output -# Arguments: -# $1 - scout output file -# Returns: -# Report content between markers, or empty if not found -####################################### -strip_ansi() { - # Comprehensive ANSI escape sequence stripping - perl -pe 's/\e\[[0-9;]*[a-zA-Z]//g; s/\e\][^\a]*\a//g; s/\e[()][AB012]//g' -} - -extract_report() { - local output_file="$1" - local report - - # Use sed to extract content between markers - report=$(sed -n "/${REPORT_START_MARKER}/,/${REPORT_END_MARKER}/p" "$output_file" | \ - sed "1d;\$d" | \ - strip_ansi) # Remove markers and strip ANSI codes - - if [[ -n "$report" ]]; then - echo "$report" - return 0 - fi - - # Fallback: if no markers found, try to extract useful content from raw output - # Strip ANSI escape codes and g3 UI elements - report=$(cat "$output_file" | \ - strip_ansi | \ - grep -v '^šŸ†• Starting new session' | \ - grep -v '^>> agent mode' | \ - grep -v '^\[38;' | \ - grep -v '^-> ~' | \ - grep -v '^ *āœ“' | \ - grep -v '^šŸ“ Auto-memory:' | \ - grep -v 'Auto-memory:' | \ - grep -v '^$' | \ - sed '/^[[:space:]]*$/d' | \ - head -500) - - if [[ -n "$report" ]]; then - echo "$report" - return 0 - fi -} - -####################################### -# Run research -# Arguments: -# $1 - query -####################################### -run_research() { - local query="$1" - local id - local research_dir - local started_at - local output_file - local exit_code - - # Generate unique ID and create directory - id=$(generate_id) - research_dir="${RESEARCH_DIR}/${id}" - mkdir -p "$research_dir" - - started_at=$(get_timestamp) - output_file="${research_dir}/scout_output.txt" - - # Write initial status - write_status "$research_dir" "$id" "$query" "running" "$started_at" "null" "null" - - # Output the research ID immediately so caller knows where to look - echo "{\"id\": \"${id}\", \"status\": \"running\", \"path\": \"${research_dir}\"}" - - # Find g3 binary - local g3_bin - if command -v g3 &> /dev/null; then - g3_bin="g3" - elif [[ -x "./target/release/g3" ]]; then - g3_bin="./target/release/g3" - elif [[ -x "./target/debug/g3" ]]; then - g3_bin="./target/debug/g3" - else - write_status "$research_dir" "$id" "$query" "failed" "$started_at" "$(get_timestamp)" "g3 binary not found in PATH or target/" - echo "{\"id\": \"${id}\", \"status\": \"failed\", \"error\": \"g3 binary not found\"}" >&2 - exit 1 - fi - - # Run scout agent and capture output - set +e - "$g3_bin" --agent "$SCOUT_AGENT" --new-session --quiet "$query" > "$output_file" 2>&1 - exit_code=$? - set -e - - local completed_at - completed_at=$(get_timestamp) - - if [[ $exit_code -ne 0 ]]; then - # Scout failed - local error_msg - error_msg=$(tail -20 "$output_file" 2>/dev/null || echo "Unknown error") - write_status "$research_dir" "$id" "$query" "failed" "$started_at" "$completed_at" "$error_msg" - echo "{\"id\": \"${id}\", \"status\": \"failed\", \"error\": \"Scout agent exited with code ${exit_code}\"}" >&2 - exit 1 - fi - - # Extract report from output - local report - report=$(extract_report "$output_file") - - if [[ -z "$report" ]]; then - write_status "$research_dir" "$id" "$query" "failed" "$started_at" "$completed_at" "Scout did not produce a valid report (missing markers)" - echo "{\"id\": \"${id}\", \"status\": \"failed\", \"error\": \"No report markers found in output\"}" >&2 - exit 1 - fi - - # Write report to file - echo "$report" > "${research_dir}/report.md" - - # Update status to complete - write_status "$research_dir" "$id" "$query" "complete" "$started_at" "$completed_at" "null" - - # Clean up scout output (optional - keep for debugging) - # rm -f "$output_file" - - echo "{\"id\": \"${id}\", \"status\": \"complete\", \"report_path\": \"${research_dir}/report.md\"}" -} - -####################################### -# Check status of a specific research task -# Arguments: -# $1 - research ID -####################################### -check_status() { - local id="$1" - local status_file="${RESEARCH_DIR}/${id}/status.json" - - if [[ ! -f "$status_file" ]]; then - echo "{\"error\": \"Research task not found: ${id}\"}" >&2 - exit 1 - fi - - cat "$status_file" -} - -####################################### -# List all research tasks -####################################### -list_research() { - if [[ ! -d "$RESEARCH_DIR" ]]; then - echo "[]" - return - fi - - local first=true - echo "[" - - for status_file in "${RESEARCH_DIR}"/*/status.json; do - if [[ ! -f "$status_file" ]]; then - continue - fi - - if [[ "$first" == true ]]; then - first=false - else - echo "," - fi - - cat "$status_file" - done - - echo "]" -} - -####################################### -# Show help -####################################### -show_help() { - cat << 'EOF' -g3-research - Perform web research via scout agent - -USAGE: - g3-research "" Start new research - g3-research --status Check status of specific research - g3-research --list List all research tasks - g3-research --help Show this help - -EXAMPLES: - # Start research (run via background_process) - g3-research "What are the best Rust HTTP client libraries?" - - # Check status - g3-research --status research_1738700000_a1b2c3 - - # List all research - g3-research --list - -OUTPUT: - All commands output JSON for machine parsing. - Research results are stored in .g3/research// - -FILES: - .g3/research//status.json Machine-readable status - .g3/research//report.md Research brief (when complete) -EOF -} - -####################################### -# Main -####################################### -main() { - if [[ $# -eq 0 ]]; then - show_help - exit 1 - fi - - case "$1" in - --help|-h) - show_help - ;; - --status) - if [[ $# -lt 2 ]]; then - echo "{\"error\": \"Missing research ID\"}" >&2 - exit 1 - fi - check_status "$2" - ;; - --list) - list_research - ;; - -*) - echo "{\"error\": \"Unknown option: $1\"}" >&2 - exit 1 - ;; - *) - # Treat as query - run_research "$1" - ;; - esac -} - -main "$@"