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"]
: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
+123 -69
View File
@@ -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,28 +59,15 @@
(def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"])
(defn- strip-ansi
"Strip ANSI escape codes to get visible length."
[s]
(str/replace s #"\033\[[0-9;]*m" ""))
(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)
;; Hat lines padded to 20 chars to align with 20-char braille logo
lines [(str p " ✦ " r)
(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)
;; 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 "
(str p " ▀▀▀▀▀▀▀▀▀ " r)]))
(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"
@@ -87,38 +76,26 @@
"\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)]
" \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 "])
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}
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 "█")])]]))
[: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,10 +292,23 @@
(and (ev/key= event :enter)
(seq (:input model))
(not (:agent-running? model)))
(let [text (:input model)
(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 text})
agent-handle (core/run-agent-loop! new-conversation (:event-queue model))]
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 ""
@@ -271,7 +318,7 @@
:spinner-frame 0
:scroll-offset 0)
:events [(ev/delayed-event 100 {:type :poll})
(ev/delayed-event 80 {:type :spinner})]})
(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))))
+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
(: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: '<skill-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")
+12 -9
View File
@@ -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)
+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))