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 (<embedded:name>/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 <embedded:...> path syntax.
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 14:22:17 +11:00
parent 788debb93a
commit cff32bf0ba
7 changed files with 130 additions and 710 deletions

View File

@@ -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<Mutex<HashMap>>)
- `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.

View File

@@ -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]

View File

@@ -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<PathBuf> {
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<Vec<(String, PathBuf)>> {
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<bool> {
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");
}
}

View File

@@ -39,7 +39,6 @@ mod parser;
mod discovery;
mod prompt;
mod embedded;
pub mod extraction;
pub use parser::Skill;
pub use discovery::discover_skills;

View File

@@ -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:
/// - `<embedded:research>/SKILL.md`
/// - `<embedded:skill-name>/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: <embedded:name>/SKILL.md
if !path.starts_with("<embedded:") {
return None;
}
// Extract skill name from path like "<embedded:research>/SKILL.md"
let after_prefix = path.strip_prefix("<embedded:")?;
let skill_name = after_prefix.split('>').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<W: UiWriter>(
tool_call: &ToolCall,
@@ -80,13 +103,7 @@ pub async fn execute_read_file<W: UiWriter>(
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<W: UiWriter>(
.and_then(|v| v.as_u64())
.map(|n| n as usize);
// Check for embedded skill paths (e.g., "<embedded:research>/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

View File

@@ -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-<topic>", ".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 <research-id>")
# 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/<research-id>/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/<id>/` 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\":\"<RESEARCH_ID>\",\"status\":\"running\",\"query\":\"<YOUR_QUERY>\",\"started_at\":\"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'\"}' > .g3/research/<RESEARCH_ID>/status.json")
```
### 3. Start the Scout Agent
Use `background_process` to run the scout agent (NEVER use blocking `shell`):
```bash
background_process("research-<topic>", "g3 --agent scout --new-session --quiet '<Your detailed research question>' > .g3/research/<RESEARCH_ID>/report.md 2>&1; if [ $? -eq 0 ]; then sed -i '' 's/running/complete/' .g3/research/<RESEARCH_ID>/status.json; else sed -i '' 's/running/failed/' .g3/research/<RESEARCH_ID>/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/<RESEARCH_ID>/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/<RESEARCH_ID>/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 "<query>"
```
# 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 <research-id>
# 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/<research-id>/report.md")
```
**Tip:** If the report is large, use partial reads:
```bash
read_file(".g3/research/<id>/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

View File

@@ -1,338 +0,0 @@
#!/bin/bash
#
# g3-research - Perform web research via scout agent with filesystem handoff
#
# Usage:
# g3-research "<query>" Start new research
# g3-research --status <id> 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/<id>/
# - 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 "<query>" Start new research
g3-research --status <id> 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/<id>/
FILES:
.g3/research/<id>/status.json Machine-readable status
.g3/research/<id>/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 "$@"