make it fancy
This commit is contained in:
@@ -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.
|
||||||
@@ -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/`.
|
||||||
@@ -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
@@ -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
@@ -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))))
|
||||||
|
|||||||
@@ -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")))})
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user