make it fancy

This commit is contained in:
2026-02-23 11:00:20 -05:00
parent 3fc0914946
commit 813972f8da
10 changed files with 1543 additions and 123 deletions
+47
View File
@@ -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 <uuid> # 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 <arg>` 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.
+152
View File
@@ -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 <id> # 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 <env>
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 <id> # Resume a specific session
```
Sessions are stored in `~/.local/share/agent0/sessions/`. Logs are in `~/.local/share/agent0/logs/`.
+2 -1
View File
@@ -1,2 +1,3 @@
{:paths ["src"] {: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"}}}
+151
View File
@@ -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 <arg>` 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 <arg>` format
+144 -90
View File
@@ -1,6 +1,8 @@
(ns agent.app (ns agent.app
(:require [agent.core :as core] (:require [agent.context :as context]
[agent.core :as core]
[agent.markdown :as md] [agent.markdown :as md]
[agent.syntax :as syntax]
[tui.core :as tui] [tui.core :as tui]
[tui.events :as ev] [tui.events :as ev]
[tui.terminal :as term] [tui.terminal :as term]
@@ -57,68 +59,43 @@
(def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"]) (def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"])
(defn- strip-ansi (def ^:private hat-lines
"Strip ANSI escape codes to get visible length." (let [p "\033[38;5;135m" r "\033[0m"]
[s] [(str p " ✦ " r)
(str/replace s #"\033\[[0-9;]*m" "")) (str p " ▄█▄ " r)
(str p " ▄█████▄ " r)
(str p " ▀▀▀▀▀▀▀▀▀ " r)]))
(defn- header-lines (def ^:private logo-lines
"Generate inline header box with ASCII Clojure logo and welcome message." [" \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 "
[width] " \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 "
(let [p "\033[38;5;135m" " \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 "
w "\033[1;97m" "\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"
d "\033[2m" "\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"
r "\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"
box-w (min 48 (max 24 (- width 4))) "\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"
iw (- box-w 2) " \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 (defn- header-view
lines [(str p " ✦ " r) "Two-column header: logo on left, welcome text on right."
(str p " ▄█▄ " r) []
(str p " ▄█████▄ " r) (let [left-col (into [:col] (map (fn [l] [:text l]) (concat hat-lines logo-lines)))
(str p " ▀▀▀▀▀▀▀▀▀ " r) right-col [:col
;; Clojure logo - generated from SVG via chafa (20x10 braille, 256-color) [:text ""]
" \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 " [:text {:fg :white :bold true} "agent0"]
" \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 " [:text ""]
" \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 " [:text "Agentic TUI for"]
"\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" [:text "Clojure wizards"]
"\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" [:text ""]
"\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" [:text "Type a command and"]
"\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" [:text "press Enter"]
" \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 " [:text ""]
" \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 " [:text {:dim true} "Esc · Ctrl+C to quit"]]]
" \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 " [:row {:widths [22 :flex]}
"" left-col
(str w " agent0" r) right-col]))
""
" 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- format-messages (defn- format-messages
"Convert display messages to a flat vector of hiccup line elements." "Convert display messages to a flat vector of hiccup line elements."
@@ -148,6 +125,58 @@
:tool :tool
[[:text {:dim true} (str " ⚙ " (:content msg))]] [[: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 :error
[[:text {:fg :red} (str " ✗ " (:content msg))]] [[:text {:fg :red} (str " ✗ " (:content msg))]]
@@ -168,12 +197,8 @@
chat-height (max 1 (- height input-box-height)) chat-height (max 1 (- height input-box-height))
content-width (max 10 (- width 2)) content-width (max 10 (- width 2))
;; Format messages with inline header ;; Format chat messages as flat text lines
all-lines all-lines (format-messages messages content-width)
(let [header (header-lines width)]
(if (empty? messages)
header
(into header (format-messages messages content-width))))
;; Add thinking indicator ;; Add thinking indicator
all-lines all-lines
@@ -203,14 +228,22 @@
max-input-width (max 1 (- width 6)) max-input-width (max 1 (- width 6))
display-input (if (> (count input) max-input-width) display-input (if (> (count input) max-input-width)
(subs input (- (count input) max-input-width)) (subs input (- (count input) max-input-width))
input)] input)
[:col {:heights [chat-height input-box-height]} input-box [:box {:border :rounded :width :fill}
(into [:col] display-lines) (if (pos? clamped-offset)
[:box {:border :rounded :width :fill} [:text {:fg :cyan} (str "↑" clamped-offset " " display-input "█")]
(if (pos? clamped-offset) [:text {:fg :green} (str "> " display-input "█")])]]
[: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 ;; Update
@@ -229,6 +262,7 @@
(case (:type event) (case (:type event)
:text (update model :messages conj {:role :assistant :content (:content event)}) :text (update model :messages conj {:role :assistant :content (:content event)})
:tool (update model :messages conj {:role :tool :content (:label 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)}) :error (update model :messages conj {:role :error :content (:message event)})
:done (let [m (assoc model :done (let [m (assoc model
:agent-running? false :agent-running? false
@@ -258,20 +292,33 @@
(and (ev/key= event :enter) (and (ev/key= event :enter)
(seq (:input model)) (seq (:input model))
(not (:agent-running? model))) (not (:agent-running? model)))
(let [text (:input model) (let [text (str/trim (:input model))]
new-messages (conj (:messages model) {:role :user :content text}) (cond
new-conversation (conj (:conversation model) {:role "user" :content text}) ;; Built-in: /skills — list loaded skills
agent-handle (core/run-agent-loop! new-conversation (:event-queue model))] (= text "/skills")
{:model (assoc model (let [listing (context/format-skill-list (:skills model))]
:messages new-messages {:model (-> model
:input "" (update :messages conj {:role :user :content text})
:conversation new-conversation (update :messages conj {:role :assistant :content listing})
:agent-running? true (assoc :input "" :scroll-offset 0))})
:agent-handle agent-handle
:spinner-frame 0 ;; Skill expansion or normal message → send to LLM
:scroll-offset 0) :else
:events [(ev/delayed-event 100 {:type :poll}) (let [expanded (context/expand-skill text (:skills model))
(ev/delayed-event 80 {:type :spinner})]}) 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 ;; Backspace
(ev/key= event :backspace) (ev/key= event :backspace)
@@ -345,6 +392,11 @@
(defn -main [& args] (defn -main [& args]
(let [{:keys [continue? session-id prompt]} (parse-args 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 ;; Resolve session to resume
resume-id (cond resume-id (cond
session-id session-id session-id session-id
@@ -364,13 +416,15 @@
(conj base-messages {:role :user :content prompt}) (conj base-messages {:role :user :content prompt})
true] true]
[base-conversation base-messages false]) [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 initial-model {:messages messages
:input "" :input ""
:conversation conversation :conversation conversation
:event-queue eq :event-queue eq
:session-id sid :session-id sid
:created created :created created
:system-prompt system-prompt
:skills skills
:agent-running? start? :agent-running? start?
:agent-handle agent-handle :agent-handle agent-handle
:spinner-frame 0 :spinner-frame 0
@@ -386,4 +440,4 @@
:init-events initial-events}) :init-events initial-events})
;; Post-exit: print session info ;; Post-exit: print session info
(println (str "\nSession: " sid)) (println (str "\nSession: " sid))
(println (str "To continue: ./agent --session " sid)))) (println (str "To continue: agent --session " sid))))
+164
View File
@@ -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")))})
+183
View File
@@ -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 <arg1> <arg2>` 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)))))
+147 -23
View File
@@ -1,5 +1,7 @@
(ns agent.core (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.fs :as fs]
[babashka.http-client :as http] [babashka.http-client :as http]
[cheshire.core :as json] [cheshire.core :as json]
@@ -16,9 +18,9 @@
(def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434")) (def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434"))
(def model (or (System/getenv "AGENT_MODEL") "qwen3-coder-next")) (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. "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: 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 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 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: For web information:
- Use delegate with the web_search agent. It can search the web AND fetch full page content for you. - 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. - 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. - 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: '<skill-name>') to execute it.
Always explain what you're doing before using tools. Use the tools when needed to complete the task.") 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 ;; Logging
;; ============================================================ ;; ============================================================
@@ -91,10 +113,25 @@ Always explain what you're doing before using tools. Use the tools when needed t
(when (seq files) (when (seq files)
(str/replace (.getName (first files)) ".edn" "")))))) (str/replace (.getName (first files)) ".edn" ""))))))
;; ============================================================
;; Skills Atom (populated by app.clj at startup)
;; ============================================================
(def skills-atom (atom {}))
;; ============================================================ ;; ============================================================
;; Tools Implementation ;; 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]}] (defn read-file [{:keys [path offset limit]}]
(let [file (io/file path)] (let [file (io/file path)]
(cond (cond
@@ -107,6 +144,8 @@ Always explain what you're doing before using tools. Use the tools when needed t
:else :else
(let [lines (str/split-lines (slurp file)) (let [lines (str/split-lines (slurp file))
total (count lines) total (count lines)
offset (->long offset)
limit (->long limit)
start (max 0 (dec (or offset 1))) start (max 0 (dec (or offset 1)))
selected (cond->> (drop start lines) selected (cond->> (drop start lines)
limit (take limit)) 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/join "\n"))
(str "Error: Not a directory: " path)))) (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]}] (defn edit-file [{:keys [path old_str new_str]}]
(let [file (io/file path)] (let [file (io/file path)]
(if (.exists file) (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.") (str "Error: old_str appears " occurrences " times. Must be unique.")
:else :else
(do (spit file (str/replace-first content old_str new_str)) (let [diff (generate-edit-diff path content old_str new_str)]
(str "Successfully edited " path)))) (spit file (str/replace-first content old_str new_str))
{:message (str "Successfully edited " path)
:diff diff})))
(str "Error: File not found: " path)))) (str "Error: File not found: " path))))
(defn create-file [{:keys [path content]}] (defn create-file [{:keys [path content]}]
(let [file (io/file path)] (let [file (io/file path)]
(if (.exists file) (if (.exists file)
(str "Error: File already exists: " path) (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) (io/make-parents file)
(spit file content) (spit file content)
(str "Successfully created " path))))) {:message (str "Successfully created " path)
:diff diff}))))
(defn run-shell-command [{:keys [command]}] (defn run-shell-command [{:keys [command]}]
(let [proc (-> (ProcessBuilder. ["bash" "-c" 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) ;; 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] (defn- call-llm* [sys-prompt tool-defs messages]
(let [body {:model model (let [body {:model model
:options {:num_predict max-tokens} :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} :stream false}
response (http/post (str ollama-host "/api/chat") response (http/post (str ollama-host "/api/chat")
{:headers {"Content-Type" "application/json"} {: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))) (json/parse-string (:body response) true)))
(defn- truncate [s max-len] (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 "Error: Unknown agent '" agent "'. Available agents: "
(str/join ", " (keys subagent-registry)))))) (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) ;; 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\")"} :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"}} :task {:type "string" :description "A clear description of what you want the subagent to research or do"}}
:required ["agent" "task"]}} :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 (def tool-map
(into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools))) (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 ;; Main Agent LLM
;; ============================================================ ;; ============================================================
(defn call-llm [messages] (defn call-llm [system-prompt messages]
(let [result (call-llm* system-prompt tool-definitions messages)] (let [result (call-llm* system-prompt tool-definitions messages)]
{:choices [{:message (:message result) {:choices [{:message (:message result)
:finish_reason (if (seq (get-in result [:message :tool_calls])) :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)) (tool-fn args))
(str "Error: Unknown tool '" tool-name "'")) (str "Error: Unknown tool '" tool-name "'"))
(catch Exception e (catch Exception e
(str "Error: " (.getMessage e))))] (str "Error: " (.getMessage e))))
(log log-file "<- Result:" (truncate (str/replace (str result) #"\n" "\\n") 200)) [content diff] (if (map? result)
{:role "tool" :tool_call_id id :content 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 (defn tool-call-label
"Generate a human-readable label for a tool call." "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]) raw-args (get-in tc [:function :arguments])
args (if (string? raw-args) (json/parse-string raw-args true) raw-args)] args (if (string? raw-args) (json/parse-string raw-args true) raw-args)]
(case tname (case tname
"read_file" (str "Reading " (:path args) "read_file" (let [offset (->long (:offset args))
(if (or (:offset args) (:limit args)) limit (->long (:limit args))]
(str ":" (or (:offset args) 1) (str "Reading " (:path args)
(when (:limit args) (str "-" (+ (dec (or (:offset args) 1)) (:limit args))))) (if (or offset limit)
" (entire file)")) (str ":" (or offset 1)
(when limit (str "-" (+ (dec (or offset 1)) limit))))
" (entire file)")))
"list_files" (str "Listing " (or (:path args) ".")) "list_files" (str "Listing " (or (:path args) "."))
"edit_file" (str "Editing " (:path args)) "edit_file" (str "Editing " (:path args))
"create_file" (str "Creating " (: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)))) "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)))) "grep_files" (str "Grepping " (:pattern args) (when (:include args) (str " in " (:include args))))
"delegate" (str "Spawning " (:agent args) " subagent") "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)))) (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 :error :message \"...\"} - error
{:type :done :conversation [...]} - loop finished {:type :done :conversation [...]} - loop finished
Returns {:future f :cancel! cancel-atom}." Returns {:future f :cancel! cancel-atom}."
[conversation event-queue] [system-prompt conversation event-queue]
(let [log-file (init-log) (let [log-file (init-log)
cancelled? (atom false)] cancelled? (atom false)]
(log log-file "Agent loop started | model:" model "| messages:" (count conversation)) (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 :else
(do (do
(log log-file "Iteration" iteration "| messages:" (count messages)) (log log-file "Iteration" iteration "| messages:" (count messages))
(let [response (call-llm messages) (let [response (call-llm system-prompt messages)
choice (first (:choices response)) choice (first (:choices response))
message (:message choice) message (:message choice)
finish-reason (:finish_reason 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] (doseq [tc tool-calls]
(swap! event-queue conj {:type :tool :label (tool-call-label tc)})) (swap! event-queue conj {:type :tool :label (tool-call-label tc)}))
;; Execute tools ;; 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]) 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 ;; If nudge, inject a system hint to stop researching
new-messages (if nudge? new-messages (if nudge?
(do (log log-file "Research loop nudge injected") (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" {: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."})) :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)] new-messages)]
(recur new-messages (inc iteration) signatures))))) (recur new-messages (inc iteration) signatures))))))
;; Done - no more tool calls ;; Done - no more tool calls
(do (do
(log log-file "Agent finished after" iteration "iterations") (log log-file "Agent finished after" iteration "iterations")
+12 -9
View File
@@ -1,6 +1,7 @@
(ns agent.markdown (ns agent.markdown
"Convert markdown text to ANSI-styled terminal output." "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])) [tui.ansi :as ansi]))
;; ============================================================ ;; ============================================================
@@ -126,21 +127,23 @@
(if (or (nil? text) (empty? text)) (if (or (nil? text) (empty? text))
[""] [""]
(loop [[line & remaining] (str/split-lines text) (loop [[line & remaining] (str/split-lines text)
in-code-block? false code-block nil
result []] result []]
(if-not line (if-not line
result result
(cond (cond
;; Code fence toggle ;; Code fence toggle
(str/starts-with? (str/trimr line) "```") (str/starts-with? (str/trimr line) "```")
(if in-code-block? (if code-block
(recur remaining false result) (recur remaining nil result)
(recur remaining true 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 ;; Inside code block — preserve formatting, syntax highlight
in-code-block? code-block
(recur remaining true (recur remaining code-block
(conj result (ansi/style (str " " line) :fg :bright-black))) (conj result (str " " (syntax/highlight-line line (:lang code-block) "") "\033[0m")))
;; Header: # text ;; Header: # text
(re-matches #"^(#{1,6})\s+(.*)" line) (re-matches #"^(#{1,6})\s+(.*)" line)
+541
View File
@@ -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))