From cb72662a6c15daec4b89f28294cfe792d78aaa3b Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Thu, 30 Jul 2026 19:16:18 -0400 Subject: [PATCH] initial --- README.md | 212 +++++++++ ai.sh | 1262 ++++++++++++++++++++++++++++++++++++++++++++++++++++ readme2.md | 314 +++++++++++++ 3 files changed, 1788 insertions(+) create mode 100644 README.md create mode 100755 ai.sh create mode 100644 readme2.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f3a088 --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +--- +slug: aish +title: AI.sh +summary: A simple shell script agent +image: https://example.com/cover.png +tags: [ai, agent, aiharness] +--- + +# aish + +``` + , , + (\____/) + (_oo_) + (O) -- ai.sh + __||__ \) + []/______\[] / + / \______/ \/ + / /__\ + (\ /____\ +``` + +## Idea +How simple, lightweight and reliable can an agent be? + +## Solution +- One shell script. +- Use tried and tested linux programs like curl and jq. +- Log file is simple so you can read it, and it is used for agent context. +- Just give the agent shell access. No special commands, no context bloat. +- Fast +- This repo has 2 files. + + +## Usage + +Ask the agent a question (no quotes needed for simple text): + +```sh +./ai.sh what is the capital of Japan? +./ai.sh -v what is 2 plus 2 +./ai.sh -m gpt-4o "write a hello world program in python. save it in ~/" +``` + +The agent can run shell commands, read files, and execute multi-step +tasks. Context is saved and resumed on subsequent calls. + +### Command mode + +Describe a command in English. The LLM generates the shell command, +prints it, and runs it in your terminal: + +```sh +./ai.sh -c show me all files including hidden ones +./ai.sh -c find all python files modified this week +./ai.sh -c show me disk usage sorted by size +``` + +### Autonomous mode + +Give the agent a goal and let it run until done or budget exhausted: + +```sh +./ai.sh -a "explore the codebase and summarize the architecture" +``` + +### Initialize a local agent + +By default, aish stores everything globally in `~/.aish/`. To create a +self-contained agent for a specific project: + +```sh +./ai.sh -i +``` + +This creates a `.aish/` directory in the current folder with its own +config, system prompt, and log. While `.aish/` exists, all storage is +local to that directory. Remove it to return to global mode. + +### Piping + +The agent's answer goes to stdout; status messages go to stderr. So you +can pipe the answer into other commands: + +```sh +./ai.sh what is the capital of Japan | wc -w +./ai.sh list all python files in this dir | grep test +./ai.sh what is 2+2 > answer.txt +``` + +You can also pipe input in. If no message is given on the command line, +stdin becomes the message. If both are given, stdin is appended: + +```sh +echo "explain this error" | ./ai.sh +cat error.log | ./ai.sh explain this error +git diff | ./ai.sh -c generate a commit message +``` + +## Quick start + +```sh +# 1. Install dependencies +sudo apt install curl jq coreutils # Debian/Ubuntu + +# 2. Add your API key (get one from https://ppq.ai) +cat > ~/.aish.conf <<'EOF' +DEFAULT_API_KEY="sk-your-key-here" +EOF +chmod 600 ~/.aish.conf + +# 3. Run it +./ai.sh what is the capital of Japan +``` + +That's it. No build step, no install script, no daemon. + +## Options + +| Short | Long | Description | +|-------|------|-------------| +| `-c` | `--cmd` | Command mode (generate and run a shell command) | +| `-a` | `--autonomous` | Autonomous mode (run until done or budget exhausted) | +| `-i` | `--init` | Create local `.aish/` with defaults, exit | +| `-x` | `--clear` | Clear the log file and exit | +| `-v` | `--verbose` | Show status lines | +| `-m` | `--model` | Override model | +| `-k` | `--api-key` | API key (or set `PPQ_API_KEY`) | +| `-C` | `--max-cost` | Override max cost (USD) | +| `-s` | `--max-steps` | Override max steps | +| `-t` | `--timeout` | Override overall timeout (seconds) | +| `-T` | `--cmd-timeout` | Override per-command timeout (seconds) | +| `-r` | `--resume` | Resume from existing log (default: yes) | +| `-R` | `--no-resume` | Start fresh (ignore existing log) | +| `-h` | `--help` | Show help | + +## Configuration + +### API key + +Create `~/.aish.conf` with your key: + +```sh +cat > ~/.aish.conf <<'EOF' +DEFAULT_API_KEY="sk-your-key-here" +EOF +chmod 600 ~/.aish.conf +``` + +This file lives in your home directory, outside the repo, and is never +committed. Any `DEFAULT_*` variable from `ai.sh` can be overridden there. + +### Config priority + +Settings are resolved in this order (first match wins): + +1. CLI flags (`-m`, `-C`, `-s`, etc.) +2. Local `.aish/config.json` (if `.aish/` exists in current directory) +3. Global `~/.aish/config.json` +4. `~/.aish.conf` shell vars +5. Built-in defaults in `ai.sh` + +### Storage + +**Global mode** (default): `~/.aish/` holds config, system prompt, and +log. The agent has shared global memory — conversations from any +directory are logged to the same file and resumed on subsequent calls. + +**Local mode**: Run `./ai.sh -i` to create a `.aish/` in the current +directory. While it exists, that directory uses its own config, prompt, +and log instead of the global ones. + +## Dependencies + +- `curl` +- `jq` +- coreutils (`date`, `mkdir`, `chmod`, `head`, `cat`, `printf`, `mktemp`, `timeout`, `sync`, `basename`, `sleep`, `wc`, `tail`, `sed`, `grep`) + +```sh +# Debian / Ubuntu +sudo apt install curl jq coreutils + +# Fedora / RHEL +sudo dnf install curl jq coreutils + +# Arch +sudo pacman -S curl jq coreutils + +# macOS (Homebrew — coreutils provides GNU versions like `timeout`) +brew install curl jq coreutils +``` + +## Log format + +Each line in `log.jsonl` is JSON with a `source` field: + +| source | event | who | +|--------|-------|-----| +| `user` | `user_input` | you | +| `model` | `response` | the AI | +| `aish` | `exec` | script ran a command | +| `aish` | `api_error` | script detected an error | + +## Warning + +**This agent has full shell access.** It can run any command on your +system — read files, write files, delete data, install software, make +network requests. There are no sandboxing or permission restrictions. + +Run it only on a computer, VM, or Qube that is dedicated to agent use. +Do not run it on a machine with sensitive data or credentials you cannot +afford to lose. diff --git a/ai.sh b/ai.sh new file mode 100755 index 0000000..38e6df3 --- /dev/null +++ b/ai.sh @@ -0,0 +1,1262 @@ +#!/bin/sh +# ai.sh — aish: AI shell agent. +# +# An AI agent with shell access. By default, context (log, config, prompt) +# is stored globally in ~/.aish/. Use --init to create a local .aish/ in +# the current directory for a customized, self-contained agent. +# +# Usage: +# ./ai.sh what is the capital of Japan +# One-shot: process message, save context, exit +# (multi-word messages need no quotes) +# ./ai.sh -a "goal" Autonomous: run until done or budget exhausted +# ./ai.sh -c find python files Command mode: LLM generates a shell command +# from the description, prints it, and runs it +# ./ai.sh -i Initialize a local .aish/ in the current dir +# +# Dependencies: curl, jq, coreutils (date, mkdir, chmod, head, cat, printf, +# mktemp, timeout, sync, basename, sleep, wc, tail, sed, grep). +# No build step. + +# NOTE: intentionally NOT using `set -e`. This script has many intentional +# non-zero return paths (budget_check, api_call, agent_loop_run, the +# `[ test ] && action` override pattern in config_load). `set -e` would +# abort on those. Instead, errors are handled explicitly at every critical +# point: curl exit codes, jq parse fallbacks, api_call/budget_check return +# values checked in `if` context. This mirrors how the C code checks +# return values rather than relying on a global trap. + +# ===================================================================== +# Defaults (src/defaults.h) +# ===================================================================== +# These are the built-in fallbacks. Any of these can be overridden by +# ~/.aish.conf (sourced below if present), ~/.aish/config.json (global), +# or .aish/config.json (local, if .aish/ exists in the current directory). + +# DEFAULT_MODEL="glm-5.2" +DEFAULT_MODEL="gpt-5.4-mini" +# DEFAULT_MODEL="~google/gemini-flash-latest" +DEFAULT_API_BASE="https://api.ppq.ai" +DEFAULT_API_KEY="" +DEFAULT_MAX_COST="10.0" # USD +DEFAULT_MAX_STEPS="500" +DEFAULT_TIMEOUT="7200" # seconds, overall +DEFAULT_CMD_TIMEOUT="60" # seconds, per shell command +DEFAULT_MAX_OUTPUT_TOKENS="65536" +DEFAULT_TEMPERATURE="0.7" + +# Load global user config if present (overrides defaults above). +# Create ~/.aish.conf with: DEFAULT_API_KEY="sk-..." +# See README.md for details. +[ -f "$HOME/.aish.conf" ] && . "$HOME/.aish.conf" + +# Buffer limits (src/defaults.h:36) +MAX_STDOUT_CHARS=8000 +MAX_STDERR_CHARS=4000 + +# API constants (src/defaults.h:54) +API_TIMEOUT_SECONDS=120 +API_MAX_RETRIES=3 +API_BACKOFF_BASE_SEC=5 # 5s, 10s, 15s + +# Paths +AISH_DIR=".aish" +LOG_FILENAME="log.jsonl" +CONFIG_FILENAME="config.json" +PROMPT_FILENAME="system_prompt.txt" +SOCK_FILENAME="sock" + +# Default system prompt (src/main.c:33 / prompts/default_system_prompt.txt) +DEFAULT_SYSTEM_PROMPT='You are an AI agent with shell access. You can run any command on this +system using the run_shell tool. You are operating in the current working +directory. + +When given a task: +1. Reason about what needs to be done. +2. Use run_shell to explore the environment, read files, run commands. +3. Take action to complete the task. +4. When finished, put your final answer in your text response, then call + done() with a brief summary. + +Your text response is what the user sees. Always include the complete answer +in your text response before calling done(). Do not leave the answer only in +command output — summarize the results in your own words. + +Be concise in your reasoning. Use shell commands efficiently. If a command +fails, examine the error and try a different approach. + +You have full access to the system. Be careful with destructive commands.' + +# Tool descriptions (src/defaults.h:89) +TOOL_RUN_SHELL_DESC='Run a shell command on this system. Returns stdout, stderr, and exit code. Use this to run any program, read/write files, make network requests, use git, etc.' +TOOL_DONE_DESC='Signal that you have completed the task. Call this only when you are truly finished with the current request.' + +# ===================================================================== +# Paths (src/config.c:32) +# ===================================================================== + +CWD="$(pwd)" +LOCAL_DIR="$CWD/$AISH_DIR" +GLOBAL_DIR="$HOME/.aish" + +# Local mode: .aish/ exists in the current directory. +# Global mode (default): use ~/.aish/ for all storage. +if [ -d "$LOCAL_DIR" ]; then + AISH_MODE="local" + DIR="$LOCAL_DIR" +else + AISH_MODE="global" + DIR="$GLOBAL_DIR" +fi + +LOG_PATH="$DIR/$LOG_FILENAME" +CONFIG_PATH="$DIR/$CONFIG_FILENAME" +PROMPT_PATH="$DIR/$PROMPT_FILENAME" +SOCK_PATH="$DIR/$SOCK_FILENAME" + +# ===================================================================== +# Globals: config + runtime state +# ===================================================================== + +# Config fields (populated by config_load) +CFG_MODEL="" +CFG_API_BASE="" +CFG_MAX_COST="" +CFG_MAX_STEPS="" +CFG_TIMEOUT="" +CFG_CMD_TIMEOUT="" +CFG_MAX_OUTPUT_TOKENS="" +CFG_TEMPERATURE="" + +# Runtime +MODE="standalone" # standalone | autonomous +MESSAGE="" +RESUME=1 +API_KEY="" + +# Budget (src/budget.c) +BUDGET_STEP=0 +BUDGET_COST=0.0 +BUDGET_START=0 + +# Conversation: a temp file holding one JSON message object per line. +# Assembled into an array with jq -s '.' when building the API request. +MESSAGES_FILE="" + +# ===================================================================== +# Usage (src/main.c:50) +# ===================================================================== + +usage() { + prog="$1" + cat >&2 < local .aish/config.json (if .aish/ exists) +> ~/.aish/config.json > ~/.aish.conf shell vars > built-in defaults. +EOF +} + +# ===================================================================== +# Arg parsing defaults (actual parsing happens in main flow below) +# ===================================================================== + +IS_AUTONOMOUS=0 +CMD_MODE=0 +INIT_MODE=0 +CLEAR_MODE=0 +VERBOSE=0 +MODEL_OVERRIDE="" +API_KEY_OVERRIDE="" +HAS_MAX_COST=0 +HAS_MAX_STEPS=0 +HAS_TIMEOUT=0 +HAS_CMD_TIMEOUT=0 +MAX_COST_OVERRIDE="" +MAX_STEPS_OVERRIDE="" +TIMEOUT_OVERRIDE="" +CMD_TIMEOUT_OVERRIDE="" + +# ===================================================================== +# Ensure data dir exists + default config + default prompt +# In global mode, creates ~/.aish/ if missing. In local mode, .aish/ +# already exists (we checked at path resolution), so this just ensures +# the config and prompt files exist. +# ===================================================================== + +ensure_data_dir() { + mkdir -p "$DIR" + chmod 700 "$DIR" 2>/dev/null || true + + # Default config.json (src/config.c:107) + if [ ! -f "$CONFIG_PATH" ]; then + jq -n \ + --arg model "$DEFAULT_MODEL" \ + --arg base "$DEFAULT_API_BASE" \ + --argjson maxcost "$DEFAULT_MAX_COST" \ + --argjson maxsteps "$DEFAULT_MAX_STEPS" \ + --argjson timeout "$DEFAULT_TIMEOUT" \ + --argjson cmdtimeout "$DEFAULT_CMD_TIMEOUT" \ + --arg prompt "$PROMPT_FILENAME" \ + --argjson maxtok "$DEFAULT_MAX_OUTPUT_TOKENS" \ + --argjson temp "$DEFAULT_TEMPERATURE" \ + '{model:$model, api_base:$base, max_cost:$maxcost, max_steps:$maxsteps, + timeout:$timeout, cmd_timeout:$cmdtimeout, system_prompt:$prompt, + max_output_tokens:$maxtok, temperature:$temp}' \ + > "$CONFIG_PATH" + fi + + # Default system_prompt.txt (src/config.c:122) + if [ ! -f "$PROMPT_PATH" ]; then + printf '%s\n' "$DEFAULT_SYSTEM_PROMPT" > "$PROMPT_PATH" + fi +} + +# ===================================================================== +# Config load (src/config.c:162) +# ===================================================================== + +config_load() { + # Read each field with jq, falling back to defaults (src/config.c:174) + CFG_MODEL="$(jq -r --arg d "$DEFAULT_MODEL" '.model // $d' "$CONFIG_PATH")" + CFG_API_BASE="$(jq -r --arg d "$DEFAULT_API_BASE" '.api_base // $d' "$CONFIG_PATH")" + CFG_MAX_COST="$(jq -r --argjson d "$DEFAULT_MAX_COST" '.max_cost // $d' "$CONFIG_PATH")" + CFG_MAX_STEPS="$(jq -r --argjson d "$DEFAULT_MAX_STEPS" '.max_steps // $d' "$CONFIG_PATH")" + CFG_TIMEOUT="$(jq -r --argjson d "$DEFAULT_TIMEOUT" '.timeout // $d' "$CONFIG_PATH")" + CFG_CMD_TIMEOUT="$(jq -r --argjson d "$DEFAULT_CMD_TIMEOUT" '.cmd_timeout // $d' "$CONFIG_PATH")" + CFG_MAX_OUTPUT_TOKENS="$(jq -r --argjson d "$DEFAULT_MAX_OUTPUT_TOKENS" '.max_output_tokens // $d' "$CONFIG_PATH")" + CFG_TEMPERATURE="$(jq -r --argjson d "$DEFAULT_TEMPERATURE" '.temperature // $d' "$CONFIG_PATH")" + + # Apply CLI overrides (src/main.c:186) + [ -n "$MODEL_OVERRIDE" ] && CFG_MODEL="$MODEL_OVERRIDE" + [ "$HAS_MAX_COST" = "1" ] && CFG_MAX_COST="$MAX_COST_OVERRIDE" + [ "$HAS_MAX_STEPS" = "1" ] && CFG_MAX_STEPS="$MAX_STEPS_OVERRIDE" + [ "$HAS_TIMEOUT" = "1" ] && CFG_TIMEOUT="$TIMEOUT_OVERRIDE" + [ "$HAS_CMD_TIMEOUT" = "1" ] && CFG_CMD_TIMEOUT="$CMD_TIMEOUT_OVERRIDE" +} + +# Load system prompt: per-project file if present, else default (src/main.c:201) +load_system_prompt() { + if [ -f "$PROMPT_PATH" ]; then + cat "$PROMPT_PATH" + else + printf '%s\n' "$DEFAULT_SYSTEM_PROMPT" + fi +} + +# ===================================================================== +# Logger (src/logger.c) +# ===================================================================== + +# Unix epoch seconds. Compact, timezone-free (epoch is universal), and +# reveals no time-of-day. Convert to a human-readable UTC time later with +# `date -u -d @1754237295` if needed. +log_timestamp() { + date +%s +} + +# Append a pre-built JSON line and flush (src/logger.c:59 write_and_flush). +# Accepts the line either as $1 or via stdin (so callers can pipe jq output). +log_append() { + if [ -n "$1" ]; then + line="$1" + else + line="$(cat)" + fi + [ -z "$line" ] && return 0 + printf '%s\n' "$line" >> "$LOG_PATH" + sync "$LOG_PATH" 2>/dev/null || true +} + +# step entry (src/logger.c:83) +# Model response entry — logs what the model decided (reasoning, command to +# run, or done summary). One entry per API response. This is logged BEFORE +# any command execution (flush-before-act: if we crash mid-execution, the +# log shows the model's decision without a matching exec entry). +# Args: step mode model reasoning command done_summary tokens_in tokens_out step_cost cum_cost elapsed +log_response() { + step="$1"; mode="$2"; model="$3"; reasoning="$4"; command="$5" + done_summary="$6"; tokens_in="$7"; tokens_out="$8" + step_cost="$9"; cum_cost="${10}"; elapsed="${11}" + ts="$(log_timestamp)" + jq -nc \ + --argjson ts "$ts" --argjson step "$step" --arg mode "$mode" --arg model "$model" \ + --arg reasoning "$reasoning" --arg command "$command" --arg done_summary "$done_summary" \ + --argjson tin "$tokens_in" --argjson tout "$tokens_out" \ + --argjson scost "$step_cost" --argjson ccost "$cum_cost" \ + --argjson el "$elapsed" --arg source "model" \ + '{timestamp:$ts, source:$source, event:"response", step:$step, mode:$mode, model:$model, + reasoning:$reasoning, command:$command, done_summary:$done_summary, + tokens_in:$tin, tokens_out:$tout, + step_cost:$scost, cumulative_cost:$ccost, elapsed_seconds:$el}' \ + | log_append +} + +# Command execution entry — logs what aish did when it ran a command. +# One entry per run_shell execution. Logged AFTER execution completes. +# Args: step stdout stderr exit_code cum_cost +log_exec() { + step="$1"; out="$2"; err="$3"; exit_code="$4"; cum_cost="$5" + ts="$(log_timestamp)" + jq -nc \ + --argjson ts "$ts" --argjson step "$step" --arg event "exec" \ + --arg out "$out" --arg err "$err" --argjson ec "$exit_code" \ + --argjson cc "$cum_cost" --arg source "aish" \ + '{timestamp:$ts, source:$source, event:$event, step:$step, + command_output:$out, command_stderr:$err, exit_code:$ec, + cumulative_cost:$cc}' \ + | log_append +} + +# user_input entry (src/logger.c:150) +log_user_input() { + text="$1" + ts="$(log_timestamp)" + jq -nc --argjson ts "$ts" --arg event "user_input" --arg text "$text" \ + --arg source "user" \ + '{timestamp:$ts, source:$source, event:$event, text:$text}' | log_append +} + +# generic event entry (src/logger.c:166) +# Args: event detail_key detail_value step cum_cost +log_event() { + event="$1"; dkey="$2"; dval="$3"; step="$4"; cum_cost="$5" + ts="$(log_timestamp)" + if [ -n "$dkey" ] && [ -n "$dval" ]; then + jq -nc --argjson ts "$ts" --arg event "$event" --arg dkey "$dkey" --arg dval "$dval" \ + --argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \ + '{timestamp:$ts, source:$source, event:$event, ($dkey):$dval, step:$step, cumulative_cost:$cc}' \ + | log_append + else + jq -nc --argjson ts "$ts" --arg event "$event" \ + --argjson step "$step" --argjson cc "$cum_cost" --arg source "aish" \ + '{timestamp:$ts, source:$source, event:$event, step:$step, cumulative_cost:$cc}' \ + | log_append + fi +} + +# api_error entry (src/agent_loop.c:185) +# Args: step retries error +log_api_error() { + step="$1"; retries="$2"; error="$3" + ts="$(log_timestamp)" + jq -nc --argjson ts "$ts" --arg event "api_error" --argjson step "$step" \ + --argjson retries "$retries" --arg error "$error" --arg source "aish" \ + '{timestamp:$ts, source:$source, event:"api_error", step:$step, retries:$retries, error:$error}' \ + | log_append +} + +# ===================================================================== +# Conversation helpers +# ===================================================================== + +# Messages are accumulated in $MESSAGES_FILE, one JSON object per line. +# The system prompt is the first message (src/resume.c:64). + +conv_init() { + MESSAGES_FILE="$(mktemp)" + # system message + jq -nc --arg content "$1" '{role:"system", content:$content}' >> "$MESSAGES_FILE" +} + +conv_append_user() { + jq -nc --arg content "$1" '{role:"user", content:$content}' >> "$MESSAGES_FILE" +} + +conv_append_assistant_text() { + # assistant message with content only (src/agent_loop.c:262) + jq -nc --arg content "$1" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE" +} + +conv_append_assistant_with_tools() { + # assistant message with tool_calls (src/agent_loop.c:382) + # $1 = reasoning (may be empty), $2 = path to a file of tool_call JSON objects (one per line) + reasoning="$1" + tc_file="$2" + if [ -s "$tc_file" ]; then + tool_calls="$(jq -s '.' "$tc_file")" + jq -nc --arg content "$reasoning" --argjson tcs "$tool_calls" \ + '{role:"assistant", content:$content, tool_calls:$tcs}' >> "$MESSAGES_FILE" + else + jq -nc --arg content "$reasoning" '{role:"assistant", content:$content}' >> "$MESSAGES_FILE" + fi +} + +conv_append_tool() { + # tool-role result message (src/agent_loop.c:358) + # $1 = tool_call_id, $2 = tool name, $3 = result content (string) + jq -nc --arg id "$1" --arg name "$2" --arg content "$3" \ + '{role:"tool", tool_call_id:$id, name:$name, content:$content}' >> "$MESSAGES_FILE" +} + +# ===================================================================== +# Resume (src/resume.c:57) +# ===================================================================== + +# Replay log.jsonl into the messages file. Drops incomplete steps. +# Sets RESUME_LAST_STEP and RESUME_CUM_COST. +RESUME_LAST_STEP=0 +RESUME_CUM_COST=0.0 + +resume_from_log() { + system_prompt="$1" + conv_init "$system_prompt" + + [ -f "$LOG_PATH" ] || return 0 + + last_complete_step=0 + pending_command="" + pending_reasoning="" + pending_step="" + + # Read line by line. Lines can be large (command output), so avoid + # `while read` word-splitting issues by using a temp file + jq. + while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + + # Parse fields with jq. Use //empty to get empty string on null. + event="$(printf '%s' "$line" | jq -r '.event // empty' 2>/dev/null)" || continue + step="$(printf '%s' "$line" | jq -r '.step // 0' 2>/dev/null)" || step=0 + cum="$(printf '%s' "$line" | jq -r '.cumulative_cost // 0' 2>/dev/null)" || cum=0 + + # Track last seen cumulative cost (src/resume.c:97) + if [ -n "$cum" ]; then + case "$cum" in + ''|*[!0-9.]*) ;; + *) RESUME_CUM_COST="$cum" ;; + esac + fi + + # user_input event — replay as user message + if [ "$event" = "user_input" ]; then + text="$(printf '%s' "$line" | jq -r '.text // empty')" + [ -n "$text" ] && conv_append_user "$text" + continue + fi + + # exec event — command execution result from aish. + # Pair it with the preceding response entry that had a command. + if [ "$event" = "exec" ]; then + exec_output="$(printf '%s' "$line" | jq -r '.command_output // empty' 2>/dev/null)" + exec_stderr="$(printf '%s' "$line" | jq -r '.command_stderr // empty' 2>/dev/null)" + exec_exit="$(printf '%s' "$line" | jq -r '.exit_code // 0' 2>/dev/null)" + # If we have a pending response with a command, complete it + if [ -n "$pending_command" ]; then + call_id="call_${pending_step}" + tc_tmp="$(mktemp)" + jq -nc --arg id "$call_id" --arg cmd "$pending_command" \ + '{type:"function", id:$id, function:{name:"run_shell", arguments:({"command":$cmd}|tojson)}}' \ + >> "$tc_tmp" + conv_append_assistant_with_tools "$pending_reasoning" "$tc_tmp" + rm -f "$tc_tmp" + result_content="$(jq -nc --arg out "$exec_output" --arg err "$exec_stderr" \ + --argjson ec "$exec_exit" \ + '{stdout:$out, stderr:$err, exit_code:$ec}')" + conv_append_tool "$call_id" "run_shell" "$result_content" + last_complete_step="$pending_step" + pending_command="" + pending_reasoning="" + pending_step="" + fi + continue + fi + + # api_error / budget events — not conversation content + if [ "$event" = "api_error" ] || [ "$event" = "cost budget exceeded" ] \ + || [ "$event" = "max steps reached" ] || [ "$event" = "timeout reached" ]; then + continue + fi + + # response event — model's decision (reasoning, command, or done) + if [ "$event" = "response" ]; then + reasoning="$(printf '%s' "$line" | jq -r '.reasoning // empty' 2>/dev/null)" || reasoning="" + command="$(printf '%s' "$line" | jq -r '.command // empty' 2>/dev/null)" || command="" + done_summary="$(printf '%s' "$line" | jq -r '.done_summary // empty' 2>/dev/null)" || done_summary="" + + if [ -n "$command" ]; then + # Model requested a command — stash and wait for exec entry + pending_command="$command" + pending_reasoning="$reasoning" + pending_step="$step" + elif [ -n "$done_summary" ]; then + # Model called done() — the response entry is the final answer. + # Don't replay done() as conversation; it ends the session. + last_complete_step="$step" + elif [ -n "$reasoning" ]; then + # Assistant text-only message (no command, no done) + conv_append_assistant_text "$reasoning" + last_complete_step="$step" + fi + continue + fi + + # Legacy: old-format "done" and "step_result" events from prior logs + if [ "$event" = "done" ] || [ "$event" = "step_result" ]; then + continue + fi + done < "$LOG_PATH" + + RESUME_LAST_STEP="$last_complete_step" +} + +# ===================================================================== +# Budget (src/budget.c) +# ===================================================================== + +budget_init() { + BUDGET_MAX_COST="$1" + BUDGET_MAX_STEPS="$2" + BUDGET_TIMEOUT="$3" + BUDGET_COST="$4" # seeded from resume + BUDGET_STEP="$5" # seeded from resume + BUDGET_START="$(date +%s)" +} + +budget_elapsed() { + now="$(date +%s)" + echo $(( now - BUDGET_START )) +} + +# Returns 0 (true) if budget ok, 1 (false) if exceeded; sets BUDGET_REASON. +# Order matches src/budget.c:31. +budget_check() { + # cost + if awk "BEGIN { exit !($BUDGET_COST >= $BUDGET_MAX_COST) }"; then + BUDGET_REASON="cost budget exceeded" + return 1 + fi + # steps + if [ "$BUDGET_STEP" -ge "$BUDGET_MAX_STEPS" ]; then + BUDGET_REASON="max steps reached" + return 1 + fi + # time + if [ "$BUDGET_TIMEOUT" -gt 0 ]; then + el="$(budget_elapsed)" + if [ "$el" -gt "$BUDGET_TIMEOUT" ]; then + BUDGET_REASON="timeout reached" + return 1 + fi + fi + BUDGET_REASON="" + return 0 +} + +# Estimate cost for a step (src/budget.c:59). Args: model tokens_in tokens_out +budget_estimate_cost() { + model="$1"; tin="$2"; tout="$3" + case "$model" in + glm-5.2) in_rate=0.85; out_rate=2.68 ;; + gpt-5.3-codex) in_rate=1.85; out_rate=14.77 ;; + gpt-4o) in_rate=2.50; out_rate=10.00 ;; + gpt-4o-mini) in_rate=0.15; out_rate=0.60 ;; + claude-3.5-sonnet) in_rate=3.00; out_rate=15.00 ;; + *) in_rate=1.0; out_rate=5.0 ;; + esac + # cost = (tin*in + tout*out) / 1e6 + awk "BEGIN { printf \"%.6f\", ($tin * $in_rate + $tout * $out_rate) / 1000000.0 }" +} + +# ===================================================================== +# Shell exec (src/shell_exec.c:39) +# ===================================================================== + +# Run a command with timeout, capture stdout/stderr/exit separately. +# Sets SH_OUT, SH_ERR, SH_EXIT. Truncates output. Handles empty + timeout. +# Args: command timeout_seconds +shell_exec() { + command="$1" + timeout_sec="$2" + [ "$timeout_sec" -le 0 ] 2>/dev/null && timeout_sec="$DEFAULT_CMD_TIMEOUT" + + # Empty command (src/shell_exec.c:45) + if [ -z "$command" ]; then + SH_OUT="" + SH_ERR="[empty command]" + SH_EXIT=-1 + return + fi + + err_tmp="$(mktemp)" + # timeout kills the whole process group (src/shell_exec.c:117 killpg) + # --signal=KILL --kill-after=2 ensures the tree dies. + SH_OUT="$(timeout --signal=KILL --kill-after=2 "$timeout_sec" sh -c "$command" 2>"$err_tmp")" + SH_EXIT=$? + SH_ERR="$(cat "$err_tmp")" + rm -f "$err_tmp" + + # timeout(1) exits 124 on timeout, 137 on KILL. Normalize (src/shell_exec.c:120) + if [ "$SH_EXIT" = "124" ] || [ "$SH_EXIT" = "137" ]; then + SH_EXIT=-1 + if [ -z "$SH_ERR" ]; then + SH_ERR="[command timed out — process group killed]" + fi + fi + + # Truncate stdout (src/shell_exec.c:183) + if [ -n "$SH_OUT" ]; then + out_len="$(printf '%s' "$SH_OUT" | wc -c)" + if [ "$out_len" -gt "$MAX_STDOUT_CHARS" ]; then + SH_OUT="$(printf '%s' "$SH_OUT" | head -c "$MAX_STDOUT_CHARS") +...[truncated at ${MAX_STDOUT_CHARS} chars]" + fi + fi + # Truncate stderr (src/shell_exec.c:199) + if [ -n "$SH_ERR" ]; then + err_len="$(printf '%s' "$SH_ERR" | wc -c)" + if [ "$err_len" -gt "$MAX_STDERR_CHARS" ]; then + SH_ERR="$(printf '%s' "$SH_ERR" | head -c "$MAX_STDERR_CHARS") +...[truncated at ${MAX_STDERR_CHARS} chars]" + fi + fi +} + +# ===================================================================== +# API request building (src/json_util.c) +# ===================================================================== + +# Build the tools array (constant). Matches src/json_util.c:12. +build_tools_json() { + jq -nc \ + --arg rsdesc "$TOOL_RUN_SHELL_DESC" --arg ddesc "$TOOL_DONE_DESC" \ + '[ + {type:"function", + function:{name:"run_shell", description:$rsdesc, + parameters:{type:"object", + properties:{command:{type:"string",description:"The shell command to execute."}, + timeout_seconds:{type:"integer",description:"Optional timeout for this command in seconds (default 60)."}}, + required:["command"]}}}, + {type:"function", + function:{name:"done", description:$ddesc, + parameters:{type:"object", + properties:{summary:{type:"string",description:"A brief summary of the outcome."}}, + required:["summary"]}}} + ]' +} + +# Build the full request body (src/json_util.c:77). +# Args: model max_tokens temperature +build_request_body() { + model="$1"; max_tokens="$2"; temperature="$3" + tools_json="$(build_tools_json)" + messages_json="$(jq -s '.' "$MESSAGES_FILE")" + jq -nc \ + --arg model "$model" --argjson messages "$messages_json" \ + --argjson tools "$tools_json" --argjson maxtok "$max_tokens" \ + --argjson temp "$temperature" \ + '{model:$model, messages:$messages, tools:$tools, + tool_choice:"auto", temperature:$temp, max_tokens:$maxtok}' +} + +# ===================================================================== +# API client (src/api_client.c:93) +# ===================================================================== + +# Call the API with retry + backoff. Sets API_RESPONSE (body) on success. +# Returns 0 on success, 1 on failure. Logs api_error on final failure. +# Args: step (for logging) +api_call() { + step="$1" + # Strip trailing slash from api_base (src/api_client.c:110) + base="${CFG_API_BASE%/}" + url="${base}/chat/completions" + + body="$(build_request_body "$CFG_MODEL" "$CFG_MAX_OUTPUT_TOKENS" "$CFG_TEMPERATURE")" + + attempt=1 + while [ "$attempt" -le "$API_MAX_RETRIES" ]; do + # curl: -sS silent but show errors, --max-time overall timeout, + # -w '\n%{http_code}' appends HTTP status as last line. + resp_with_code="$(printf '%s' "$body" | \ + curl -sS -X POST "$url" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_KEY" \ + --data-binary @- \ + --max-time "$API_TIMEOUT_SECONDS" \ + -w '\n%{http_code}' 2>&1)" || curl_rc=$? + + curl_rc=${curl_rc:-0} + + if [ "$curl_rc" != "0" ]; then + # curl-level failure (network, timeout) — retry + if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then + sleep "$(( API_BACKOFF_BASE_SEC * attempt ))" + attempt=$((attempt+1)) + continue + fi + log_api_error "$step" "$API_MAX_RETRIES" "curl error: rc=$curl_rc" + return 1 + fi + + # Split body and status (last line is the http code) + http_code="$(printf '%s' "$resp_with_code" | tail -n1)" + resp_body="$(printf '%s' "$resp_with_code" | sed '$d')" + + case "$http_code" in + 2*) + # Check for an error object in the body — some providers + # return HTTP 200 with {"error":{...}} on rate limits etc. + err_msg="$(printf '%s' "$resp_body" | jq -r '.error.message // .error // empty' 2>/dev/null)" + if [ -n "$err_msg" ]; then + if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then + sleep "$(( API_BACKOFF_BASE_SEC * attempt ))" + attempt=$((attempt+1)) + continue + fi + log_api_error "$step" "$API_MAX_RETRIES" "API error (HTTP $http_code): $err_msg" + return 1 + fi + API_RESPONSE="$resp_body" + return 0 + ;; + *) + # Non-2xx — retry (mirrors C: retries on any non-2xx) + if [ "$attempt" -lt "$API_MAX_RETRIES" ]; then + sleep "$(( API_BACKOFF_BASE_SEC * attempt ))" + attempt=$((attempt+1)) + continue + fi + log_api_error "$step" "$API_MAX_RETRIES" "HTTP $http_code: $resp_body" + return 1 + ;; + esac + done + + log_api_error "$step" "$API_MAX_RETRIES" "no attempts succeeded" + return 1 +} + +# ===================================================================== +# Response parsing (src/json_util.c:128) +# ===================================================================== + +# Parse API_RESPONSE into globals: +# RESP_CONTENT, RESP_TOKENS_IN, RESP_TOKENS_OUT +# RESP_TOOL_CALLS_FILE — temp file with one tool_call object per line: +# {id, name, arguments} (arguments is the raw JSON string from the API) +# RESP_TOOL_CALL_COUNT +parse_response() { + RESP_CONTENT="$(printf '%s' "$API_RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null)" || RESP_CONTENT="" + RESP_TOKENS_IN="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.prompt_tokens // 0' 2>/dev/null)" || RESP_TOKENS_IN=0 + RESP_TOKENS_OUT="$(printf '%s' "$API_RESPONSE" | jq -r '.usage.completion_tokens // 0' 2>/dev/null)" || RESP_TOKENS_OUT=0 + + RESP_TOOL_CALLS_FILE="$(mktemp)" + # Extract each tool call as {id, name, arguments} (src/json_util.c:154) + printf '%s' "$API_RESPONSE" | jq -c '.choices[0].message.tool_calls[]? | + {id:(.id // "call"), name:(.function.name // ""), arguments:(.function.arguments // "{}")}' \ + > "$RESP_TOOL_CALLS_FILE" 2>/dev/null || true + + RESP_TOOL_CALL_COUNT=0 + if [ -s "$RESP_TOOL_CALLS_FILE" ]; then + RESP_TOOL_CALL_COUNT="$(wc -l < "$RESP_TOOL_CALLS_FILE")" + fi +} + +# Get a string field from a tool call's arguments JSON. Args: arguments_json field +parse_arg_string() { + printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null +} + +# Get an int field from a tool call's arguments JSON. Args: arguments_json field fallback +parse_arg_int() { + val="$(printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // empty' 2>/dev/null)" + [ -z "$val" ] && echo "$3" || echo "$val" +} + +# ===================================================================== +# Display helpers (src/agent_loop.c:28) +# ===================================================================== + +# Truncate to 120 chars with ellipsis (src/agent_loop.c:32) +truncate_display() { + s="$1" + len="$(printf '%s' "$s" | wc -c)" + if [ "$len" -gt 120 ]; then + printf '%s' "$s" | head -c 117 + printf '...' + else + printf '%s' "$s" + fi +} + +# Print a status line to stderr only in --verbose mode. Status lines are +# diagnostics (loaded/step/budget/done/finished); the actual response goes +# to stdout via print_response so it can be piped cleanly. +status() { + [ "$VERBOSE" = "1" ] || return 0 + printf '%s\n' "$*" >&2 +} + +print_step() { + step="$1"; command="$2"; reasoning="$3" + if [ -n "$command" ]; then + disp="$(truncate_display "$command")" + status "[step $step] \$ $disp" + elif [ -n "$reasoning" ]; then + disp="$(truncate_display "$reasoning")" + status "[step $step] (reasoning) $disp" + else + status "[step $step]" + fi +} + +print_response() { + # The response is the real payload — always goes to stdout. + # Default (quiet): print it raw so output is clean. + # Verbose: prefix with [response] for consistency with other status lines. + [ -z "$1" ] && return 0 + if [ "$VERBOSE" = "1" ]; then + printf '[response] %s\n' "$1" + else + printf '%s\n' "$1" + fi +} + +# ===================================================================== +# Agent loop (src/agent_loop.c:117) +# ===================================================================== + +agent_loop_run() { + result=1 # default failure + + while true; do + # 1. Check budget (src/agent_loop.c:133) + if ! budget_check; then + status "[budget] ${BUDGET_REASON:-limit reached} — stopping" + log_event "$BUDGET_REASON" "" "" "$BUDGET_STEP" "$BUDGET_COST" + result=0 # normal stop + break + fi + + BUDGET_STEP=$((BUDGET_STEP + 1)) + + # 3-4. Call the API (src/agent_loop.c:175) + if ! api_call "$BUDGET_STEP"; then + status "[error] API call failed (see log)" + result=1 + break + fi + + # 4. Parse response (src/agent_loop.c:191) + parse_response + + # Detect empty responses: no content and no tool calls. + # No hidden prompting — just report the error and stop. + if [ -z "$RESP_CONTENT" ] && [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then + echo "[error] empty response from API (no content, no tool calls)" >&2 + log_api_error "$BUDGET_STEP" 3 "empty response: $API_RESPONSE" + result=1 + break + fi + + # 5. Calculate cost (src/agent_loop.c:203) + step_cost="$(budget_estimate_cost "$CFG_MODEL" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT")" + BUDGET_COST="$(awk "BEGIN { printf \"%.6f\", $BUDGET_COST + $step_cost }")" + + # 6. Scan tool calls to extract log_command and done_summary BEFORE + # logging. This lets us write one model entry that captures the full + # model response (reasoning + command + done summary) in a single + # log line. Flush-before-act: logged before any command execution. + log_command="" + pre_done_summary="" + if [ "$RESP_TOOL_CALL_COUNT" -gt 0 ]; then + idx=0 + while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do + tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")" + tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')" + tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')" + if [ "$tc_name" = "run_shell" ] && [ -z "$log_command" ]; then + log_command="$(parse_arg_string "$tc_args" "command")" + fi + if [ "$tc_name" = "done" ]; then + pre_done_summary="$(parse_arg_string "$tc_args" "summary")" + fi + idx=$((idx+1)) + done + fi + + elapsed="$(budget_elapsed)" + log_response "$BUDGET_STEP" "$MODE" "$CFG_MODEL" "$RESP_CONTENT" "$log_command" \ + "$pre_done_summary" "$RESP_TOKENS_IN" "$RESP_TOKENS_OUT" \ + "$step_cost" "$BUDGET_COST" "$elapsed" + + # 7. Print step info (src/agent_loop.c:244) + print_step "$BUDGET_STEP" "$log_command" "$RESP_CONTENT" + + # 8. No tool calls — text response (src/agent_loop.c:259) + if [ "$RESP_TOOL_CALL_COUNT" -eq 0 ]; then + conv_append_assistant_text "$RESP_CONTENT" + print_response "$RESP_CONTENT" + rm -f "$RESP_TOOL_CALLS_FILE" + + # Text response with no tool calls = done (both standalone and + # autonomous). No hidden prompting to continue. + result=0 + break + fi + + # 9. Process tool calls (src/agent_loop.c:290) + done_called=0 + done_summary="" + tc_objs_file="$(mktemp)" # tool_call objects for the assistant message + + idx=0 + while [ "$idx" -lt "$RESP_TOOL_CALL_COUNT" ]; do + tc_line="$(sed -n "$((idx+1))p" "$RESP_TOOL_CALLS_FILE")" + tc_id="$(printf '%s' "$tc_line" | jq -r '.id // empty')" + tc_name="$(printf '%s' "$tc_line" | jq -r '.name // empty')" + tc_args="$(printf '%s' "$tc_line" | jq -r '.arguments // "{}"')" + + [ -z "$tc_name" ] && { idx=$((idx+1)); continue; } + + if [ "$tc_name" = "done" ]; then + done_called=1 + done_summary="$(parse_arg_string "$tc_args" "summary")" + # Still add to conversation (src/agent_loop.c:309) + jq -nc --arg id "${tc_id:-done}" --arg args "$tc_args" \ + '{type:"function", id:$id, function:{name:"done", arguments:$args}}' >> "$tc_objs_file" + idx=$((idx+1)) + continue + fi + + if [ "$tc_name" = "run_shell" ]; then + command="$(parse_arg_string "$tc_args" "command")" + cmd_timeout="$(parse_arg_int "$tc_args" "timeout_seconds" "$CFG_CMD_TIMEOUT")" + + if [ -z "$command" ]; then + # Empty command (src/agent_loop.c:322) + jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \ + '{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file" + result_json="$(jq -nc --arg out "" --arg err "[empty command]" --argjson ec -1 \ + '{stdout:$out, stderr:$err, exit_code:$ec}')" + conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json" + idx=$((idx+1)) + continue + fi + + # Execute (src/agent_loop.c:338) + shell_exec "$command" "$cmd_timeout" + + # Log execution result AFTER execution (flush-before-act) + log_exec "$BUDGET_STEP" "$SH_OUT" "$SH_ERR" "$SH_EXIT" "$BUDGET_COST" + + # Add to conversation (src/agent_loop.c:347) + jq -nc --arg id "${tc_id:-shell}" --arg args "$tc_args" \ + '{type:"function", id:$id, function:{name:"run_shell", arguments:$args}}' >> "$tc_objs_file" + result_json="$(jq -nc --arg out "$SH_OUT" --arg err "$SH_ERR" --argjson ec "$SH_EXIT" \ + '{stdout:$out, stderr:$err, exit_code:$ec}')" + conv_append_tool "${tc_id:-shell}" "run_shell" "$result_json" + + idx=$((idx+1)) + continue + fi + + # Unknown tool (src/agent_loop.c:367) + err_msg="unknown tool: $tc_name" + jq -nc --arg id "${tc_id:-unknown}" --arg name "$tc_name" --arg args "$tc_args" \ + '{type:"function", id:$id, function:{name:$name, arguments:$args}}' >> "$tc_objs_file" + result_json="$(jq -nc --arg err "$err_msg" --argjson ec -1 \ + '{stdout:"", stderr:$err, exit_code:$ec}')" + conv_append_tool "${tc_id:-unknown}" "$tc_name" "$result_json" + idx=$((idx+1)) + done + + # 11. Append assistant message with tool_calls (src/agent_loop.c:382) + conv_append_assistant_with_tools "$RESP_CONTENT" "$tc_objs_file" + rm -f "$tc_objs_file" + rm -f "$RESP_TOOL_CALLS_FILE" + + # 12. done() handling — print the model's reasoning (the actual answer) + # to the screen. Fall back to done_summary only if reasoning is empty. + if [ "$done_called" = "1" ]; then + final_answer="$RESP_CONTENT" + [ -z "$final_answer" ] && final_answer="$done_summary" + [ -z "$final_answer" ] && final_answer="task complete" + print_response "$final_answer" + status "[done] $final_answer" + result=0 + break + fi + done + + return $result +} + +# ===================================================================== +# Main flow — runs when executed directly (./ai.sh ...). +# All functionality (ask + command-generation modes) is handled here; +# the script is no longer intended to be sourced. +# ===================================================================== + +# Arg parsing + mode detection +PROG_NAME="$(basename "$0")" + +if [ "$PROG_NAME" = "aa" ]; then + echo "error: interactive server mode (aa) is not yet implemented" >&2 + echo " use: $PROG_NAME \"message\" (one-shot)" >&2 + exit 1 +fi + +i=1 +while [ $i -le $# ]; do + eval "arg=\"\${$i}\"" + case "$arg" in + --help|-h) + usage "$PROG_NAME" + exit 0 + ;; + --verbose|-v) + VERBOSE=1 + i=$((i+1)) + ;; + --autonomous|-a) + IS_AUTONOMOUS=1 + i=$((i+1)) + ;; + --cmd|-c) + CMD_MODE=1 + i=$((i+1)) + ;; + --init|-i) + INIT_MODE=1 + i=$((i+1)) + ;; + --clear|-x) + CLEAR_MODE=1 + i=$((i+1)) + ;; + --model|-m) + i=$((i+1)); eval "MODEL_OVERRIDE=\"\${$i}\""; i=$((i+1)) + ;; + --api-key|-k) + i=$((i+1)); eval "API_KEY_OVERRIDE=\"\${$i}\""; i=$((i+1)) + ;; + --max-cost|-C) + i=$((i+1)); eval "MAX_COST_OVERRIDE=\"\${$i}\"" + HAS_MAX_COST=1; i=$((i+1)) + ;; + --max-steps|-s) + i=$((i+1)); eval "MAX_STEPS_OVERRIDE=\"\${$i}\"" + HAS_MAX_STEPS=1; i=$((i+1)) + ;; + --timeout|-t) + i=$((i+1)); eval "TIMEOUT_OVERRIDE=\"\${$i}\"" + HAS_TIMEOUT=1; i=$((i+1)) + ;; + --cmd-timeout|-T) + i=$((i+1)); eval "CMD_TIMEOUT_OVERRIDE=\"\${$i}\"" + HAS_CMD_TIMEOUT=1; i=$((i+1)) + ;; + --resume|-r) + RESUME=1; i=$((i+1)) + ;; + --no-resume|-R) + RESUME=0; i=$((i+1)) + ;; + --*) + echo "unknown option: $arg" >&2 + usage "$PROG_NAME" + exit 1 + ;; + -*) + echo "unknown option: $arg" >&2 + usage "$PROG_NAME" + exit 1 + ;; + *) + if [ -z "$MESSAGE" ]; then MESSAGE="$arg"; else MESSAGE="$MESSAGE $arg"; fi + i=$((i+1)) + ;; + esac +done + +# --clear: delete the log file and exit. +if [ "$CLEAR_MODE" = "1" ]; then + if [ -f "$LOG_PATH" ]; then + rm -f "$LOG_PATH" + echo "Cleared $LOG_PATH" + else + echo "No log to clear ($LOG_PATH does not exist)" + fi + exit 0 +fi + +# --init: create local .aish/ with default config + prompt, then exit. +if [ "$INIT_MODE" = "1" ]; then + mkdir -p "$LOCAL_DIR" || { echo "error: failed to create $LOCAL_DIR" >&2; exit 1; } + chmod 700 "$LOCAL_DIR" 2>/dev/null || true + # Temporarily point paths to the local dir for ensure_data_dir + DIR="$LOCAL_DIR" + CONFIG_PATH="$DIR/$CONFIG_FILENAME" + PROMPT_PATH="$DIR/$PROMPT_FILENAME" + LOG_PATH="$DIR/$LOG_FILENAME" + SOCK_PATH="$DIR/$SOCK_FILENAME" + ensure_data_dir + echo "Initialized $LOCAL_DIR" + echo " Edit $CONFIG_PATH to change model, budget, etc." + echo " Edit $PROMPT_PATH to customize the agent's instructions." + exit 0 +fi + +# Read piped stdin if present (not a terminal). If args were also given, +# append the stdin content to the message. This enables: +# echo "explain this" | ./ai.sh +# cat error.log | ./ai.sh explain this error +# git diff | ./ai.sh -c generate a commit message +if [ ! -t 0 ]; then + STDIN_INPUT="$(cat)" + if [ -n "$STDIN_INPUT" ]; then + if [ -z "$MESSAGE" ]; then + MESSAGE="$STDIN_INPUT" + else + MESSAGE="$MESSAGE + +$STDIN_INPUT" + fi + fi +fi +unset STDIN_INPUT + +if [ -z "$MESSAGE" ]; then + echo "error: no message provided" >&2 + usage "$0" + exit 1 +fi + +# --cmd mode: rewrite the message into a command-generation prompt and +# force a short, non-resuming run. The generated command is captured, +# printed to stderr in grey, and eval'd in this shell. +if [ "$CMD_MODE" = "1" ]; then + MESSAGE="Respond with only the shell command to accomplish this task. No explanation, no markdown, no backticks — just the raw command ready to paste into a terminal: $MESSAGE" + RESUME=0 + MAX_STEPS_OVERRIDE=3 + HAS_MAX_STEPS=1 +fi + +[ "$IS_AUTONOMOUS" = "1" ] && MODE="autonomous" || MODE="standalone" + +# Ensure data dir exists (creates ~/.aish/ in global mode; no-op dir-wise +# in local mode since .aish/ already exists). Ensures config + prompt files. +if ! ensure_data_dir; then + echo "error: failed to create data directory ($DIR)" >&2 + exit 1 +fi + +# Get API key — priority: --api-key flag > PPQ_API_KEY env > DEFAULT_API_KEY +API_KEY="$API_KEY_OVERRIDE" +if [ -z "$API_KEY" ]; then + API_KEY="${PPQ_API_KEY:-}" +fi +if [ -z "$API_KEY" ]; then + API_KEY="$DEFAULT_API_KEY" +fi +if [ -z "$API_KEY" ]; then + echo "error: no API key provided. Use --api-key or set PPQ_API_KEY" >&2 + echo " (.aish/ has been set up — set the key and try again)" >&2 + exit 1 +fi + +# Load config +config_load + +# Load system prompt +SYSTEM_PROMPT="$(load_system_prompt)" + +# Resume or fresh +if [ "$RESUME" = "1" ]; then + resume_from_log "$SYSTEM_PROMPT" + msg_count="$(wc -l < "$MESSAGES_FILE")" + if [ "$msg_count" -gt 1 ]; then + status "[loaded $LOG_PATH — $msg_count messages, \$$RESUME_CUM_COST spent]" + fi + seed_step="$RESUME_LAST_STEP" + seed_cost="$RESUME_CUM_COST" +else + conv_init "$SYSTEM_PROMPT" + seed_step=0 + seed_cost=0.0 +fi + +# Append the user's message +conv_append_user "$MESSAGE" +log_user_input "$MESSAGE" + +# Initialize budget, seeded from resume +budget_init "$CFG_MAX_COST" "$CFG_MAX_STEPS" "$CFG_TIMEOUT" "$seed_cost" "$seed_step" + +# Mode banner +if [ "$MODE" = "autonomous" ]; then + if [ "$(printf '%s' "$MESSAGE" | wc -c)" -gt 80 ]; then + status "[autonomous mode — goal: (long goal)]" + else + status "[autonomous mode — goal: $MESSAGE]" + fi +fi + +# Run the agent loop. In --cmd mode, capture stdout (the generated command) +# while letting status messages go to stderr. +if [ "$CMD_MODE" = "1" ]; then + CMD_OUT="$(agent_loop_run 2>&1)" + loop_rc=$? + # Extract the last non-empty line as the command (status lines start with [). + CMD_GEN="$(printf '%s\n' "$CMD_OUT" | grep -v '^\[' | sed '/^$/d' | tail -n 1)" + if [ -z "$CMD_GEN" ]; then + echo "aishc: no command returned from LLM" >&2 + rm -f "$MESSAGES_FILE" + exit 1 + fi + printf '\033[2m$ %s\033[0m\n' "$CMD_GEN" >&2 + eval "$CMD_GEN" + eval_rc=$? + rm -f "$MESSAGES_FILE" + exit $eval_rc +fi + +agent_loop_run +loop_rc=$? + +# Print summary +status "" +status "[finished — $BUDGET_STEP steps, \$$BUDGET_COST spent]" + +# Cleanup +rm -f "$MESSAGES_FILE" + +exit $loop_rc diff --git a/readme2.md b/readme2.md new file mode 100644 index 0000000..2cf65f5 --- /dev/null +++ b/readme2.md @@ -0,0 +1,314 @@ +--- +slug: aish +title: AI.sh +summary: A simple shell script agent +image: https://example.com/cover.png +tags: [ai, agent, aiharness] +--- + +# aish + +``` + , , + (\____/) + (_oo_) + (O) -- ai.sh + __||__ \) + []/______\[] / + / \______/ \/ + / /__\ + (\ /____\ +``` + +## Idea +How simple, lightweight and reliable can an agent be? + +## Solution +- One shell script. +- Use tried and tested linux programs like curl and jq. +- Log file is simple so you can read it, and it is used for agent context. +- Just give the agent shell access. No special commands, no context bloat. +- Fast +- This repo has 2 files. + + +## Usage + +Ask the agent a question (no quotes needed for simple text): + +```sh +$ ./ai.sh what is the capital of Japan? +The capital of Japan is Tokyo. +``` + +```sh +$ ./ai.sh what is 2 plus 2 +2 plus 2 is 4. +``` + +```sh +$ ./ai.sh write a hello world program in python. save it in ~/ +I've created a Python hello world program at ~/hello.py: + +```python +print("Hello, World!") +``` + +You can run it with `python3 ~/hello.py`. +``` + +With options: + +```sh +$ ./ai.sh -v what is 2 plus 2 +[loaded /home/user/.aish/log.jsonl — 3 messages, $0.0001 spent] +[step 1] (reasoning) The user wants to know 2 plus 2. +[done] 2 plus 2 is 4. +2 plus 2 is 4. +[finished — 1 steps, $0.0001 spent] +``` + +```sh +$ ./ai.sh -m gpt-4o "write a hello world program in python. save it in ~/" +I've created a Python hello world program at ~/hello.py: + +```python +print("Hello, World!") +``` + +You can run it with `python3 ~/hello.py`. +``` + +```sh +$ ./ai.sh -R what is the capital of France +The capital of France is Paris. +``` + +The agent can run shell commands, read files, and execute multi-step +tasks. Context is saved and resumed on subsequent calls. + +### Command mode + +Describe a command in English. The LLM generates the shell command, +prints it, and runs it in your terminal: + +```sh +$ ./ai.sh -c show me all files including hidden ones +$ ls -a +. .. .git .gitignore README.md ai.sh +``` + +```sh +$ ./ai.sh -c find all python files modified this week +$ find . -name '*.py' -mtime -7 +./scripts/build.py +./src/main.py +``` + +```sh +$ ./ai.sh -c show me disk usage sorted by size +$ du -sh * | sort -rh +4.0M ai.sh +2.0M README.md +``` + +### Autonomous mode + +Give the agent a goal and let it run until done or budget exhausted: + +```sh +$ ./ai.sh -a "explore the codebase and summarize the architecture" +[autonomous mode — goal: explore the codebase and summarize the architecture] +[step 1] $ ls -la +[step 2] $ cat ai.sh | head -50 +[step 3] $ wc -l ai.sh +[done] This is a single-file shell script AI agent. It uses curl to call +an LLM API and jq to parse JSON. The script has three main parts: config +loading, an agent loop that calls the API and executes tool calls, and +a logger that writes append-only JSON lines. No build step required. +[finished — 3 steps, $0.0003 spent] +``` + +### Initialize a local agent + +By default, aish stores everything globally in `~/.aish/`. To create a +self-contained agent for a specific project: + +```sh +$ ./ai.sh -i +Initialized /home/user/myproject/.aish + Edit /home/user/myproject/.aish/config.json to change model, budget, etc. + Edit /home/user/myproject/.aish/system_prompt.txt to customize the agent's instructions. +``` + +This creates a `.aish/` directory in the current folder with its own +config, system prompt, and log. While `.aish/` exists, all storage is +local to that directory. Remove it to return to global mode. + +### Piping + +The agent's answer goes to stdout; status messages go to stderr. So you +can pipe the answer into other commands: + +```sh +$ ./ai.sh what is the capital of Japan | wc -w +5 +``` + +```sh +$ ./ai.sh list all python files in this dir | grep test +./test_runner.py +./test_config.py +``` + +```sh +$ ./ai.sh what is 2+2 > answer.txt +$ cat answer.txt +2 + 2 is 4. +``` + +You can also pipe input in. If no message is given on the command line, +stdin becomes the message. If both are given, stdin is appended: + +```sh +$ echo "explain this error" | ./ai.sh +The error message indicates a missing semicolon at the end of line 12. +Add a `;` after the closing brace to fix it. +``` + +```sh +$ cat error.log | ./ai.sh explain this error +Looking at the error log, there are three issues: + +1. Line 5: NullPointerException — the variable `config` was never + initialized before use. +2. Line 12: FileNotFound — the path `/etc/app.conf` doesn't exist on + this system. +3. Line 18: Timeout — the API call took longer than 30 seconds. + +The root cause is likely the missing config file. Create it and the +other errors should resolve. +``` + +```sh +$ git diff | ./ai.sh -c generate a commit message +$ git commit -m "Fix: handle empty config file gracefully + +- Add null check before reading config +- Fall back to defaults when config is missing +- Log warning when using fallback values" +``` + +## Quick start + +```sh +# 1. Install dependencies +sudo apt install curl jq coreutils # Debian/Ubuntu + +# 2. Add your API key (get one from https://ppq.ai) +cat > ~/.aish.conf <<'EOF' +DEFAULT_API_KEY="sk-your-key-here" +EOF +chmod 600 ~/.aish.conf + +# 3. Run it +./ai.sh what is the capital of Japan +``` + +That's it. No build step, no install script, no daemon. + +## Options + +| Short | Long | Description | +|-------|------|-------------| +| `-c` | `--cmd` | Command mode (generate and run a shell command) | +| `-a` | `--autonomous` | Autonomous mode (run until done or budget exhausted) | +| `-i` | `--init` | Create local `.aish/` with defaults, exit | +| `-x` | `--clear` | Clear the log file and exit | +| `-v` | `--verbose` | Show status lines | +| `-m` | `--model` | Override model | +| `-k` | `--api-key` | API key (or set `PPQ_API_KEY`) | +| `-C` | `--max-cost` | Override max cost (USD) | +| `-s` | `--max-steps` | Override max steps | +| `-t` | `--timeout` | Override overall timeout (seconds) | +| `-T` | `--cmd-timeout` | Override per-command timeout (seconds) | +| `-r` | `--resume` | Resume from existing log (default: yes) | +| `-R` | `--no-resume` | Start fresh (ignore existing log) | +| `-h` | `--help` | Show help | + +## Configuration + +### API key + +Create `~/.aish.conf` with your key: + +```sh +cat > ~/.aish.conf <<'EOF' +DEFAULT_API_KEY="sk-your-key-here" +EOF +chmod 600 ~/.aish.conf +``` + +This file lives in your home directory, outside the repo, and is never +committed. Any `DEFAULT_*` variable from `ai.sh` can be overridden there. + +### Config priority + +Settings are resolved in this order (first match wins): + +1. CLI flags (`-m`, `-C`, `-s`, etc.) +2. Local `.aish/config.json` (if `.aish/` exists in current directory) +3. Global `~/.aish/config.json` +4. `~/.aish.conf` shell vars +5. Built-in defaults in `ai.sh` + +### Storage + +**Global mode** (default): `~/.aish/` holds config, system prompt, and +log. The agent has shared global memory — conversations from any +directory are logged to the same file and resumed on subsequent calls. + +**Local mode**: Run `./ai.sh -i` to create a `.aish/` in the current +directory. While it exists, that directory uses its own config, prompt, +and log instead of the global ones. + +## Dependencies + +- `curl` +- `jq` +- coreutils (`date`, `mkdir`, `chmod`, `head`, `cat`, `printf`, `mktemp`, `timeout`, `sync`, `basename`, `sleep`, `wc`, `tail`, `sed`, `grep`) + +```sh +# Debian / Ubuntu +sudo apt install curl jq coreutils + +# Fedora / RHEL +sudo dnf install curl jq coreutils + +# Arch +sudo pacman -S curl jq coreutils + +# macOS (Homebrew — coreutils provides GNU versions like `timeout`) +brew install curl jq coreutils +``` + +## Log format + +Each line in `log.jsonl` is JSON with a `source` field: + +| source | event | who | +|--------|-------|-----| +| `user` | `user_input` | you | +| `model` | `response` | the AI | +| `aish` | `exec` | script ran a command | +| `aish` | `api_error` | script detected an error | + +## Warning + +**This agent has full shell access.** It can run any command on your +system — read files, write files, delete data, install software, make +network requests. There are no sandboxing or permission restrictions. + +Run it only on a computer, VM, or Qube that is dedicated to agent use. +Do not run it on a machine with sensitive data or credentials you cannot +afford to lose.