Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,18 @@ work that must paint first uses `:pending-task` — a fn or vector of fns,
place of reading a key*. I/O that can run off-thread uses `:async-jobs`:
`bufs/spawn-task` starts a `(future …)` immediately with an `io` thunk
(no editor state) and a `:then` `(fn [state outcome] -> state)` applied
later by `bufs/drain-jobs` on the main thread. Goroutines never touch
buffers. Outcome is `{:ok v}` / `{:err e}` because let-go's future
delivers nil on throw. While jobs are in flight the loop peeks
`term/key-pending?` and sleeps 20ms instead of parking in `read-chord`,
so typing continues. Idle is still a blocking read. C-g
(`bufs/discard-jobs`) drops results without killing HTTP/`os/sh`. Named
functions, not inline `fn` literals, when building the closure inside a
conditional — see the AOT-hoisting gotcha below.
later by `bufs/drain-jobs` on the main thread *before* the next frame is
painted — drain-then-paint, not the other way around, or a finished job
sits on screen until the next keystroke. `bufs/loop-wait` is the idle
decision (`:quit` / `:pending-task` / `:poll` / `:read-key`) so that
order is testable without a TTY. Goroutines never touch buffers. Outcome
is `{:ok v}` / `{:err e}` because let-go's future delivers nil on throw.
While jobs are in flight the loop peeks `term/key-pending?` and sleeps
20ms instead of parking in `read-chord`, so typing continues. Idle is
still a blocking read. C-g (`bufs/discard-jobs`) drops results without
killing HTTP/`os/sh`. Named functions, not inline `fn` literals, when
building the closure inside a conditional — see the AOT-hoisting gotcha
below.

