From 813972f8da9d9ee9fa0ddc1a21550f9c91b28563 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Mon, 23 Feb 2026 11:00:20 -0500 Subject: [PATCH] make it fancy --- CLAUDE.md | 47 ++++ README.md | 152 +++++++++++ bb.edn | 3 +- outline.md | 151 +++++++++++ src/agent/app.clj | 234 ++++++++++------- src/agent/clojuredocs.clj | 164 ++++++++++++ src/agent/context.clj | 183 +++++++++++++ src/agent/core.clj | 170 ++++++++++-- src/agent/markdown.clj | 21 +- src/agent/syntax.clj | 541 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 1543 insertions(+), 123 deletions(-) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 outline.md create mode 100644 src/agent/clojuredocs.clj create mode 100644 src/agent/context.clj create mode 100644 src/agent/syntax.clj diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d45e558 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +agent0 is an agentic coding TUI built with Babashka (Clojure). It runs local LLMs via Ollama with tool-use capabilities (file editing, shell commands, code search, web research) in a terminal interface. + +## Running + +```bash +agent # Interactive +agent "prompt" # With initial prompt +agent --continue # Resume latest session +agent --session # Resume specific session +``` + +**Requirements:** Babashka, Ollama running locally. Model defaults to `qwen3-coder-next` (override with `$AGENT_MODEL`). Ollama host defaults to `http://localhost:11434` (override with `$OLLAMA_HOST`). + +## Architecture + +**Runtime:** Babashka scripts (`bb.edn`), depends on local `../clojure-tui` for the TUI framework. + +**Source files (`src/agent/`):** + +- **app.clj** — TUI layer. Elm-architecture: `view` renders state, `update-fn` handles events. The agent loop runs async in a background thread; UI polls an event queue every 100ms. Manages session persistence, skill expansion, scrolling, and input. +- **core.clj** — Agent brain. `run-agent-loop!` iterates: call LLM → execute tool calls → append results → repeat (max 50 iterations). Tools return strings or `{:message str :diff str}` maps. Includes stuck-loop detection (exact repeats and research loops). `build-system-prompt` composes the base prompt with project context. +- **context.clj** — Loads project context from `.agent0/context.md`, `CLAUDE.md`, `.cursorrules`, or `.github/copilot-instructions.md`. Parses skill definitions from `~/.config/agent0/skills.md` (global) and `.agent0/skills.md` (project). Skills are `/name ` templates with `{param}` substitution. +- **markdown.clj** — Converts markdown to ANSI-styled terminal text. Handles inline formatting, code blocks, headers, lists, links, and ANSI-aware word wrapping. +- **web_search.clj** — Subagent config for web research. Uses DuckDuckGo HTML search + URL fetching. Delegated to via the `delegate` tool. + +**Data flow:** User input → skill expansion (if `/command`) → conversation history → `run-agent-loop!` (background future) → LLM call → tool execution → events pushed to atom queue → UI polls and renders. + +**Event types:** `:text` (assistant response), `:tool` (tool label), `:diff` (file change preview), `:error`, `:done`. + +**Persistence:** Sessions in `~/.local/share/agent0/sessions/`, logs in `~/.local/share/agent0/logs/`. + +## Key Patterns + +- Tools that modify files (`edit-file`, `create-file`) return `{:message ... :diff ...}` maps. `execute-tool` extracts the diff and passes it as a separate UI event. The LLM only sees the `:message` string. +- The TUI uses clojure-tui's hiccup-like syntax: `[:row {:widths [22 :flex]} left right]`, `[:col {:heights [:flex 3]} content input]`, `[:box {:border :rounded}]`, `[:text {:fg :red :bold true} "text"]`. +- `run-agent-loop!` returns `{:future f :cancel! atom}`. Setting `cancel!` to true + `future-cancel` interrupts the loop. +- Subagents (`web_search`) are synchronous mini-loops with their own tools and system prompt, invoked via the `delegate` tool. + +## No Tests + +There is no test suite. Verify changes by loading namespaces in the nREPL (`bb nrepl-server`) and testing functions interactively. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83dee21 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# agent0 + +A local AI coding assistant that runs in your terminal. You type questions or instructions, and the AI reads your code, edits files, runs commands, and searches the web — all from a simple text interface. + +No cloud accounts or API keys required for local use. Your code stays on your machine. + +## What it does + +agent0 gives you an AI assistant that understands your codebase. You can ask it to: + +- **Read and edit files** — it can open any file in your project, make targeted edits, or create new files +- **Run shell commands** — compile code, run scripts, install packages, anything you'd do in a terminal +- **Search your code** — find files by name patterns or search inside files for specific text +- **Look up Clojure docs** — instant documentation for Clojure standard library functions +- **Research the web** — search the internet and read web pages to answer questions +- **Run skills** — execute predefined workflows you set up for common tasks + +Everything happens in a terminal UI with a chat-style interface. You type, the AI responds and uses tools as needed, and you see what it's doing in real time. + +## Requirements + +### Self-hosted (local, no API keys) + +This is the default setup. The AI model runs entirely on your machine. + +- **[Babashka](https://github.com/babashka/babashka)** — a fast Clojure scripting runtime (used to run agent0) +- **[Ollama](https://ollama.com)** — runs AI models locally on your computer +- **A downloaded model** — agent0 defaults to `qwen3-coder-next`, a coding-focused model + +Hardware considerations: local models need a decent GPU or a machine with enough RAM. Smaller models work on modest hardware; larger models need more resources. Check [Ollama's model library](https://ollama.com/library) for size and requirements. + +### Remote LLM (Ajet Cloud, OpenAI, Anthropic) + +If you'd rather use a cloud-hosted model instead of running one locally, agent0 can connect to any remote server that speaks the Ollama API: + +- **[Babashka](https://github.com/babashka/babashka)** — still required to run agent0 itself +- **A remote Ollama-compatible endpoint** — set `OLLAMA_HOST` to the server URL + +Some providers that work: + - [Ajet Cloud](https://ajet.fyi) — managed Ollama-compatible hosting + - Any remote machine running Ollama + - OpenAI or Anthropic via an Ollama-compatible proxy (e.g. [LiteLLM](https://github.com/BerriAI/litellm)) + +With a remote provider, you don't need Ollama installed locally or a powerful machine — the model runs on the remote server. + +## Installation + +### 1. Install Babashka + +**macOS:** +```bash +brew install borkdude/brew/babashka +``` + +**Linux:** +```bash +bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install) +``` + +See the [Babashka install docs](https://github.com/babashka/babashka#installation) for other methods. + +### 2. Set up a model + +**For local use** — install Ollama and pull a model: +```bash +# Install Ollama from https://ollama.com, then: +ollama pull qwen3-coder-next +``` + +**For remote use** — point to your provider: +```bash +export OLLAMA_HOST="https://your-provider-url" +``` + +### 3. Clone the repository + +```bash +cd ~/repos # or wherever you keep projects +git clone https://git.ajet.fyi/ajet/agent0.git +``` + +Dependencies (including the TUI framework) are fetched automatically via git deps. + +### 4. Add to your PATH + +Make the `agent` command available from anywhere: + +```bash +# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) +export PATH="$HOME/repos/agent0:$PATH" +``` + +Then restart your terminal or run `source ~/.bashrc`. + +## Usage + +```bash +agent # Start a new conversation +agent "explain this codebase" # Start with a question or instruction +agent --continue # Resume your most recent session +agent --session # Resume a specific session by ID +``` + +Once running, type your message and press Enter. The AI will respond, using tools as needed. You'll see what it's doing — reading files, running commands, searching — in real time. + +## Configuration + +### Environment variables + +| Variable | What it does | Default | +|---|---|---| +| `AGENT_MODEL` | Which AI model to use | `qwen3-coder-next` | +| `OLLAMA_HOST` | Where the model is running | `http://localhost:11434` | + +### Project context + +agent0 automatically reads instructions from your project to give the AI relevant context. It checks for these files (in order): + +1. `.agent0/context.md` — agent0-specific project instructions +2. `CLAUDE.md` — also used by Claude Code +3. `.cursorrules` — also used by Cursor +4. `.github/copilot-instructions.md` — also used by GitHub Copilot + +You can also put global instructions (applied to all projects) in `~/.config/agent0/context.md`. + +### Skills + +Skills are reusable prompt templates you trigger with `/name`. Define them in: + +- `~/.config/agent0/skills.md` — available in all projects +- `.agent0/skills.md` — project-specific skills + +Example skill definition: +```markdown +# /deploy + +Deploy the application to the {env} environment. Run the deploy script, +verify it completes successfully, and report the result. +``` + +Then use it: type `/deploy staging` and it expands into the full prompt. + +## Sessions + +Every conversation is automatically saved. You can pick up where you left off: + +```bash +agent --continue # Resume the latest session +agent --session # Resume a specific session +``` + +Sessions are stored in `~/.local/share/agent0/sessions/`. Logs are in `~/.local/share/agent0/logs/`. diff --git a/bb.edn b/bb.edn index 76f5e7f..e788f91 100644 --- a/bb.edn +++ b/bb.edn @@ -1,2 +1,3 @@ {:paths ["src"] - :deps {tui/tui {:local/root "../clojure-tui"}}} + :deps {tui/tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git" + :git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}} diff --git a/outline.md b/outline.md new file mode 100644 index 0000000..36cba37 --- /dev/null +++ b/outline.md @@ -0,0 +1,151 @@ +# agent0 Architecture Outline + +## Core Overview +agent0 is an **agentic TUI** built with Babashka (Clojure) that interacts with local LLMs via Ollama. It features an Elm-architecture UI with background agent processing. + +--- + +## File Structure & Responsibilities + +| File | Responsibility | +|------|------| +| `app.clj` | TUI layer - Elm architecture, event handling, rendering | +| `core.clj` | Agent brain - LLM calls, tool execution, async loop | +| `context.clj` | Project context loading, skill parsing/expansion | +| `markdown.clj` | Markdown → ANSI-styled terminal output | +| `web_search.clj` | Web research subagent configuration | +| `clojuredocs.clj` | ClojureDocs integration (not shown, imported) | + +--- + +## Data Flow + +``` +User Input → Skill Expansion → Agent Loop → LLM → Tools → Events → UI +``` + +1. **Input** → Optional skill expansion (`/name args`) +2. **Conversation** → Added to history +3. **Agent Loop** (async future) → Calls LLM, executes tools, appends results +4. **Events** → Pushed to event queue every 100ms +5. **UI** → Polls queue and re-renders + +--- + +## Key Functions + +### app.clj (TUI Layer) + +| Function | Purpose | +|----------|---------| +| `view` | Elm view - renders state as hiccup UI | +| `update-fn` | Elm update - handles events, user input, agent polling | +| `process-agent-event` | Pushes LLM responses to messages list | +| `format-messages` | Converts messages to display lines with ANSI styling | +| `wrap-line` / `wrap-text` | Word-wrap with ANSI awareness | +| `-main` | Entry point - parses args, loads context/skills, starts TUI | + +### core.clj (Agent Brain) + +| Function | Purpose | +|----------|---------| +| `run-agent-loop!` | Main async loop - iterates: call LLM → execute tools → repeat (max 50) | +| `call-llm*` | HTTP POST to Ollama `/api/chat` | +| `call-llm` | Wrapper with tool definitions | +| `execute-tool` | Runs tool function, extracts diff for UI | +| `tool-call-label` | Human-readable label for tool calls (displayed as "⚙") | +| `detect-stuck-loop` | Detects infinite loops (exact repeats + research loops) | +| `run-subagent!` | Subagent execution (e.g., web_search) | +| `delegate` | Tool to invoke subagents | +| `skills-tool` | Tool for listing/running skills | +| Session persistence: `save-session!`, `load-session`, `new-session-id` | | +| Tools: `read-file`, `list-files`, `edit-file`, `create-file`, `run-shell-command`, `glob-files`, `grep-files` | File & shell operations | + +### context.clj (Context & Skills) + +| Function | Purpose | +|----------|---------| +| `load-project-context` | Loads `.agent0/context.md`, `CLAUDE.md`, etc. | +| `load-skills` | Loads skills from `~/.claude/skills/`, `~/.config/agent0/skills.md`, `.agent0/skills.md` | +| `parse-skills` | Parses `/name ` templates with `{param}` substitution | +| `expand-skill` | Expands skill invocations like `/test --watch` | +| `format-skill-list` | Formats skill list for display | + +### markdown.clj (Markdown Rendering) + +| Function | Purpose | +|----------|---------| +| `render-markdown` | Converts markdown to ANSI-styled lines | +| `apply-inline` | Bold, italic, strikethrough, links | +| `protect-code-spans` / `restore-code-spans` | Preserves inline code | +| `wrap-words` | ANSI-aware word wrapping | + +--- + +## Event Types + +```clojure +{:type :text :content "..."} ; Assistant response +{:type :tool :label "..."} ; Tool being called (displayed as "⚙") +{:type :diff :content "..."} ; File edit preview (diff format) +{:type :error :message "..."} ; Error message +{:type :done :conversation [...]} ; Loop finished +``` + +--- + +## Tool Pattern + +Tools that modify files return `{:message ".." :diff ".."}`: +- `:message` → sent to LLM as tool result +- `:diff` → pushed as separate `:diff` event to UI + +--- + +## Agent Loop Algorithm + +``` +1. Call LLM with conversation + tool definitions +2. If tool_calls in response: + a. Push tool labels to UI + b. Execute each tool function + c. Push diffs to UI (if any) + d. Append tool results to conversation + e. Repeat (stuck-loop detection at ~3 repeats) +3. If no tool_calls: + a. Push final assistant text + b. Signal :done +``` + +**Stuck-loop detection:** +- Exact repeat: same tool calls 3x in a row → hard stop +- Research loop: `web_search` called 3+ times with varying args → inject system nudge, stop at 6 + +--- + +## State Management + +**Model state (UI):** +```clojure +{:messages [...] ; Display messages + :input "" ; User input field + :conversation [...] ; LLM conversation history + :event-queue (atom []) ; Background-to-UI events + :session-id ... ; For persistence + :agent-running? bool ; Spinner state + :agent-handle ... ; Future + cancel atom + :skills {...} ; Available skills} +``` + +**Sessions saved to:** `~/.local/share/agent0/sessions/{uuid}.edn` +**Logs saved to:** `~/.local/share/agent0/logs/agent-{timestamp}.log` + +--- + +## Key Patterns + +1. **Async agent loop** returns `{:future f :cancel! atom}` for interruption +2. **TUI uses clojure-tui** hiccup-like syntax with ANSI escape codes +3. **Polling UI** checks event queue every 100ms (drains and processes) +4. **Dynamic binding** `*log-file*` passes log context to subagents +5. **Skills** use `{param}` substitution from `/name ` format diff --git a/src/agent/app.clj b/src/agent/app.clj index 2f3f737..e45440f 100644 --- a/src/agent/app.clj +++ b/src/agent/app.clj @@ -1,6 +1,8 @@ (ns agent.app - (:require [agent.core :as core] + (:require [agent.context :as context] + [agent.core :as core] [agent.markdown :as md] + [agent.syntax :as syntax] [tui.core :as tui] [tui.events :as ev] [tui.terminal :as term] @@ -57,68 +59,43 @@ (def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"]) -(defn- strip-ansi - "Strip ANSI escape codes to get visible length." - [s] - (str/replace s #"\033\[[0-9;]*m" "")) +(def ^:private hat-lines + (let [p "\033[38;5;135m" r "\033[0m"] + [(str p " ✦ " r) + (str p " ▄█▄ " r) + (str p " ▄█████▄ " r) + (str p " ▀▀▀▀▀▀▀▀▀ " r)])) -(defn- header-lines - "Generate inline header box with ASCII Clojure logo and welcome message." - [width] - (let [p "\033[38;5;135m" - w "\033[1;97m" - d "\033[2m" - r "\033[0m" - box-w (min 48 (max 24 (- width 4))) - iw (- box-w 2) +(def ^:private logo-lines + [" \033[38;5;255m⢀\033[38;5;231m⣠\033[38;5;254m⣴\033[38;5;189m⣶\033[38;5;146m⣿⣿⣿⣿\033[38;5;189m⣶\033[38;5;254m⣦\033[38;5;231m⣄\033[38;5;255m⡀\033[0m " + " \033[38;5;246m⣠\033[38;5;253m⣶\033[38;5;147m⣿\033[38;5;68m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;146m⣿\033[38;5;188m⣶\033[38;5;246m⣄\033[0m " + " \033[38;5;253m⣴\033[38;5;231m⣿\033[38;5;189m⣿\033[38;5;254m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;153m⣿⣿⣿\033[38;5;147m⣿\033[38;5;104m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;253m⣦\033[0m " + "\033[38;5;250m⣸\033[38;5;255m⣿\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;187m⣿\033[38;5;156m⣿\033[38;5;149m⣿\033[38;5;194m⣿\033[38;5;153m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;250m⣇\033[0m" + "\033[38;5;231m⣿\033[38;5;107m⣿\033[38;5;71m⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿⣿\033[38;5;255m⣿\033[38;5;147m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m" + "\033[38;5;231m⣿\033[38;5;71m⣿⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿\033[38;5;193m⣿⣿\033[38;5;255m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m" + "\033[38;5;253m⢹\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;107m⣿\033[38;5;194m⣿\033[38;5;150m⣿⣿\033[38;5;193m⣿\033[38;5;113m⣿\033[38;5;193m⣿\033[38;5;189m⣿\033[38;5;111m⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿\033[38;5;110m⣿\033[38;5;189m⣿\033[38;5;253m⡏\033[0m" + " \033[38;5;254m⢻\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿\033[38;5;150m⣿\033[38;5;187m⣿⣿\033[38;5;193m⣿⣿\033[38;5;194m⣿\033[38;5;253m⣿⣿\033[38;5;252m⣿⣿\033[38;5;253m⣿\033[38;5;231m⣿\033[38;5;254m⡟\033[0m " + " \033[38;5;231m⠙\033[38;5;254m⢿\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;150m⣿\033[38;5;194m⡿\033[38;5;231m⠋\033[0m " + " \033[38;5;188m⠉\033[38;5;231m⠛\033[38;5;187m⠿\033[38;5;150m⣿⣿⣿⣿⣿⣿\033[38;5;187m⠿\033[38;5;231m⠛\033[38;5;188m⠉\033[0m "]) - ;; Hat lines padded to 20 chars to align with 20-char braille logo - lines [(str p " ✦ " r) - (str p " ▄█▄ " r) - (str p " ▄█████▄ " r) - (str p " ▀▀▀▀▀▀▀▀▀ " r) - ;; Clojure logo - generated from SVG via chafa (20x10 braille, 256-color) - " \033[38;5;255m⢀\033[38;5;231m⣠\033[38;5;254m⣴\033[38;5;189m⣶\033[38;5;146m⣿⣿⣿⣿\033[38;5;189m⣶\033[38;5;254m⣦\033[38;5;231m⣄\033[38;5;255m⡀\033[0m " - " \033[38;5;246m⣠\033[38;5;253m⣶\033[38;5;147m⣿\033[38;5;68m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;146m⣿\033[38;5;188m⣶\033[38;5;246m⣄\033[0m " - " \033[38;5;253m⣴\033[38;5;231m⣿\033[38;5;189m⣿\033[38;5;254m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;153m⣿⣿⣿\033[38;5;147m⣿\033[38;5;104m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;253m⣦\033[0m " - "\033[38;5;250m⣸\033[38;5;255m⣿\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;187m⣿\033[38;5;156m⣿\033[38;5;149m⣿\033[38;5;194m⣿\033[38;5;153m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;250m⣇\033[0m" - "\033[38;5;231m⣿\033[38;5;107m⣿\033[38;5;71m⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿⣿\033[38;5;255m⣿\033[38;5;147m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m" - "\033[38;5;231m⣿\033[38;5;71m⣿⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿\033[38;5;193m⣿⣿\033[38;5;255m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m" - "\033[38;5;253m⢹\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;107m⣿\033[38;5;194m⣿\033[38;5;150m⣿⣿\033[38;5;193m⣿\033[38;5;113m⣿\033[38;5;193m⣿\033[38;5;189m⣿\033[38;5;111m⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿\033[38;5;110m⣿\033[38;5;189m⣿\033[38;5;253m⡏\033[0m" - " \033[38;5;254m⢻\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿\033[38;5;150m⣿\033[38;5;187m⣿⣿\033[38;5;193m⣿⣿\033[38;5;194m⣿\033[38;5;253m⣿⣿\033[38;5;252m⣿⣿\033[38;5;253m⣿\033[38;5;231m⣿\033[38;5;254m⡟\033[0m " - " \033[38;5;231m⠙\033[38;5;254m⢿\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;150m⣿\033[38;5;194m⡿\033[38;5;231m⠋\033[0m " - " \033[38;5;188m⠉\033[38;5;231m⠛\033[38;5;187m⠿\033[38;5;150m⣿⣿⣿⣿⣿⣿\033[38;5;187m⠿\033[38;5;231m⠛\033[38;5;188m⠉\033[0m " - "" - (str w " agent0" r) - "" - " Welcome! Type a message" - " and press Enter to chat." - "" - (str d " Esc to interrupt · Ctrl+C to quit" r)] - - pad-line - (fn [s] - (let [vis (count (strip-ansi s)) - lp (max 0 (quot (- iw vis) 2)) - rp (max 0 (- iw vis lp))] - (str d "│" r - (apply str (repeat lp \space)) - s - (apply str (repeat rp \space)) - d "│" r))) - - h (apply str (repeat iw "─")) - top (str d "╭" h "╮" r) - bot (str d "╰" h "╯" r) - - all (concat [top (pad-line "")] - (map pad-line lines) - [(pad-line "") bot]) - - lp (max 0 (quot (- width box-w) 2)) - prefix (apply str (repeat lp \space))] - - (mapv (fn [line] [:text (str prefix line)]) all))) +(defn- header-view + "Two-column header: logo on left, welcome text on right." + [] + (let [left-col (into [:col] (map (fn [l] [:text l]) (concat hat-lines logo-lines))) + right-col [:col + [:text ""] + [:text {:fg :white :bold true} "agent0"] + [:text ""] + [:text "Agentic TUI for"] + [:text "Clojure wizards"] + [:text ""] + [:text "Type a command and"] + [:text "press Enter"] + [:text ""] + [:text {:dim true} "Esc · Ctrl+C to quit"]]] + [:row {:widths [22 :flex]} + left-col + right-col])) (defn- format-messages "Convert display messages to a flat vector of hiccup line elements." @@ -148,6 +125,58 @@ :tool [[:text {:dim true} (str " ⚙ " (:content msg))]] + :diff + (let [raw-lines (str/split-lines (:content msg)) + header (first raw-lines) + has-header? (and header (str/starts-with? header "@@")) + change-start (if has-header? + (or (some-> (re-find #":(\d+)" header) second parse-long) 1) + 1) + ;; Extract file extension from @@ header for syntax highlighting + diff-lang (when has-header? + (when-let [path (second (re-find #"@@\s+(?:new:\s+)?(\S+)" header))] + (let [dot-idx (str/last-index-of path ".")] + (when dot-idx + (syntax/lang-for-ext (subs path dot-idx)))))) + body-lines (if has-header? (rest raw-lines) raw-lines) + ctx-before (count (take-while #(str/starts-with? % " ") body-lines)) + first-lineno (- change-start ctx-before) + grey "\033[38;5;242m" + rst "\033[0m" + [body-els _] + (reduce + (fn [[acc {:keys [old-n new-n]}] line] + (cond + (str/starts-with? line "-") + (let [code (subs line 1) + highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)] + [(conj acc [:text (str " " grey (format "%4d " old-n) rst + "\033[48;5;52m" highlighted)]) + {:old-n (inc old-n) :new-n new-n}]) + + (str/starts-with? line "+") + (let [code (subs line 1) + highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)] + [(conj acc [:text (str " " grey (format "%4d " new-n) rst + "\033[48;5;22m" highlighted)]) + {:old-n old-n :new-n (inc new-n)}]) + + (str/starts-with? line "...") + [(conj acc [:text {:dim true} (str " " line)]) + {:old-n old-n :new-n new-n}] + + :else + (let [code (if (str/starts-with? line " ") (subs line 1) line) + highlighted (str " " (syntax/highlight-line code diff-lang "") rst)] + [(conj acc [:text {:dim true} (str " " grey (format "%4d " old-n) rst highlighted)]) + {:old-n (inc old-n) :new-n (inc new-n)}]))) + [[] {:old-n first-lineno :new-n first-lineno}] + body-lines)] + (into (if has-header? + [[:text (str " \033[36;2m" header "\033[0m")]] + []) + body-els)) + :error [[:text {:fg :red} (str " ✗ " (:content msg))]] @@ -168,12 +197,8 @@ chat-height (max 1 (- height input-box-height)) content-width (max 10 (- width 2)) - ;; Format messages with inline header - all-lines - (let [header (header-lines width)] - (if (empty? messages) - header - (into header (format-messages messages content-width)))) + ;; Format chat messages as flat text lines + all-lines (format-messages messages content-width) ;; Add thinking indicator all-lines @@ -203,14 +228,22 @@ max-input-width (max 1 (- width 6)) display-input (if (> (count input) max-input-width) (subs input (- (count input) max-input-width)) - input)] + input) - [:col {:heights [chat-height input-box-height]} - (into [:col] display-lines) - [:box {:border :rounded :width :fill} - (if (pos? clamped-offset) - [:text {:fg :cyan} (str "↑" clamped-offset " " display-input "█")] - [:text {:fg :green} (str "> " display-input "█")])]])) + input-box [:box {:border :rounded :width :fill} + (if (pos? clamped-offset) + [:text {:fg :cyan} (str "↑" clamped-offset " " display-input "█")] + [:text {:fg :green} (str "> " display-input "█")])]] + + (if (empty? messages) + ;; Empty state: show two-column header + [:col {:heights [:flex input-box-height]} + (header-view) + input-box] + ;; Chat state: scrollable messages + [:col {:heights [chat-height input-box-height]} + (into [:col] display-lines) + input-box]))) ;; ============================================================ ;; Update @@ -229,6 +262,7 @@ (case (:type event) :text (update model :messages conj {:role :assistant :content (:content event)}) :tool (update model :messages conj {:role :tool :content (:label event)}) + :diff (update model :messages conj {:role :diff :content (:content event)}) :error (update model :messages conj {:role :error :content (:message event)}) :done (let [m (assoc model :agent-running? false @@ -258,20 +292,33 @@ (and (ev/key= event :enter) (seq (:input model)) (not (:agent-running? model))) - (let [text (:input model) - new-messages (conj (:messages model) {:role :user :content text}) - new-conversation (conj (:conversation model) {:role "user" :content text}) - agent-handle (core/run-agent-loop! new-conversation (:event-queue model))] - {:model (assoc model - :messages new-messages - :input "" - :conversation new-conversation - :agent-running? true - :agent-handle agent-handle - :spinner-frame 0 - :scroll-offset 0) - :events [(ev/delayed-event 100 {:type :poll}) - (ev/delayed-event 80 {:type :spinner})]}) + (let [text (str/trim (:input model))] + (cond + ;; Built-in: /skills — list loaded skills + (= text "/skills") + (let [listing (context/format-skill-list (:skills model))] + {:model (-> model + (update :messages conj {:role :user :content text}) + (update :messages conj {:role :assistant :content listing}) + (assoc :input "" :scroll-offset 0))}) + + ;; Skill expansion or normal message → send to LLM + :else + (let [expanded (context/expand-skill text (:skills model)) + llm-content (or expanded text) + new-messages (conj (:messages model) {:role :user :content text}) + new-conversation (conj (:conversation model) {:role "user" :content llm-content}) + agent-handle (core/run-agent-loop! (:system-prompt model) new-conversation (:event-queue model))] + {:model (assoc model + :messages new-messages + :input "" + :conversation new-conversation + :agent-running? true + :agent-handle agent-handle + :spinner-frame 0 + :scroll-offset 0) + :events [(ev/delayed-event 100 {:type :poll}) + (ev/delayed-event 80 {:type :spinner})]}))) ;; Backspace (ev/key= event :backspace) @@ -345,6 +392,11 @@ (defn -main [& args] (let [{:keys [continue? session-id prompt]} (parse-args args) + ;; Load project context and skills + project-context (context/load-project-context) + skills (context/load-skills) + _ (reset! core/skills-atom skills) + system-prompt (core/build-system-prompt project-context skills) ;; Resolve session to resume resume-id (cond session-id session-id @@ -364,13 +416,15 @@ (conj base-messages {:role :user :content prompt}) true] [base-conversation base-messages false]) - agent-handle (when start? (core/run-agent-loop! conversation eq)) + agent-handle (when start? (core/run-agent-loop! system-prompt conversation eq)) initial-model {:messages messages :input "" :conversation conversation :event-queue eq :session-id sid :created created + :system-prompt system-prompt + :skills skills :agent-running? start? :agent-handle agent-handle :spinner-frame 0 @@ -386,4 +440,4 @@ :init-events initial-events}) ;; Post-exit: print session info (println (str "\nSession: " sid)) - (println (str "To continue: ./agent --session " sid)))) + (println (str "To continue: agent --session " sid)))) diff --git a/src/agent/clojuredocs.clj b/src/agent/clojuredocs.clj new file mode 100644 index 0000000..0c2a95a --- /dev/null +++ b/src/agent/clojuredocs.clj @@ -0,0 +1,164 @@ +(ns agent.clojuredocs + "ClojureDocs lookup tool. Downloads and caches the ClojureDocs JSON export, + builds an in-memory index, and provides instant lookups by name." + (:require [babashka.http-client :as http] + [cheshire.core :as json] + [clojure.java.io :as io] + [clojure.string :as str])) + +;; ============================================================ +;; Download & Cache +;; ============================================================ + +(def ^:private export-url "https://clojuredocs.org/clojuredocs-export.json") + +(def ^:private cache-path + (let [home (System/getProperty "user.home")] + (.getPath (io/file home ".local" "share" "agent0" "clojuredocs-export.json")))) + +(def ^:private ttl-ms (* 7 24 60 60 1000)) ;; 7 days + +(defn- cache-fresh? [] + (let [f (io/file cache-path)] + (and (.exists f) + (> (.length f) 0) + (< (- (System/currentTimeMillis) (.lastModified f)) ttl-ms)))) + +(defn- ensure-cache! [] + (when-not (cache-fresh?) + (let [f (io/file cache-path)] + (io/make-parents f) + (let [resp (http/get export-url {:headers {"User-Agent" "agent0/1.0"}})] + (spit f (:body resp))))) + cache-path) + +;; ============================================================ +;; In-memory Index (delay — computed once per session) +;; ============================================================ + +(defn- build-index + "Parse the export JSON and build a lookup map. + Returns {:by-fqn {\"ns/name\" var-map}, :by-name {\"name\" [var-map ...]}}." + [path] + (let [data (json/parse-string (slurp path) true) + vars (:vars data)] + (reduce + (fn [idx v] + (let [ns-name (:ns v) + var-name (:name v) + fqn (str ns-name "/" var-name)] + (-> idx + (assoc-in [:by-fqn fqn] v) + (update-in [:by-name var-name] (fnil conj []) v)))) + {:by-fqn {} :by-name {}} + vars))) + +(def ^:private index + (delay (build-index (ensure-cache!)))) + +;; ============================================================ +;; Formatting +;; ============================================================ + +(defn- format-arglists [v] + (let [arglists (:arglists v)] + (when (seq arglists) + (str/join "\n" (map #(str " " %) arglists))))) + +(defn- format-see-alsos [v] + (let [see-alsos (:see-alsos v)] + (when (seq see-alsos) + (str "See also: " + (str/join ", " (map (fn [sa] + (let [tv (:to-var sa)] + (if (and (:ns tv) (:name tv)) + (str (:ns tv) "/" (:name tv)) + (str sa)))) + see-alsos)))))) + +(defn- format-examples [v mode] + (let [examples (:examples v)] + (case mode + "none" nil + "full" (when (seq examples) + (str/join "\n---\n" + (map-indexed (fn [i ex] + (str "Example " (inc i) ":\n" (:body ex))) + examples))) + ;; default: "short" — up to 3, truncated at 600 chars each + (when (seq examples) + (str/join "\n---\n" + (map-indexed (fn [i ex] + (let [body (:body ex) + body (if (> (count body) 600) + (str (subs body 0 600) "...") + body)] + (str "Example " (inc i) ":\n" body))) + (take 3 examples))))))) + +(defn- format-var [v examples-mode] + (let [fqn (str (:ns v) "/" (:name v)) + doc (:doc v) + arglists (format-arglists v) + see-alsos (format-see-alsos v) + examples (format-examples v examples-mode)] + (str/join "\n\n" + (remove nil? + [(str "## " fqn) + (when arglists (str "Arglists:\n" arglists)) + (when doc (str/trim doc)) + see-alsos + examples])))) + +;; ============================================================ +;; Lookup Logic +;; ============================================================ + +(defn lookup + "Look up a Clojure var in ClojureDocs. + Tries: FQN exact match → bare name match → substring fallback (capped at 10)." + [query examples-mode] + (let [{:keys [by-fqn by-name]} @index + query (str/trim query)] + (cond + ;; FQN exact match + (get by-fqn query) + (format-var (get by-fqn query) examples-mode) + + ;; Bare name exact match + (get by-name query) + (let [matches (get by-name query)] + (if (= 1 (count matches)) + (format-var (first matches) examples-mode) + (str (count matches) " vars named \"" query "\":\n\n" + (str/join "\n\n" (map #(format-var % examples-mode) matches))))) + + ;; Substring fallback + :else + (let [q (str/lower-case query) + matches (->> (vals by-fqn) + (filter (fn [v] + (or (str/includes? (str/lower-case (str (:name v))) q) + (str/includes? (str/lower-case (str (:ns v) "/" (:name v))) q)))) + (take 10))] + (if (seq matches) + (str (count matches) " matches for \"" query "\":\n\n" + (str/join "\n\n" (map #(format-var % examples-mode) matches))) + (str "No ClojureDocs entry found for \"" query "\".")))))) + +;; ============================================================ +;; Tool Definition +;; ============================================================ + +(def tool + {:type "function" + :function {:name "clojuredocs" + :description "Look up Clojure function/macro documentation from ClojureDocs. Supports fully-qualified names (e.g. \"clojure.core/map\"), bare names (e.g. \"map\"), and substring search (e.g. \"reduc\"). Returns doc, arglists, see-alsos, and examples. Use this instead of web search for Clojure standard library docs." + :parameters {:type "object" + :properties {:name {:type "string" + :description "Function or macro name to look up. Can be fully-qualified (\"clojure.core/map\"), bare (\"map\"), or a substring (\"reduc\")."} + :examples {:type "string" + :description "How many examples to include: \"none\", \"short\" (default, up to 3 truncated), or \"full\" (all examples, untruncated)."}} + :required ["name"]}} + :impl (fn [{:keys [name examples]}] + (lookup name (or examples "short")))}) diff --git a/src/agent/context.clj b/src/agent/context.clj new file mode 100644 index 0000000..16f9184 --- /dev/null +++ b/src/agent/context.clj @@ -0,0 +1,183 @@ +(ns agent.context + (:require [clojure.java.io :as io] + [clojure.string :as str])) + +;; ============================================================ +;; Project Context Loading +;; ============================================================ + +(def ^:private project-context-files + "Context files to search for in CWD, in order." + [{:path ".agent0/context.md" :label "agent0 context"} + {:path "CLAUDE.md" :label "CLAUDE.md"} + {:path ".cursorrules" :label ".cursorrules"} + {:path ".github/copilot-instructions.md" :label "copilot-instructions.md"}]) + +(defn- read-context-section + "Read a file and wrap its content in a labeled section, or nil if missing/empty." + [file label] + (when (.exists file) + (let [content (str/trim (slurp file))] + (when (seq content) + (str "## " label "\n\n" content))))) + +(defn load-project-context + "Load context from global (~/.config/agent0/context.md) and project files in CWD. + Global context is included first, then project-local files. + Returns a single string with section headers, or nil if none found." + [] + (let [home (System/getProperty "user.home") + global-file (io/file home ".config" "agent0" "context.md") + global-section (read-context-section global-file "Global Instructions (from ~/.config/agent0/context.md)") + project-sections + (for [{:keys [path label]} project-context-files + :let [section (read-context-section (io/file path) + (str "Project Instructions (from " label ")"))] + :when section] + section) + all (cond-> [] + global-section (conj global-section) + (seq project-sections) (into project-sections))] + (when (seq all) + (str/join "\n\n" all)))) + +;; ============================================================ +;; Skills Parsing +;; ============================================================ + +(defn parse-skills + "Parse a skills.md string into a map of skill-name -> {:params [...] :template \"...\" :source source} + Format: headings like `# /name` or `# /name ` followed by template body. + source is attached by the caller (e.g. :global or :project)." + ([text] (parse-skills text nil)) + ([text source] + (when (and text (seq (str/trim text))) + (let [;; Split on lines starting with # / + parts (str/split text #"(?m)^# +/") + ;; First part before any heading is ignored + skill-parts (rest parts)] + (into {} + (for [part skill-parts + :let [lines (str/split-lines part) + header (first lines) + ;; Parse name and params from header line + tokens (str/split (str/trim header) #"\s+") + skill-name (first tokens) + params (vec (for [t (rest tokens) + :when (and (str/starts-with? t "<") + (str/ends-with? t ">"))] + (subs t 1 (dec (count t))))) + ;; Body is everything after header, trimmed + body (str/trim (str/join "\n" (rest lines)))] + :when (and skill-name (seq body))] + [skill-name (cond-> {:params params :template body} + source (assoc :source source))])))))) + + +(defn- parse-frontmatter + "Parse YAML-ish frontmatter from a string starting with ---. + Returns [metadata-map body-string]. Handles simple key: value pairs." + [text] + (if (str/starts-with? text "---") + (let [after-open (subs text 3) + end-idx (str/index-of after-open "\n---")] + (if end-idx + (let [fm-str (subs after-open 0 end-idx) + body (str/trim (subs after-open (+ end-idx 4))) + meta (->> (str/split-lines fm-str) + (keep (fn [line] + (when-let [colon-idx (str/index-of line ":")] + (let [k (str/trim (subs line 0 colon-idx)) + v (str/trim (subs line (inc colon-idx)))] + (when (and (seq k) (seq v)) + [k v]))))) + (into {}))] + [meta body]) + [nil text])) + [nil text])) + +(defn- load-claude-skills + "Load skills from ~/.claude/skills/*/SKILL.md. + Each SKILL.md has YAML frontmatter with 'name' and a markdown body template." + [] + (let [home (System/getProperty "user.home") + skills-dir (io/file home ".claude" "skills")] + (when (.isDirectory skills-dir) + (->> (.listFiles skills-dir) + (filter #(.isDirectory %)) + (keep (fn [dir] + (let [skill-file (io/file dir "SKILL.md")] + (when (.exists skill-file) + (let [text (slurp skill-file) + [fm body] (parse-frontmatter text) + skill-name (get fm "name")] + (when (and skill-name (seq body)) + [skill-name {:params [] :template body :source :claude}])))))) + (into {}))))) + +(defn load-skills + "Load skills from Claude Code (~/.claude/skills/), global (~/.config/agent0/skills.md), + and project (.agent0/skills.md). Later sources override earlier for same-name skills. + Each skill carries :source (:claude, :global, or :project)." + [] + (let [home (System/getProperty "user.home") + global-file (io/file home ".config" "agent0" "skills.md") + project-file (io/file ".agent0" "skills.md") + claude-skills (load-claude-skills) + global-skills (when (.exists global-file) + (parse-skills (slurp global-file) :global)) + project-skills (when (.exists project-file) + (parse-skills (slurp project-file) :project))] + (merge claude-skills global-skills project-skills))) + +(defn format-skill-list + "Format loaded skills into a displayable string. + Groups by source, sorted by name within each group." + [skills] + (if (or (nil? skills) (empty? skills)) + "No skills loaded." + (let [sorted (sort-by key skills) + by-source (group-by (fn [[_ v]] (:source v)) sorted) + format-entry (fn [[name {:keys [params]}]] + (if (seq params) + (str " /" name " " (str/join " " (map #(str "<" % ">") params))) + (str " /" name))) + sections (cond-> [] + (seq (:project by-source)) + (conj (str "Project skills:\n" + (str/join "\n" (map format-entry (:project by-source))))) + (seq (:global by-source)) + (conj (str "Global skills:\n" + (str/join "\n" (map format-entry (:global by-source))))) + (seq (:claude by-source)) + (conj (str "Claude Code skills:\n" + (str/join "\n" (map format-entry (:claude by-source))))) + ;; Skills without a source tag (shouldn't happen, but defensive) + (seq (get by-source nil)) + (conj (str "Skills:\n" + (str/join "\n" (map format-entry (get by-source nil))))))] + (str/join "\n\n" sections)))) + +(defn expand-skill + "If input starts with /name, look up the skill and expand its template. + Positional args from the input are substituted for {param} placeholders. + Returns the expanded string, or nil if input is not a skill invocation." + [input skills] + (when (and input (str/starts-with? input "/")) + (let [tokens (str/split (str/trim input) #"\s+" 2) + skill-name (subs (first tokens) 1) ;; strip leading / + args-str (second tokens) + skill (get skills skill-name)] + (when skill + (let [{:keys [params template]} skill + ;; Split remaining input into positional args + args (if args-str + (str/split (str/trim args-str) #"\s+") + []) + ;; Build substitution map: {param} -> arg value + expanded (reduce + (fn [tmpl [param arg]] + (str/replace tmpl (str "{" param "}") arg)) + template + (map vector params args))] + expanded))))) diff --git a/src/agent/core.clj b/src/agent/core.clj index 4b9dd19..f4bce63 100644 --- a/src/agent/core.clj +++ b/src/agent/core.clj @@ -1,5 +1,7 @@ (ns agent.core - (:require [agent.web-search :as web-search] + (:require [agent.clojuredocs :as clojuredocs] + [agent.context :as context] + [agent.web-search :as web-search] [babashka.fs :as fs] [babashka.http-client :as http] [cheshire.core :as json] @@ -16,9 +18,9 @@ (def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434")) (def model (or (System/getenv "AGENT_MODEL") "qwen3-coder-next")) -(def max-tokens 65536) +(def max-tokens 131072) -(def system-prompt +(def base-system-prompt "You are a helpful coding assistant. You can read, list, create, edit, search, and find files to help the user with their coding tasks. When researching or exploring code: @@ -26,13 +28,33 @@ When researching or exploring code: 2. Use grep_files to search file contents and find relevant line numbers 3. Use read_file with offset and limit to read only the relevant sections — avoid reading entire files unless they are small or you need the full context +For Clojure documentation: +- Use the clojuredocs tool for instant lookups of Clojure standard library functions and macros. +- Supports fully-qualified names (clojure.core/map), bare names (map), and substring search (reduc). + For web information: - Use delegate with the web_search agent. It can search the web AND fetch full page content for you. - Describe what you need clearly and the agent will return a concise summary of its findings. - You do NOT have direct web search or URL fetch tools — always delegate. +For predefined workflows: +- Use the skills tool to run predefined skill workflows by name. +- If the user invokes a skill (e.g. /deploy, /test), use skills(action: 'run', name: '') to execute it. + Always explain what you're doing before using tools. Use the tools when needed to complete the task.") +(defn build-system-prompt + "Build the full system prompt by appending project context (if any) and loaded skills to the base prompt." + [project-context skills] + (let [skills-section (when (seq skills) + (let [listing (context/format-skill-list skills)] + (str "## Available Skills\n\n" + "The following skills are loaded and ready to use via the skills tool with action 'run':\n\n" + listing)))] + (cond-> base-system-prompt + project-context (str "\n\n" project-context) + skills-section (str "\n\n" skills-section)))) + ;; ============================================================ ;; Logging ;; ============================================================ @@ -91,10 +113,25 @@ Always explain what you're doing before using tools. Use the tools when needed t (when (seq files) (str/replace (.getName (first files)) ".edn" "")))))) +;; ============================================================ +;; Skills Atom (populated by app.clj at startup) +;; ============================================================ + +(def skills-atom (atom {})) + ;; ============================================================ ;; Tools Implementation ;; ============================================================ +(defn- ->long + "Coerce a value to long. LLMs sometimes return numbers as strings." + [x] + (cond + (nil? x) nil + (number? x) (long x) + (string? x) (parse-long x) + :else nil)) + (defn read-file [{:keys [path offset limit]}] (let [file (io/file path)] (cond @@ -107,6 +144,8 @@ Always explain what you're doing before using tools. Use the tools when needed t :else (let [lines (str/split-lines (slurp file)) total (count lines) + offset (->long offset) + limit (->long limit) start (max 0 (dec (or offset 1))) selected (cond->> (drop start lines) limit (take limit)) @@ -135,6 +174,32 @@ Always explain what you're doing before using tools. Use the tools when needed t (str/join "\n")) (str "Error: Not a directory: " path)))) +(defn- generate-edit-diff + "Generate a unified-diff-like string for a file edit." + [path content old_str new_str] + (let [pos (str/index-of content old_str) + pre (subs content 0 pos) + start-line (count (filter #(= % \newline) pre)) + all-lines (vec (str/split-lines content)) + old-lines (if (empty? old_str) [] (str/split-lines old_str)) + new-lines (if (empty? new_str) [] (str/split-lines new_str)) + end-line (+ start-line (count old-lines)) + ctx 3 + ctx-start (max 0 (- start-line ctx)) + ctx-end (min (count all-lines) (+ end-line ctx)) + before-ctx (subvec all-lines ctx-start start-line) + after-ctx (subvec all-lines end-line ctx-end) + diff-lines (concat + [(str "@@ " path ":" (inc start-line) " @@")] + (map #(str " " %) before-ctx) + (map #(str "-" %) old-lines) + (map #(str "+" %) new-lines) + (map #(str " " %) after-ctx)) + max-lines 30] + (str/join "\n" (if (> (count diff-lines) max-lines) + (concat (take max-lines diff-lines) ["..."]) + diff-lines)))) + (defn edit-file [{:keys [path old_str new_str]}] (let [file (io/file path)] (if (.exists file) @@ -148,18 +213,30 @@ Always explain what you're doing before using tools. Use the tools when needed t (str "Error: old_str appears " occurrences " times. Must be unique.") :else - (do (spit file (str/replace-first content old_str new_str)) - (str "Successfully edited " path)))) + (let [diff (generate-edit-diff path content old_str new_str)] + (spit file (str/replace-first content old_str new_str)) + {:message (str "Successfully edited " path) + :diff diff}))) (str "Error: File not found: " path)))) (defn create-file [{:keys [path content]}] (let [file (io/file path)] (if (.exists file) (str "Error: File already exists: " path) - (do + (let [lines (str/split-lines content) + max-lines 20 + truncated? (> (count lines) max-lines) + shown (if truncated? (take max-lines lines) lines) + diff (str/join "\n" + (concat + [(str "@@ new: " path " @@")] + (map #(str "+" %) shown) + (when truncated? + [(str "... +" (- (count lines) max-lines) " more lines")])))] (io/make-parents file) (spit file content) - (str "Successfully created " path))))) + {:message (str "Successfully created " path) + :diff diff})))) (defn run-shell-command [{:keys [command]}] (let [proc (-> (ProcessBuilder. ["bash" "-c" command]) @@ -226,6 +303,10 @@ Always explain what you're doing before using tools. Use the tools when needed t ;; Ollama API (parameterized) ;; ============================================================ +(def llm-timeout-ms + "Timeout for LLM API calls in milliseconds (5 minutes)." + (* 5 60 1000)) + (defn- call-llm* [sys-prompt tool-defs messages] (let [body {:model model :options {:num_predict max-tokens} @@ -234,7 +315,8 @@ Always explain what you're doing before using tools. Use the tools when needed t :stream false} response (http/post (str ollama-host "/api/chat") {:headers {"Content-Type" "application/json"} - :body (json/generate-string body)})] + :body (json/generate-string body) + :timeout llm-timeout-ms})] (json/parse-string (:body response) true))) (defn- truncate [s max-len] @@ -312,6 +394,21 @@ Always explain what you're doing before using tools. Use the tools when needed t (str "Error: Unknown agent '" agent "'. Available agents: " (str/join ", " (keys subagent-registry)))))) +;; ============================================================ +;; Skills Tool +;; ============================================================ + +(defn skills-tool [{:keys [action name args]}] + (let [skills @skills-atom] + (case action + "list" (context/format-skill-list skills) + "run" (if-let [skill (get skills name)] + (let [input (str "/" name (when args (str " " args)))] + (or (context/expand-skill input skills) + (str "Error: Failed to expand skill '" name "'"))) + (str "Error: Unknown skill '" name "'. Use action 'list' to see available skills.")) + (str "Error: Unknown action '" action "'. Use 'list' or 'run'.")))) + ;; ============================================================ ;; Tool Registry (OpenAI format) ;; ============================================================ @@ -389,7 +486,19 @@ Always explain what you're doing before using tools. Use the tools when needed t :properties {:agent {:type "string" :description "The subagent to delegate to (e.g. \"web_search\")"} :task {:type "string" :description "A clear description of what you want the subagent to research or do"}} :required ["agent" "task"]}} - :impl delegate}]) + :impl delegate} + + {:type "function" + :function {:name "skills" + :description "Discover and run predefined skill workflows. Use 'list' to see available skills, 'run' to execute one." + :parameters {:type "object" + :properties {:action {:type "string" :description "Action: 'list' to see available skills, 'run' to execute a skill"} + :name {:type "string" :description "Skill name (required for 'run')"} + :args {:type "string" :description "Arguments for the skill (optional, space-separated)"}} + :required ["action"]}} + :impl skills-tool} + + clojuredocs/tool]) (def tool-map (into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools))) @@ -401,7 +510,7 @@ Always explain what you're doing before using tools. Use the tools when needed t ;; Main Agent LLM ;; ============================================================ -(defn call-llm [messages] +(defn call-llm [system-prompt messages] (let [result (call-llm* system-prompt tool-definitions messages)] {:choices [{:message (:message result) :finish_reason (if (seq (get-in result [:message :tool_calls])) @@ -424,9 +533,13 @@ Always explain what you're doing before using tools. Use the tools when needed t (tool-fn args)) (str "Error: Unknown tool '" tool-name "'")) (catch Exception e - (str "Error: " (.getMessage e))))] - (log log-file "<- Result:" (truncate (str/replace (str result) #"\n" "\\n") 200)) - {:role "tool" :tool_call_id id :content result}))) + (str "Error: " (.getMessage e)))) + [content diff] (if (map? result) + [(:message result) (:diff result)] + [(str result) nil])] + (log log-file "<- Result:" (truncate (str/replace (str content) #"\n" "\\n") 200)) + (cond-> {:role "tool" :tool_call_id id :content content} + diff (assoc :diff diff))))) (defn tool-call-label "Generate a human-readable label for a tool call." @@ -435,11 +548,13 @@ Always explain what you're doing before using tools. Use the tools when needed t raw-args (get-in tc [:function :arguments]) args (if (string? raw-args) (json/parse-string raw-args true) raw-args)] (case tname - "read_file" (str "Reading " (:path args) - (if (or (:offset args) (:limit args)) - (str ":" (or (:offset args) 1) - (when (:limit args) (str "-" (+ (dec (or (:offset args) 1)) (:limit args))))) - " (entire file)")) + "read_file" (let [offset (->long (:offset args)) + limit (->long (:limit args))] + (str "Reading " (:path args) + (if (or offset limit) + (str ":" (or offset 1) + (when limit (str "-" (+ (dec (or offset 1)) limit)))) + " (entire file)"))) "list_files" (str "Listing " (or (:path args) ".")) "edit_file" (str "Editing " (:path args)) "create_file" (str "Creating " (:path args)) @@ -447,6 +562,10 @@ Always explain what you're doing before using tools. Use the tools when needed t "glob_files" (str "Globbing " (:pattern args) (when (:path args) (str " in " (:path args)))) "grep_files" (str "Grepping " (:pattern args) (when (:include args) (str " in " (:include args)))) "delegate" (str "Spawning " (:agent args) " subagent") + "clojuredocs" (str "Looking up " (:name args)) + "skills" (if (= (:action args) "run") + (str "Running skill /" (:name args)) + "Listing skills") (str "Calling " tname)))) ;; ============================================================ @@ -501,7 +620,7 @@ Always explain what you're doing before using tools. Use the tools when needed t {:type :error :message \"...\"} - error {:type :done :conversation [...]} - loop finished Returns {:future f :cancel! cancel-atom}." - [conversation event-queue] + [system-prompt conversation event-queue] (let [log-file (init-log) cancelled? (atom false)] (log log-file "Agent loop started | model:" model "| messages:" (count conversation)) @@ -528,7 +647,7 @@ Always explain what you're doing before using tools. Use the tools when needed t :else (do (log log-file "Iteration" iteration "| messages:" (count messages)) - (let [response (call-llm messages) + (let [response (call-llm system-prompt messages) choice (first (:choices response)) message (:message choice) finish-reason (:finish_reason choice) @@ -554,9 +673,14 @@ Always explain what you're doing before using tools. Use the tools when needed t (doseq [tc tool-calls] (swap! event-queue conj {:type :tool :label (tool-call-label tc)})) ;; Execute tools - (let [tool-results (mapv #(execute-tool log-file %) tool-calls) + (let [tool-results (mapv #(execute-tool log-file %) tool-calls)] + ;; Push diffs to UI + (doseq [tr tool-results] + (when-let [diff (:diff tr)] + (swap! event-queue conj {:type :diff :content diff}))) + (let [clean-results (mapv #(dissoc % :diff) tool-results) assistant-msg (select-keys message [:role :content :tool_calls]) - new-messages (into (conj messages assistant-msg) tool-results) + new-messages (into (conj messages assistant-msg) clean-results) ;; If nudge, inject a system hint to stop researching new-messages (if nudge? (do (log log-file "Research loop nudge injected") @@ -564,7 +688,7 @@ Always explain what you're doing before using tools. Use the tools when needed t {:role "system" :content "You have already performed several web searches. You have enough information to answer. Stop searching and synthesize your findings into a clear response now."})) new-messages)] - (recur new-messages (inc iteration) signatures))))) + (recur new-messages (inc iteration) signatures)))))) ;; Done - no more tool calls (do (log log-file "Agent finished after" iteration "iterations") diff --git a/src/agent/markdown.clj b/src/agent/markdown.clj index b9760d5..5b4e04f 100644 --- a/src/agent/markdown.clj +++ b/src/agent/markdown.clj @@ -1,6 +1,7 @@ (ns agent.markdown "Convert markdown text to ANSI-styled terminal output." - (:require [clojure.string :as str] + (:require [agent.syntax :as syntax] + [clojure.string :as str] [tui.ansi :as ansi])) ;; ============================================================ @@ -126,21 +127,23 @@ (if (or (nil? text) (empty? text)) [""] (loop [[line & remaining] (str/split-lines text) - in-code-block? false + code-block nil result []] (if-not line result (cond ;; Code fence toggle (str/starts-with? (str/trimr line) "```") - (if in-code-block? - (recur remaining false result) - (recur remaining true result)) + (if code-block + (recur remaining nil result) + (let [tag (str/trim (subs (str/trimr line) 3)) + lang (when (seq tag) (syntax/lang-for-fence tag))] + (recur remaining {:lang lang} result))) - ;; Inside code block — preserve formatting, no wrapping - in-code-block? - (recur remaining true - (conj result (ansi/style (str " " line) :fg :bright-black))) + ;; Inside code block — preserve formatting, syntax highlight + code-block + (recur remaining code-block + (conj result (str " " (syntax/highlight-line line (:lang code-block) "") "\033[0m"))) ;; Header: # text (re-matches #"^(#{1,6})\s+(.*)" line) diff --git a/src/agent/syntax.clj b/src/agent/syntax.clj new file mode 100644 index 0000000..063037d --- /dev/null +++ b/src/agent/syntax.clj @@ -0,0 +1,541 @@ +(ns agent.syntax + "Regex-based syntax highlighting for code blocks and diffs. + Single-pass tokenizer using java.util.regex.Matcher.lookingAt()." + (:require [clojure.string :as str]) + (:import [java.util.regex Pattern Matcher])) + +;; ============================================================ +;; Color Palette (ANSI 256) +;; ============================================================ + +(def ^:private colors + {:string "\033[38;5;108m" + :comment "\033[38;5;245m" + :keyword "\033[38;5;176m" + :number "\033[38;5;216m" + :builtin "\033[38;5;75m" + :constant "\033[38;5;216m" + :type "\033[38;5;180m" + :clj-kw "\033[38;5;73m" + :param "\033[38;5;208m"}) + +;; ============================================================ +;; Tokenizer Engine +;; ============================================================ + +(defn- highlight-line* + "Walk left-to-right through `line`. For each position, try rules in order; + first match wins. `rules` is a vector of [compiled-Pattern color-or-fn]. + When color-or-fn is a function, it receives the matched text and returns + an ANSI color string (or nil for default). + `default-fg` is the ANSI code for unhighlighted text." + [^String line rules ^String default-fg] + (let [len (.length line) + sb (StringBuilder.) + matcher-cache (object-array (count rules))] + ;; Pre-create matchers for each rule + (dotimes [i (count rules)] + (let [[^Pattern pat _] (nth rules i)] + (aset matcher-cache i (.matcher pat line)))) + (loop [pos 0] + (if (>= pos len) + (.toString sb) + (let [matched? + (loop [ri 0] + (if (>= ri (count rules)) + false + (let [^Matcher m (aget matcher-cache ri) + _ (.region m pos len)] + (if (.lookingAt m) + (let [[_ color-or-fn] (nth rules ri) + text (.group m) + color (if (fn? color-or-fn) + (color-or-fn text) + color-or-fn)] + (if color + (do (.append sb color) + (.append sb text) + (.append sb default-fg)) + (do (.append sb text))) + (.end m)) + (recur (inc ri))))))] + (if matched? + (recur (long matched?)) + (do (.append sb (.charAt line pos)) + (recur (inc pos))))))))) + +;; ============================================================ +;; Language: Clojure +;; ============================================================ + +(def ^:private clj-special-forms + #{"def" "defn" "defn-" "defmacro" "defmethod" "defmulti" "defonce" "defprotocol" + "defrecord" "deftype" "defstruct" "definline" "definterface" + "fn" "fn*" "if" "if-let" "if-not" "if-some" + "when" "when-let" "when-not" "when-first" "when-some" + "do" "let" "letfn" "binding" "loop" "recur" + "cond" "condp" "cond->" "cond->>" "case" + "try" "catch" "finally" "throw" + "quote" "var" "import" "require" "use" "refer" "ns" + "and" "or" "not" + "doseq" "dotimes" "doto" "dorun" "doall" + "for" "while" + "new" "set!" "monitor-enter" "monitor-exit" + "->" "->>" "as->" "some->" "some->>"}) + +(def ^:private clj-builtins + #{"map" "filter" "reduce" "apply" "partial" "comp" "juxt" "complement" + "mapv" "filterv" "mapcat" "keep" "remove" + "first" "second" "last" "rest" "next" "cons" "conj" "into" + "assoc" "dissoc" "update" "get" "get-in" "assoc-in" "update-in" "select-keys" + "merge" "merge-with" + "atom" "deref" "reset!" "swap!" "compare-and-set!" + "str" "subs" "format" "name" "keyword" "symbol" + "println" "print" "prn" "pr" "pr-str" "prn-str" + "count" "empty?" "seq" "seq?" "sequential?" + "vec" "vector" "vector?" "list" "list?" "set" "hash-set" "sorted-set" + "hash-map" "sorted-map" "zipmap" "frequencies" "group-by" + "keys" "vals" "contains?" "find" + "range" "repeat" "repeatedly" "iterate" "cycle" "interleave" "interpose" + "take" "drop" "take-while" "drop-while" "split-at" "split-with" "partition" + "partition-by" "partition-all" + "concat" "flatten" "distinct" "sort" "sort-by" "reverse" "shuffle" + "every?" "some" "not-every?" "not-any?" + "identity" "constantly" + "inc" "dec" "+" "-" "*" "/" "mod" "rem" "quot" + "=" "==" "not=" "<" ">" "<=" ">=" + "zero?" "pos?" "neg?" "even?" "odd?" "number?" "integer?" + "nil?" "true?" "false?" "string?" "keyword?" "symbol?" "map?" "coll?" "fn?" + "type" "class" "instance?" "satisfies?" "extends?" + "meta" "with-meta" "vary-meta" + "read-string" "slurp" "spit" + "re-find" "re-matches" "re-seq" "re-pattern" + "future" "promise" "deliver" "realized?" "pmap" + "resolve" "ns-resolve" "eval" + "max" "min" "abs" "rand" "rand-int" + "nth" "nfirst" "nnext" "fnext" "ffirst" + "not-empty" "bounded-count" "transduce" "sequence" + "volatile!" "vswap!" "vreset!" + "reduced" "reduced?" "unreduced" "ensure-reduced" + "ex-info" "ex-data" "ex-message"}) + +(def ^:private clj-constants + #{"nil" "true" "false"}) + +(defn- clj-classify [text] + (cond + (contains? clj-constants text) (:constant colors) + (contains? clj-special-forms text) (:keyword colors) + (contains? clj-builtins text) (:builtin colors) + :else nil)) + +(def ^:private clj-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [[";.*" (:comment colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["#\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["\\\\(?:newline|space|tab|backspace|formfeed|return|[a-zA-Z])" (:string colors)] + [":[a-zA-Z_*+!?<>=/.\\-][a-zA-Z0-9_*+!?<>=/.\\-:#]*" (:clj-kw colors)] + ["-?0[xX][0-9a-fA-F]+" (:number colors)] + ["-?\\d+\\.\\d+" (:number colors)] + ["-?\\d+/\\d+" (:number colors)] + ["-?\\d+" (:number colors)] + ["##(?:Inf|-Inf|NaN)" (:constant colors)] + ["[a-zA-Z_*+!?<>=/.\\-][a-zA-Z0-9_*+!?<>=/.\\-:#]*" clj-classify]])) + +;; ============================================================ +;; Language: JavaScript / TypeScript +;; ============================================================ + +(def ^:private js-keywords + #{"async" "await" "break" "case" "catch" "class" "const" "continue" + "debugger" "default" "delete" "do" "else" "export" "extends" + "finally" "for" "from" "function" "if" "import" "in" "instanceof" + "let" "new" "of" "return" "static" "super" "switch" "this" + "throw" "try" "typeof" "var" "void" "while" "with" "yield" + ;; TS extras + "type" "interface" "enum" "namespace" "declare" "implements" + "abstract" "as" "readonly" "keyof" "infer"}) + +(def ^:private js-builtins + #{"console" "Math" "JSON" "Object" "Array" "String" "Number" "Boolean" + "Promise" "Map" "Set" "WeakMap" "WeakSet" "Symbol" "Proxy" "Reflect" + "parseInt" "parseFloat" "isNaN" "isFinite" "undefined" "NaN" "Infinity" + "require" "module" "exports" "process" "Buffer" "global" "window" "document"}) + +(def ^:private js-constants + #{"true" "false" "null" "undefined" "NaN" "Infinity"}) + +(defn- js-classify [text] + (cond + (contains? js-constants text) (:constant colors) + (contains? js-keywords text) (:keyword colors) + (contains? js-builtins text) (:builtin colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private js-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["//.*" (:comment colors)] + ["/\\*[\\s\\S]*?\\*/" (:comment colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["`(?:[^`\\\\]|\\\\.)*`" (:string colors)] + ["/(?![*/])(?:[^/\\\\]|\\\\.)+/[gimsuy]*" (:string colors)] + ["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] + ["0[xX][0-9a-fA-F]+" (:number colors)] + ["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)] + ["\\d+" (:number colors)] + ["[a-zA-Z_$][a-zA-Z0-9_$]*" js-classify]])) + +;; ============================================================ +;; Language: Python +;; ============================================================ + +(def ^:private py-keywords + #{"and" "as" "assert" "async" "await" "break" "class" "continue" + "def" "del" "elif" "else" "except" "finally" "for" "from" + "global" "if" "import" "in" "is" "lambda" "nonlocal" "not" + "or" "pass" "raise" "return" "try" "while" "with" "yield" + "match" "case"}) + +(def ^:private py-builtins + #{"print" "len" "range" "int" "str" "float" "list" "dict" "set" "tuple" + "bool" "type" "isinstance" "issubclass" "hasattr" "getattr" "setattr" + "super" "property" "staticmethod" "classmethod" "enumerate" "zip" + "map" "filter" "sorted" "reversed" "any" "all" "min" "max" "sum" + "abs" "round" "input" "open" "repr" "id" "hash" "callable" "iter" "next" + "ValueError" "TypeError" "KeyError" "IndexError" "RuntimeError" + "Exception" "StopIteration" "AttributeError" "ImportError" "OSError" + "self" "cls"}) + +(def ^:private py-constants + #{"True" "False" "None"}) + +(defn- py-classify [text] + (cond + (contains? py-constants text) (:constant colors) + (contains? py-keywords text) (:keyword colors) + (contains? py-builtins text) (:builtin colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private py-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["#.*" (:comment colors)] + ["\"\"\"[\\s\\S]*?\"\"\"" (:string colors)] + ["'''[\\s\\S]*?'''" (:string colors)] + ["f\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["f'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["@[a-zA-Z_][a-zA-Z0-9_.]*" (:param colors)] + ["0[xX][0-9a-fA-F]+" (:number colors)] + ["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)] + ["\\d+" (:number colors)] + ["[a-zA-Z_][a-zA-Z0-9_]*" py-classify]])) + +;; ============================================================ +;; Language: Java +;; ============================================================ + +(def ^:private java-keywords + #{"abstract" "assert" "boolean" "break" "byte" "case" "catch" "char" + "class" "const" "continue" "default" "do" "double" "else" "enum" + "extends" "final" "finally" "float" "for" "goto" "if" "implements" + "import" "instanceof" "int" "interface" "long" "native" "new" + "package" "private" "protected" "public" "return" "short" "static" + "strictfp" "super" "switch" "synchronized" "this" "throw" "throws" + "transient" "try" "var" "void" "volatile" "while" "yield" "record" + "sealed" "permits" "non-sealed"}) + +(def ^:private java-constants + #{"true" "false" "null"}) + +(defn- java-classify [text] + (cond + (contains? java-constants text) (:constant colors) + (contains? java-keywords text) (:keyword colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private java-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["//.*" (:comment colors)] + ["/\\*[\\s\\S]*?\\*/" (:comment colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] + ["0[xX][0-9a-fA-F]+[lL]?" (:number colors)] + ["\\d+\\.\\d+[fFdD]?" (:number colors)] + ["\\d+[lLfFdD]?" (:number colors)] + ["[a-zA-Z_$][a-zA-Z0-9_$]*" java-classify]])) + +;; ============================================================ +;; Language: Kotlin +;; ============================================================ + +(def ^:private kt-keywords + #{"abstract" "annotation" "as" "break" "by" "catch" "class" "companion" + "const" "constructor" "continue" "crossinline" "data" "do" "else" "enum" + "expect" "external" "final" "finally" "for" "fun" "get" "if" "import" + "in" "infix" "init" "inline" "inner" "interface" "internal" "is" + "lateinit" "noinline" "object" "open" "operator" "out" "override" + "package" "private" "protected" "public" "reified" "return" "sealed" + "set" "super" "suspend" "tailrec" "this" "throw" "try" "typealias" + "val" "var" "vararg" "when" "where" "while" "yield"}) + +(def ^:private kt-builtins + #{"println" "print" "listOf" "mutableListOf" "mapOf" "mutableMapOf" + "setOf" "mutableSetOf" "arrayOf" "intArrayOf" "emptyList" "emptyMap" + "require" "check" "error" "TODO" "repeat" "run" "with" "apply" "also" "let" + "takeIf" "takeUnless" "lazy" "coroutineScope" "launch" "async" + "String" "Int" "Long" "Double" "Float" "Boolean" "Char" "Unit" "Any" "Nothing"}) + +(def ^:private kt-constants + #{"true" "false" "null"}) + +(defn- kt-classify [text] + (cond + (contains? kt-constants text) (:constant colors) + (contains? kt-keywords text) (:keyword colors) + (contains? kt-builtins text) (:builtin colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private kt-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["//.*" (:comment colors)] + ["/\\*[\\s\\S]*?\\*/" (:comment colors)] + ["\"\"\"[\\s\\S]*?\"\"\"" (:string colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] + ["0[xX][0-9a-fA-F]+[lL]?" (:number colors)] + ["\\d+\\.\\d+[fFdD]?" (:number colors)] + ["\\d+[lLfFdD]?" (:number colors)] + ["[a-zA-Z_][a-zA-Z0-9_]*" kt-classify]])) + +;; ============================================================ +;; Language: Rust +;; ============================================================ + +(def ^:private rust-keywords + #{"as" "async" "await" "break" "const" "continue" "crate" "dyn" + "else" "enum" "extern" "fn" "for" "if" "impl" "in" + "let" "loop" "match" "mod" "move" "mut" "pub" "ref" + "return" "self" "Self" "static" "struct" "super" "trait" "type" + "unsafe" "use" "where" "while" "yield" "macro_rules"}) + +(def ^:private rust-builtins + #{"println" "eprintln" "format" "vec" "panic" "assert" "assert_eq" + "assert_ne" "debug_assert" "todo" "unimplemented" "unreachable" + "cfg" "derive" "include" "include_str" "env" "concat" "stringify" + "Some" "None" "Ok" "Err" "Box" "Rc" "Arc" "Vec" "String" + "Option" "Result" "HashMap" "HashSet" "BTreeMap" "BTreeSet" + "Iterator" "IntoIterator" "From" "Into" "TryFrom" "TryInto" + "Display" "Debug" "Clone" "Copy" "Default" "PartialEq" "Eq" + "PartialOrd" "Ord" "Hash" "Send" "Sync" "Sized" "Drop" "Fn" "FnMut" "FnOnce"}) + +(def ^:private rust-constants + #{"true" "false"}) + +(defn- rust-classify [text] + (cond + (contains? rust-constants text) (:constant colors) + (contains? rust-keywords text) (:keyword colors) + (contains? rust-builtins text) (:builtin colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private rust-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["//.*" (:comment colors)] + ["/\\*[\\s\\S]*?\\*/" (:comment colors)] + ["r#\"[^\"]*\"#" (:string colors)] + ["r\"[^\"]*\"" (:string colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] ;; lifetimes + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] ;; char literals + ["[a-zA-Z_][a-zA-Z0-9_]*!" (:builtin colors)] ;; macros + ["0[xX][0-9a-fA-F_]+" (:number colors)] + ["0[bB][01_]+" (:number colors)] + ["0[oO][0-7_]+" (:number colors)] + ["\\d[\\d_]*\\.\\d[\\d_]*(?:[eE][+-]?\\d+)?(?:f32|f64)?" (:number colors)] + ["\\d[\\d_]*(?:u8|u16|u32|u64|u128|usize|i8|i16|i32|i64|i128|isize|f32|f64)?" (:number colors)] + ["[a-zA-Z_][a-zA-Z0-9_]*" rust-classify]])) + +;; ============================================================ +;; Language: Bash +;; ============================================================ + +(def ^:private bash-keywords + #{"if" "then" "else" "elif" "fi" "for" "while" "until" "do" "done" + "case" "esac" "in" "function" "select" "time" "coproc" + "return" "exit" "break" "continue" "shift" "trap" + "local" "export" "declare" "typeset" "readonly" "unset"}) + +(def ^:private bash-builtins + #{"echo" "printf" "read" "cd" "pwd" "ls" "cp" "mv" "rm" "mkdir" "rmdir" + "cat" "grep" "sed" "awk" "find" "sort" "uniq" "wc" "head" "tail" + "chmod" "chown" "curl" "wget" "tar" "gzip" "gunzip" "zip" "unzip" + "git" "docker" "make" "ssh" "scp" "rsync" + "test" "true" "false" "source" "eval" "exec" "set" "env"}) + +(defn- bash-classify [text] + (cond + (contains? bash-keywords text) (:keyword colors) + (contains? bash-builtins text) (:builtin colors) + :else nil)) + +(def ^:private bash-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["#.*" (:comment colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'[^']*'" (:string colors)] + ["\\$\\{[^}]+\\}" (:param colors)] + ["\\$[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] + ["\\$[0-9@#?!$*-]" (:param colors)] + ["\\d+" (:number colors)] + ["[a-zA-Z_][a-zA-Z0-9_]*" bash-classify]])) + +;; ============================================================ +;; Language: JSON +;; ============================================================ + +(def ^:private json-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["\"(?:[^\"\\\\]|\\\\.)*\"\\s*:" (:clj-kw colors)] ;; keys + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["-?\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)] + ["-?\\d+" (:number colors)] + ["\\b(?:true|false)\\b" (:constant colors)] + ["\\bnull\\b" (:constant colors)]])) + +;; ============================================================ +;; Language: Generic (Go, C, C++, Ruby, CSS, etc.) +;; ============================================================ + +(def ^:private generic-keywords + #{"if" "else" "for" "while" "do" "switch" "case" "default" "break" + "continue" "return" "goto" "try" "catch" "throw" "finally" + "class" "struct" "enum" "interface" "extends" "implements" + "public" "private" "protected" "static" "const" "final" "abstract" + "virtual" "override" "new" "delete" "this" "self" "super" + "import" "export" "package" "module" "use" "require" "include" + "void" "int" "long" "float" "double" "char" "bool" "string" + "var" "let" "val" "def" "fn" "func" "fun" "function" + "type" "typedef" "namespace" "template" "typename" + "async" "await" "yield" "defer" "select" "chan" "go" + "begin" "end" "then" "elsif" "unless" "rescue" "ensure" "raise"}) + +(def ^:private generic-constants + #{"true" "false" "nil" "null" "none" "None" "True" "False" "NULL" + "undefined" "NaN" "Infinity"}) + +(defn- generic-classify [text] + (cond + (contains? generic-constants text) (:constant colors) + (contains? generic-keywords text) (:keyword colors) + (and (>= (count text) 2) (Character/isUpperCase (.charAt ^String text 0))) (:type colors) + :else nil)) + +(def ^:private generic-rules + (mapv (fn [[re c]] [(Pattern/compile re) c]) + [["//.*" (:comment colors)] + ["#.*" (:comment colors)] + ["/\\*[\\s\\S]*?\\*/" (:comment colors)] + ["\"(?:[^\"\\\\]|\\\\.)*\"" (:string colors)] + ["'(?:[^'\\\\]|\\\\.)*'" (:string colors)] + ["`(?:[^`\\\\]|\\\\.)*`" (:string colors)] + ["@[a-zA-Z_][a-zA-Z0-9_]*" (:param colors)] + ["0[xX][0-9a-fA-F]+" (:number colors)] + ["\\d+\\.\\d+(?:[eE][+-]?\\d+)?" (:number colors)] + ["\\d+" (:number colors)] + ["[a-zA-Z_][a-zA-Z0-9_]*" generic-classify]])) + +;; ============================================================ +;; Language Registry +;; ============================================================ + +(def ^:private lang-rules + {:clojure clj-rules + :javascript js-rules + :python py-rules + :java java-rules + :kotlin kt-rules + :rust rust-rules + :bash bash-rules + :json json-rules + :generic generic-rules}) + +(def ^:private fence-tag->lang + {"clojure" :clojure "clj" :clojure "cljs" :clojure "edn" :clojure + "javascript" :javascript "js" :javascript "typescript" :javascript "ts" :javascript + "jsx" :javascript "tsx" :javascript + "python" :python "py" :python + "java" :java + "kotlin" :kotlin "kt" :kotlin + "rust" :rust "rs" :rust + "bash" :bash "sh" :bash "shell" :bash "zsh" :bash + "json" :json "jsonc" :json + "go" :generic "c" :generic "cpp" :generic "c++" :generic + "ruby" :generic "rb" :generic + "css" :generic "scss" :generic "less" :generic + "html" :generic "xml" :generic "svg" :generic + "yaml" :generic "yml" :generic "toml" :generic + "sql" :generic "graphql" :generic "gql" :generic + "lua" :generic "perl" :generic "r" :generic + "swift" :generic "scala" :generic "groovy" :generic + "haskell" :generic "hs" :generic "elixir" :generic "ex" :generic + "erlang" :generic "erl" :generic + "zig" :generic "nim" :generic "ocaml" :generic "ml" :generic + "dart" :generic "php" :generic + "dockerfile" :generic "makefile" :generic + "diff" :generic "patch" :generic}) + +(def ^:private ext->lang + {".clj" :clojure ".cljs" :clojure ".cljc" :clojure ".edn" :clojure ".bb" :clojure + ".js" :javascript ".jsx" :javascript ".ts" :javascript ".tsx" :javascript ".mjs" :javascript + ".py" :python ".pyw" :python + ".java" :java + ".kt" :kotlin ".kts" :kotlin + ".rs" :rust + ".sh" :bash ".bash" :bash ".zsh" :bash + ".json" :json ".jsonc" :json + ".go" :generic ".c" :generic ".h" :generic ".cpp" :generic ".hpp" :generic ".cc" :generic + ".rb" :generic ".css" :generic ".scss" :generic ".less" :generic + ".html" :generic ".xml" :generic ".svg" :generic + ".yaml" :generic ".yml" :generic ".toml" :generic + ".sql" :generic ".lua" :generic ".pl" :generic ".r" :generic + ".swift" :generic ".scala" :generic ".groovy" :generic + ".hs" :generic ".ex" :generic ".exs" :generic ".erl" :generic + ".zig" :generic ".nim" :generic ".ml" :generic + ".dart" :generic ".php" :generic}) + +;; ============================================================ +;; Public API +;; ============================================================ + +(defn lang-for-fence + "Map a code fence tag (e.g. \"clojure\", \"js\") to a language keyword." + [tag] + (when tag + (get fence-tag->lang (str/lower-case (str/trim tag))))) + +(defn lang-for-ext + "Map a file extension (e.g. \".clj\", \".rs\") to a language keyword." + [ext] + (when ext + (get ext->lang (str/lower-case ext)))) + +(defn highlight-line + "Syntax-highlight a single line of code. Returns string with ANSI fg codes. + `lang` — keyword like :clojure, :javascript, etc. (nil = no highlighting) + `default-fg` — ANSI code for unhighlighted text (\"\" for terminal default, + or e.g. \"\\033[38;5;210m\" for diff removed lines). + Caller should append \\033[0m after the returned string." + [line lang default-fg] + (if-let [rules (get lang-rules lang)] + (str default-fg (highlight-line* line rules (or default-fg ""))) + line))