**Windows (splits) are the same wrapping trick one level up.** The
workspace holds a window tree (`legmacs.windows` — pure data + layout
Expand Down Expand Up @@ -250,13 +254,21 @@ round-trip. `vibe-replace` does no I/O: it snapshots the form and
`(bufs/spawn-task … "vibing...")`, so it's as pure and testable as any
other command. The future `eval`s the form (in `legmacs.main`, same as
`C-x C-e`) with the call site bound in `vibe/*vibe-context*` (a dynamic
var, so two overlapping vibes don't clobber each other), then
`resolve-vibe` splices the returned string over the form as one undo
step. Two guards there are load-bearing: `resolve-vibe` re-checks that a
`(vibe` form still opens at `start` before touching the buffer, and it
refuses to splice a non-string result (let-go's `eval` *returns* compile
errors as an `#error` value instead of raising them, so a typo'd prompt
would otherwise be pasted into your code).
var, so two overlapping vibes don't clobber each other). The user
message includes a live catalog of canonical let-go namespaces
(`string/` not `clojure.string/`) and the file's `:require` aliases; the
model can also call `list-namespaces` / `ns-publics` / `var-doc` against
the running VM (OpenAI goes through `/v1/responses` so tools can sit
next to `:reasoning-effort`, default `:medium`; a `chat/completions` URL
selects the older shape). Then `resolve-vibe` rewrites any JVM-Clojure prefixes,
splices the string over the form, and inserts missing `(:require ...)`
entries into the ns form — one undo step. Two guards there are
load-bearing: `resolve-vibe` re-checks that a `(vibe` form still opens
at `start` before touching the buffer, and it refuses to splice a
non-string result (let-go's `eval` *returns* compile errors as an
`#error` value instead of raising them, so a typo'd prompt would
otherwise be pasted into your code). The system prompt also requires a
docstring on every `defn` / `defn-` / `defcommand`.

Full per-file breakdown and the complete default keymap are in
[README.md](README.md); read it before making structural changes.
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ started:
- a dedicated `*repl*` buffer (`C-c C-z`), for when you want an actual
running transcript instead of building one by hand with `C-j`
- `(vibe "...")` (`C-c C-v`): write what you want where you want it, and an
LLM writes the code over the call -- with the rest of your buffer as
context (see [Vibe coding](#vibe-coding))
LLM writes the code over the call -- with the rest of your buffer, a
live let-go ns catalog, and `ns-publics`/`var-doc` tools as context
(see [Vibe coding](#vibe-coding))
- keyboard macros, a mark ring, interactive query-replace, dabbrev-expand,
comment-dwim, and bracketed paste
- structural expand-region for let-go, growing the selection one
Expand Down Expand Up @@ -104,14 +105,19 @@ Type a call where you want the code, put point in it, press `C-c C-v`:
```clojure
(defn triple [x] (* 3 x))

(defn double [x]
(defn double
"Double x."
[x]
(* 2 x))
```

One undo puts the call back. The model gets your whole buffer with the call
site swapped for a marker, so it writes code that fits *that spot* --
surrounding defns, aliases, and indentation included, not a generic answer
to the prompt in isolation.
site swapped for a marker, a live catalog of let-go namespaces, and tools
to inspect them (`ns-publics`, `var-doc`), so it writes code that fits
*that spot* — surrounding defns, aliases, indentation, and the real
`string/`/`json/`/`os/` names, not JVM Clojure. Missing `(:require ...)`
entries are added to the file's ns form in the same undo step. Every
`defn` it writes should have a docstring.

`vibe` is also just a function: `C-x C-e` on `(vibe "a quicksort")` echoes
the generated code instead of splicing it, and it composes like anything
Expand All @@ -128,13 +134,19 @@ another:

```clojure
{:key "sk-..."
:model "gpt-5.6-sol"}
:model "gpt-5.6-sol"
:reasoning-effort :high}
```

The file is merged into vibe's config wholesale, so the other knobs work
from there too -- `:provider` (`:openai` or `:anthropic`, both ship in
`legmacs.vibe/providers`), `:url` for anything speaking the OpenAI
chat-completions shape (llama.cpp, ollama, openrouter, groq), `:max-tokens`,
`legmacs.vibe/providers`), `:reasoning-effort` (`:none` / `:low` /
`:medium` / `:high`, default `:medium`), `:url` for anything speaking
OpenAI's HTTP (llama.cpp, ollama, openrouter, groq -- a `chat/completions`
URL selects the older API shape automatically), `:api` (`:responses` is
the default and the one gpt-5.6 needs for tools + reasoning;
`:chat-completions` if you must), `:max-tokens`,
`:tools` (`false` if the endpoint cannot do tool calls),
and `:system` if you want to rewrite the instructions the model gets:

```clojure
Expand Down
29 changes: 21 additions & 8 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,19 @@ so one `C-_` puts your prompt back.

What makes the answers usable is the context. `vibe-replace` sends the
whole buffer with the call site swapped for a `<<<VIBE:HERE>>>` marker,
plus the column that marker starts at, so the model writes code shaped to
fit that exact spot rather than a generic answer to the prompt. `vibe`
itself can't see any of that (it's just a function), so `vibe-replace`
parks the call site in an atom for the duration of the eval -- which is
also why a bare `(vibe "...")` from the REPL still works, just contextless.
plus the column that marker starts at, plus a live catalog of let-go's
canonical namespaces (`string/`, `json/`, `os/`, never `clojure.string/`)
and the file's existing `:require` aliases. The model can also call
`list-namespaces`, `ns-publics`, and `var-doc` against the running VM
before it writes code. The splice is one undo step: the generated
forms replace the call, and any namespace those forms use that the file
does not already require is inserted into the `ns` form. `vibe` itself
can't see the buffer (it's just a function), so `vibe-replace` parks the
call site in `*vibe-context*` for the duration of the eval -- which is
also why a bare `(vibe "...")` from the REPL still works, just without a
file to splice requires into. JVM-Clojure prefixes in the reply
(`clojure.string/join`) are rewritten to the canonical names before
anything is inserted.

Setup is `~/.config/legmacs/vibe.edn` (or `$LEGMACS_CONFIG_DIR/vibe.edn` --
the same variable `bin/legmacs` already honours for `legmacs_init.lg`):
Expand All @@ -354,9 +362,13 @@ rather than being read key by key, so everything else works from there too:
| `:key` | API key. Falls back to the provider's env var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) if absent. |
| `:model` | Model id. Defaults to the provider's (`gpt-5.6-luna` / `claude-sonnet-5`). |
| `:provider` | `:openai` (default) or `:anthropic`. |
| `:url` | Endpoint. Point it at llama.cpp/ollama/openrouter/groq and keep `:provider :openai`, since they speak the same shape. |
| `:url` | Endpoint. Default is OpenAI `/v1/responses`. Point it at llama.cpp/ollama/openrouter/groq (`.../v1/chat/completions`) and the older chat-completions shape is inferred. Or set `:api` explicitly. |
| `:api` | `:responses` (default) or `:chat-completions`. Responses is the current OpenAI API and the one that can combine function tools with reasoning on gpt-5.6-*. |
| `:max-tokens` | Default 8192. Reasoning models spend this budget on reasoning *and* output, so a small value shows up as an empty reply, not a truncated one. |
| `:system` | The instructions the model gets. Replace to change house style. |
| `:tools` | `true` (default): the model may call `list-namespaces` / `ns-publics` / `var-doc`. Set `false` for local endpoints that do not speak tool calls. A 400 on a tools turn retries once without them. |
| `:max-tool-rounds` | Cap on tool-call turns before the model has to answer. Default 4. |
| `:reasoning-effort` | How hard the model thinks: `none`, `low`, `medium` (default), `high`, and whatever else the model accepts (`xhigh`, `max`, ...). Keywords or strings. Sent as Responses `reasoning.effort`. On chat/completions, tools force `none` because that endpoint rejects the combination. |

Config is four layers, each beating the one before it: `legmacs.vibe/defaults`,
then vibe.edn, then the `legmacs.vibe/config` atom (for `legmacs-init.lg`:
Expand All @@ -370,7 +382,8 @@ in ..." message instead.

A provider in `legmacs.vibe/providers` is four keys and two functions
(`:body` builds the request map, `:extract` pulls the text out of the
parsed response), so adding one is a `swap!`.
parsed response — or `{:text ... :calls [...]}` when the model asked
for tools), so adding one is a `swap!`.

Vibe has to wait on a network round-trip, and it stays out of the
pure pipeline the same way Acme Execute does: `vibe-replace` performs no
Expand Down Expand Up @@ -771,7 +784,7 @@ Set `LEGMACS_CONFIG_DIR` to use a different directory than
| [`legmacs/lisp_syntax.lg`](../legmacs/lisp_syntax.lg) | Pure bracket/string/comment scanner. What paren matching, syntax highlighting, auto-indent, and expand-region are all built on. |
| [`legmacs/modes/letgo.lg`](../legmacs/modes/letgo.lg) | let-go-mode: in-process eval, syntax highlighting, paren matching, auto-indent, expand-region, auto-paired brackets. Registered for `*scratch*`, `.lg` files, and (syntax-only) `.clj`/`.cljc`/`.cljs`/`.bb`/`.edn` files. |
| [`legmacs/modes/repl.lg`](../legmacs/modes/repl.lg) | The `*repl*` buffer (`C-c C-z`): plain let-go-mode plus a `:repl` minor mode that reinterprets `RET` as evaluate-if-complete, else newline-and-indent. |
| [`legmacs/vibe.lg`](../legmacs/vibe.lg) | `(vibe "...")` and `C-c C-v`: ask an LLM for let-go code, with the surrounding buffer as context, and splice the answer over the call. `spawn-task` runs the HTTP call in a future so the editor stays interactive. |
| [`legmacs/vibe.lg`](../legmacs/vibe.lg) | `(vibe "...")` and `C-c C-v`: ask an LLM for let-go code, with the surrounding buffer, a live ns catalog, and `ns-publics`/`var-doc` tools as context, splice the answer over the call, and add missing `(:require ...)`s. `spawn-task` runs the HTTP call in a future so the editor stays interactive. |
| [`legmacs/modes/markdown.lg`](../legmacs/modes/markdown.lg) | markdown-mode: syntax highlighting only (headers, emphasis, code, links, quotes, lists, rules, and fenced code blocks carried across lines). Registered for `.md`/`.markdown` files. |
| [`legmacs/modes/prog.lg`](../legmacs/modes/prog.lg) | The language pack: one spec-driven line scanner (comments, strings, keyword/type/constant sets, `#directives`, `$variables`, call sites, and multi-line strings carried across lines) behind major modes for Go, JS/TS, Python, C/C++, shell, rc, Rust, JSON, YAML, TOML, Lua, Ruby, SQL, Dockerfile, CSS, HTML, Zig, Java, Kotlin, Swift, C#, PHP, and Makefile. `register-prog-mode!` is the one-call way to add a language; the same spec map also becomes the mode's `:syntax-spec`. |
| [`legmacs/prog_syntax.lg`](../legmacs/prog_syntax.lg) | A full-buffer bracket/string/comment scanner like `legmacs.lisp-syntax`, but spec-driven instead of Lisp-specific -- what the generic structural modes below are built on. |
Expand Down
3 changes: 2 additions & 1 deletion examples/legmacs_init.example.lg
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@
;; win over vibe.edn.
;;
;; (require '[legmacs.vibe :as vibe])
;; (swap! vibe/config assoc :model "gpt-5.6-sol")
;; (swap! vibe/config assoc :model "gpt-5.6-sol" :reasoning-effort :high)
;; (swap! vibe/config assoc :tools false) ; llama.cpp / local OpenAI-compat
;; (swap! vibe/config update :system str "\nPrefer threading macros.\n")
Loading
Loading