diff --git a/CLAUDE.md b/CLAUDE.md index 6a78d78..eda1c63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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. diff --git a/README.md b/README.md index c1cf989..2664f3d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 45c3a42..73ded2c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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 `<<>>` 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`): @@ -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`: @@ -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 @@ -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. | diff --git a/examples/legmacs_init.example.lg b/examples/legmacs_init.example.lg index 9e2087b..1155b3d 100644 --- a/examples/legmacs_init.example.lg +++ b/examples/legmacs_init.example.lg @@ -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") diff --git a/legmacs/vibe.lg b/legmacs/vibe.lg index 2d5c77f..081fe8b 100644 --- a/legmacs/vibe.lg +++ b/legmacs/vibe.lg @@ -10,12 +10,17 @@ ;; C-c C-v ; vibe-replace: find the (vibe ...) ;; ; form around point, run it, and ;; ; replace the form's text with the -;; ; string it returned. +;; ; string it returned. Missing +;; ; (:require ...) entries for the +;; ; namespaces the code uses are +;; ; added to the file's ns form in +;; ; the same undo step. ;; ;; The interesting part is the context: vibe-replace hands the model the whole -;; buffer with the call site swapped for a marker, so the answer is written to -;; fit *that spot* — surrounding defns, aliases, indentation and all — rather -;; than in a vacuum. +;; buffer with the call site swapped for a marker, plus a live catalog of +;; loaded let-go namespaces (and tools to inspect them), so the answer is +;; written to fit *that spot* — surrounding defns, aliases, indentation and +;; all — rather than in a vacuum, and rather than as JVM Clojure. ;; ;; Setup is one file, ~/.config/legmacs/vibe.edn: ;; @@ -45,9 +50,27 @@ "<<>>") (def default-system-prompt - (str "You write let-go code. let-go is a Clojure dialect running on a Go VM: " - "most of clojure.core is there, plus namespaces like string/, json/, " - "http/, os/ and term/.\n\n" + (str "You write let-go code. let-go is a Clojure dialect on a Go VM, not JVM Clojure.\n\n" + "Namespaces:\n" + "- Canonical names are short: string, json, http, os, io, term, async, set, " + "walk, edn, pprint, test, math, net. Qualify them as string/join, " + "json/read-json, os/getenv, http/get, io/slurp.\n" + "- Never write clojure.string, clojure.java.io, clojure.core.async, " + "clojure.set, clojure.walk, clojure.pprint, or clojure.core. " + "clojure.string/join does not invoke — the real fn is string/join. " + "clojure.core is already referred; write map not clojure.core/map.\n" + "- Prefer an existing :as alias (buf/ not legmacs.buffer/). If you need a " + "namespace the file does not require, use its canonical prefix and do not " + "edit the ns form; missing requires are added for you.\n" + "- json/read-json takes an optional {:keywords? true}. json/write-json " + "accepts keyword or string map keys.\n" + "- (throw \"msg\") is a plain string. Catch with (or (ex-message e) (str e)).\n" + "- Do not use finally for cleanup that must run on throw; reify the outcome " + "then clean up.\n" + "- No top-level forward references: a defn body may only refer to names " + "defined above it.\n" + "- count, subs, and string/index-of on a string are O(n) (rune-indexed). " + "Do not call them per character in a scan; convert to a char vector once.\n\n" "You get a source file with the marker " marker " at exactly one spot, " "and a request. Reply with the code that should replace that marker.\n\n" "Rules:\n" @@ -56,8 +79,35 @@ "- The code must read as balanced, complete form(s) in that position.\n" "- Match the file's existing style, naming and aliases.\n" "- Do not repeat any other part of the file.\n" - "- let-go has no forward references at the top level: a defn body may " - "only refer to names defined above it.\n")) + "- Every defn, defn-, and defcommand must have a docstring.\n" + "- Tools list-namespaces, ns-publics, and var-doc inspect the live runtime. " + "Use them when the namespace catalog is not enough or you need arglists.\n")) + +;; Canonical JVM-Clojure prefixes the model emits, mapped to let-go's real +;; names. Longest match first so clojure.core.async/ is not eaten by +;; clojure.core/. +(def clojure-ns-rewrites + [["clojure.core.async/" "async/"] + ["clojure.java.io/" "io/"] + ["clojure.string/" "string/"] + ["clojure.set/" "set/"] + ["clojure.walk/" "walk/"] + ["clojure.edn/" "edn/"] + ["clojure.zip/" "zip/"] + ["clojure.data/" "data/"] + ["clojure.pprint/" "pprint/"] + ["clojure.test/" "test/"] + ["clojure.core/" ""]]) + +(def stdlib-libs + "Short names we always offer in the catalog when the ns is loaded." + ["string" "json" "http" "os" "io" "term" "async" "set" "walk" "edn" + "pprint" "test" "math" "net" "hash" "graph" "transit" "bencode" + "storage" "unix" "syscall" "pods" "xxh3"]) + +(def skip-catalog-ns + #{"user" "core" "clojure.core" "gogen" "resolver" "rt" "ir" "ir.data" + "disasm" "profile" "js" "let-go.core" "let-go.types" "let-go.semver"}) ;; --- providers ----------------------------------------------------------- ;; @@ -69,37 +119,297 @@ ;; Request bodies are built with *keyword* keys on purpose: let-go's ;; json/write-json double-quotes string map keys ({"a" 1} serialises as ;; {"\"a\"": 1}), but keywordizes correctly. +;; +;; :parse turns a decoded response into {:content s :tool-calls [...]}. +;; :body accepts :messages (a growing chat) and optional :tools. + +(def vibe-tools + [{:name "list-namespaces" + :description (str "Names of loaded let-go namespaces you can require. " + "Returns canonical short names (string, json, os), never clojure.string.") + :parameters {:type "object" :properties {}}} + {:name "ns-publics" + :description "Public var names in a loaded namespace, e.g. os or string or legmacs.buffer." + :parameters {:type "object" + :properties {:ns {:type "string" + :description "Namespace name"}} + :required ["ns"]}} + {:name "var-doc" + :description (str "Docstring and arglists for a var. Pass a qualified name " + "like os/getenv or string/join.") + :parameters {:type "object" + :properties {:name {:type "string" + :description "Var name, preferably qualified"}} + :required ["name"]}}]) + +(defn- openai-chat-tools [tools] + (mapv (fn [t] + {:type "function" + :function {:name (:name t) + :description (:description t) + :parameters (or (:parameters t) + {:type "object" :properties {}})}}) + tools)) + +(defn- openai-responses-tools [tools] + ;; Flat tool items, not the chat/completions {type, function:{...}} wrapper. + ;; strict false: list-namespaces has an empty parameter object that + ;; wouldn't survive Responses' default "try to make this strict" pass. + (mapv (fn [t] + {:type "function" + :name (:name t) + :description (:description t) + :parameters (or (:parameters t) + {:type "object" :properties {}}) + :strict false}) + tools)) + +(defn- anthropic-tools [tools] + (mapv (fn [t] + {:name (:name t) + :description (:description t) + :input_schema (or (:parameters t) + {:type "object" :properties {}})}) + tools)) + +(defn- openai-tool-call [c] + {:id (:id c) + :type "function" + :function {:name (:name c) + :arguments (json/write-json (or (:args c) {}))}}) + +(defn- openai-message [m] + (let [role (:role m)] + (cond + (= role "tool") + {:role "tool" :tool_call_id (:tool-call-id m) :content (:content m)} + + (= role "assistant") + (let [base {:role "assistant" :content (or (:content m) "")}] + (if (seq (:tool-calls m)) + (assoc base :tool_calls (mapv openai-tool-call (:tool-calls m))) + base)) + + :else + {:role "user" :content (:content m)}))) + +(defn openai-api + "Which OpenAI HTTP shape to speak. :responses is the current API -- the + one that can combine function tools with reasoning_effort on gpt-5.6-*. + :chat-completions is the older one, still what llama.cpp/ollama/openrouter + speak. Inferred from :url when it contains chat/completions; otherwise + :api in vibe.edn wins, defaulting to :responses." + [cfg] + (let [given (:api cfg) + url (str (or (:url cfg) ""))] + (cond + given (keyword given) + (string/includes? url "chat/completions") :chat-completions + :else :responses))) + +(defn effort-str + "Config value -> the string the API wants. Keywords (:medium) and + strings (\"medium\") both work; nil means omit the field." + [v] + (when v + (if (keyword? v) (name v) (str v)))) + +(defn openai-chat-url [] "https://api.openai.com/v1/chat/completions") +(defn openai-responses-url [] "https://api.openai.com/v1/responses") + +(defn- keywordize-json [v] + (cond + (map? v) + (into {} (map (fn [[k val]] + [(if (string? k) (keyword k) k) + (keywordize-json val)]) + v)) + (vector? v) (mapv keywordize-json v) + :else v)) + +(defn- responses-input [messages] + (vec + (mapcat (fn [m] + (let [role (:role m)] + (cond + (= role "tool") + [{:type "function_call_output" + :call_id (:tool-call-id m) + :output (or (:content m) "")}] + (= role "assistant") + (if (seq (:output m)) + (mapv keywordize-json (:output m)) + (when (seq (:content m)) + [{:role "assistant" :content (:content m)}])) + :else + [{:role "user" :content (:content m)}]))) + messages))) + +(defn- openai-chat-body [{:keys [model system messages max-tokens tools reasoning-effort]}] + ;; gpt-5.6-* on this endpoint rejects tools unless effort is none. If the + ;; user asked for more, we still send none on a tools turn -- a 400 retry + ;; without tools is the other way out. + (let [effort (let [e (effort-str reasoning-effort)] + (cond (and (seq tools) (or (nil? e) (not= e "none"))) "none" + e e + :else nil)) + base {:model model + :max_completion_tokens max-tokens + :messages (into [{:role "system" :content system}] + (mapv openai-message messages))} + base (if (seq tools) + (assoc base :tools (openai-chat-tools tools)) + base)] + (if effort + (assoc base :reasoning_effort effort) + base))) + +(defn- openai-responses-body [{:keys [model system messages max-tokens tools reasoning-effort]}] + (let [effort (effort-str reasoning-effort) + base {:model model + :instructions system + :input (responses-input messages) + :max_output_tokens max-tokens + :store false} + base (if (seq tools) + (assoc base :tools (openai-responses-tools tools)) + base)] + (if effort + (assoc base :reasoning {:effort effort}) + base))) + +(defn openai-body + "Dispatch on :api. Public so tests can pin the wire shape." + [opts] + (if (= :chat-completions (:api opts)) + (openai-chat-body opts) + (openai-responses-body opts))) + +(defn- as-arg-map [args] + (try + (cond (map? args) args + (seq (str args)) (let [v (json/read-json args)] + (if (map? v) v {})) + :else {}) + (catch e {}))) + +(defn- openai-parse-call [c] + (let [f (get c "function")] + {:id (get c "id") + :name (get f "name") + :args (as-arg-map (get f "arguments"))})) + +(defn- openai-chat-parse [m] + (let [msg (get (first (get m "choices")) "message") + content (get msg "content") + raw (get msg "tool_calls")] + {:content (when (and (string? content) (seq content)) content) + :tool-calls (mapv openai-parse-call (or raw []))})) + +(defn- responses-text [output] + (string/join + "" + (mapcat (fn [item] + (when (= "message" (get item "type")) + (keep (fn [c] + (when (or (= "output_text" (get c "type")) + (= "text" (get c "type"))) + (get c "text"))) + (or (get item "content") [])))) + (or output [])))) -(defn- openai-body [{:keys [model system user max-tokens]}] - {:model model - :max_completion_tokens max-tokens - :messages [{:role "system" :content system} - {:role "user" :content user}]}) +(defn openai-responses-parse + "Pull :content, :tool-calls, and the raw :output items (reasoning + + function_call) so the next turn can send them back. Public for tests." + [m] + (let [output (or (get m "output") []) + content (or (let [t (get m "output_text")] + (when (and (string? t) (seq t)) t)) + (let [t (responses-text output)] + (when (seq t) t))) + calls (vec + (keep (fn [item] + (when (= "function_call" (get item "type")) + {:id (or (get item "call_id") (get item "id")) + :name (get item "name") + :args (as-arg-map (get item "arguments"))})) + output))] + {:content content + :tool-calls calls + :output output})) -(defn- openai-extract [m] - (let [choices (get m "choices")] - (when (seq choices) - (get (get (first choices) "message") "content")))) +(defn openai-parse [m] + (if (get m "choices") + (openai-chat-parse m) + (openai-responses-parse m))) -(defn- anthropic-body [{:keys [model system user max-tokens]}] - {:model model - :max_tokens max-tokens - :system system - :messages [{:role "user" :content user}]}) +(defn- anthropic-assistant-content [m] + (let [blocks (if (seq (:content m)) + [{:type "text" :text (:content m)}] + [])] + (into blocks + (mapv (fn [c] + {:type "tool_use" + :id (:id c) + :name (:name c) + :input (or (:args c) {})}) + (:tool-calls m))))) -(defn- anthropic-extract [m] - (let [blocks (get m "content")] - (when (seq blocks) - (get (first blocks) "text")))) +(defn- anthropic-wire-messages [messages] + (loop [ms messages, out [], pending []] + (if-not (seq ms) + (if (seq pending) + (conj out {:role "user" :content pending}) + out) + (let [m (first ms)] + (if (= "tool" (:role m)) + (recur (rest ms) out + (conj pending {:type "tool_result" + :tool_use_id (:tool-call-id m) + :content (:content m)})) + (let [out (if (seq pending) + (conj out {:role "user" :content pending}) + out) + wire (if (= "assistant" (:role m)) + {:role "assistant" :content (anthropic-assistant-content m)} + {:role "user" :content (:content m)})] + (recur (rest ms) (conj out wire) []))))))) + +(defn- anthropic-body [{:keys [model system messages max-tokens tools]}] + (let [base {:model model + :max_tokens max-tokens + :system system + :messages (anthropic-wire-messages messages)}] + (if (seq tools) + (assoc base :tools (anthropic-tools tools)) + base))) + +(defn- anthropic-parse [m] + (let [blocks (or (get m "content") []) + text (string/join + "" + (keep (fn [b] + (when (= "text" (get b "type")) + (get b "text"))) + blocks)) + calls (vec + (keep (fn [b] + (when (= "tool_use" (get b "type")) + {:id (get b "id") + :name (get b "name") + :args (or (get b "input") {})})) + blocks))] + {:content (when (seq text) text) + :tool-calls calls})) (def providers - {:openai {:url "https://api.openai.com/v1/chat/completions" + {:openai {:url "https://api.openai.com/v1/responses" :model "gpt-5.6-luna" :key-env "OPENAI_API_KEY" :headers (fn [key] {"Authorization" (str "Bearer " key) "Content-Type" "application/json"}) :body openai-body - :extract openai-extract} + :parse openai-parse} :anthropic {:url "https://api.anthropic.com/v1/messages" :model "claude-sonnet-5" :key-env "ANTHROPIC_API_KEY" @@ -107,7 +417,7 @@ "anthropic-version" "2023-06-01" "Content-Type" "application/json"}) :body anthropic-body - :extract anthropic-extract}}) + :parse anthropic-parse}}) (def defaults "The bottom layer. Anything absent here falls through to the provider's @@ -115,10 +425,22 @@ :max-tokens is generous because gpt-5.6-luna is a reasoning model and its reasoning tokens come out of the same budget as the code you asked - for -- a tight limit shows up as an empty reply, not a truncated one." + for -- a tight limit shows up as an empty reply, not a truncated one. + + :reasoning-effort is sent as Responses' reasoning.effort (none / low / + medium / high / ...). :medium is gpt-5.6's own default; set another in + vibe.edn. The older chat/completions endpoint cannot combine tools with + anything but none -- we still speak it when :url points there. + + :tools is on by default so the model can inspect live namespaces. + Pointing :url at a local endpoint that does not speak tool-calls? + Set :tools false. A 400 on a tools turn retries once without them." {:provider :openai :max-tokens 8192 - :system default-system-prompt}) + :reasoning-effort :medium + :system default-system-prompt + :tools true + :max-tool-rounds 4}) (def config "Programmatic overrides, on top of vibe.edn. Empty by default -- this is @@ -135,11 +457,14 @@ ;; variable bin/legmacs already honours for legmacs_init.lg): ;; ;; {:key "sk-..." -;; :model "gpt-5.6-luna"} +;; :model "gpt-5.6-luna" +;; :reasoning-effort :medium} ; none, low, medium, high, ... ;; ;; :key and an optional :model are the whole expected surface, but the file -;; is merged into the config map wholesale, so :provider, :url, :max-tokens -;; and :system work from here too without needing their own plumbing. +;; is merged into the config map wholesale, so :provider, :url, :api +;; (:responses or :chat-completions), :max-tokens, :reasoning-effort, +;; :system, :tools and :max-tool-rounds work from here too without needing +;; their own plumbing. ;; ;; Re-read on every call rather than cached at load: it's one small file ;; against a network round-trip, and editing your model in a legmacs buffer @@ -192,36 +517,274 @@ close (string/last-index-of body "```")] (string/trim (if close (subs body 0 close) body)))))) -(defn- one-line - "Collapse a message to something the one-line echo area can show." +(defn format-echo + "Echo-area text: newlines flattened so render can wrap to the terminal + width and grow the echo rows. Not truncated -- a 200-char cap is what + hid the rest of an HTTP error on a one-line minibuffer." [s] - (let [s (string/replace (str s) "\n" " ")] - (if (> (count s) 200) (str (subs s 0 200) "...") s))) + (string/trim (string/replace (str s) "\n" " "))) + +(defn http-error-text + "Pull error.message out of an OpenAI/Anthropic JSON body when present, + otherwise the raw body. The echo area then wraps this instead of showing + a truncated blob of JSON." + [body] + (let [raw (str body) + extracted (try + (let [m (json/read-json raw) + err (get m "error")] + (or (when (map? err) (get err "message")) + (get m "message"))) + (catch e nil))] + (format-echo (or (when (seq extracted) extracted) raw)))) (defn- error-message [e] (or (ex-message e) (str e))) -(defn- request-completion [cfg provider user-message] +(defn- tool-arg [args k] + (or (get args k) + (get args (keyword k)))) + +(defn catalog-worthy? + "True for a namespace name we are willing to list or auto-require: lowercase, + not a clojure.* alias stub, not a compiler-internal ns." + [nm] + (and (seq nm) + (<= 97 (int (nth nm 0)) 122) + (not (string/starts-with? nm "clojure.")) + (not (string/starts-with? nm "java.")) + (not (contains? skip-catalog-ns nm)))) + +(defn- format-var-names [ns-sym] + (string/join " " (sort (map str (keys (ns-publics ns-sym)))))) + +(defn- format-ns-line [nm] + (let [sym (symbol nm)] + (if (find-ns sym) + (str nm ": " (format-var-names sym)) + nm))) + +(defn loaded-lib-names + "Canonical names of loaded namespaces the model is allowed to see." + [] + (sort (filter catalog-worthy? + (map (fn [ns] (str (ns-name ns))) (all-ns))))) + +(defn- available-stdlib [] + (filter (fn [nm] (find-ns (symbol nm))) stdlib-libs)) + +(defn- parse-libspec [spec] + (try + (cond + (symbol? spec) {:lib (str spec)} + (vector? spec) + (let [lib (str (first spec)) + opts (apply hash-map (rest spec)) + alias (:as opts)] + {:lib lib + :as (when alias (str alias))}) + :else nil) + (catch e + (when (vector? spec) + {:lib (str (first spec))})))) + +(defn- name-char? [ch] + (let [c (int ch)] + (or (<= 97 c 122) (<= 65 c 90) (<= 48 c 57) + (contains? #{45 95 33 63 42 43 62 60 61} c)))) + +(defn- ns-name-char? [ch] + (or (name-char? ch) (= ch \.))) + +(defn- line-start? [text offset] + (or (zero? offset) + (= \newline (nth text (dec offset))) + (and (contains? #{\space \tab} (nth text (dec offset))) + (line-start? text (dec offset))))) + +(defn ns-head-at? + "Does a top-level `(ns ` form open at `offset`?" + [text offset] + (and (<= 0 offset) + (line-start? text offset) + (<= (+ offset 4) (count text)) + (= "(ns" (subs text offset (+ offset 3))) + (not (name-char? (nth text (+ offset 3)))))) + +(defn find-ns-form + "[start end) of the first top-level (ns ...) form, or nil." + [text] + (let [scanned (ls/scan text) + candidates (filter (fn [[o c]] + (and (< o c) + (ns-head-at? text o) + (not (ls/in-string-or-comment? scanned o)))) + (:pairs scanned))] + (when (seq candidates) + (let [[o c] (apply min-key first candidates)] + [o (inc c)])))) + +(defn- find-require-list [text ns-start ns-end] + (let [region (subs text ns-start ns-end) + idx (string/index-of region "(:require")] + (when idx + (let [open (+ ns-start idx) + close (get (:pairs (ls/scan text)) open)] + (when (and close (< close ns-end)) + [open (inc close)]))))) + +(defn parse-require-list + "Libspecs inside a `(:require ...)` form, as {:lib :as} maps." + [text req-start req-end] + (let [form (try (read-string (subs text req-start req-end)) + (catch e nil))] + (if (seq form) + (vec (keep parse-libspec (rest form))) + []))) + +(defn ns-require-index + "Libs and :as aliases declared by the file's ns form. + {:libs #{\"string\" ...} :aliases {\"buf\" \"legmacs.buffer\"}}." + [text] + (if-let [[ns-s ns-e] (find-ns-form text)] + (if-let [[rs re] (find-require-list text ns-s ns-e)] + (let [specs (parse-require-list text rs re)] + {:libs (set (map :lib specs)) + :aliases (into {} (keep (fn [s] + (when (:as s) + [(:as s) (:lib s)])) + specs))}) + {:libs #{} :aliases {}}) + {:libs #{} :aliases {}})) + +(defn- format-file-requires [text] + (let [idx (ns-require-index text) + libs (sort (:libs idx)) + aliases (:aliases idx)] + (if-not (seq libs) + "" + (str "This file's ns form requires:\n" + (string/join + "\n" + (map (fn [lib] + (let [alias (some (fn [[a l]] (when (= l lib) a)) aliases) + extra (when (and (not (contains? (set stdlib-libs) lib)) + (find-ns (symbol lib))) + (str ": " (format-var-names (symbol lib))))] + (str " " lib + (when alias (str " as " alias)) + extra))) + libs)) + "\n")))) + +(defn namespace-catalog + "Live stdlib (and this file's requires) for the user message." + ([] (namespace-catalog nil)) + ([text] + (str "Loaded stdlib (canonical names — never clojure.string, always string/):\n" + (string/join "\n" (map (fn [nm] (str " " (format-ns-line nm))) + (available-stdlib))) + "\n" + (when (seq text) + (format-file-requires text))))) + +(defn tool-list-namespaces [] + (string/join "\n" (loaded-lib-names))) + +(defn tool-ns-publics [args] + (let [nm (str (tool-arg args "ns"))] + (cond + (not (seq nm)) "ns-publics: missing ns" + (not (find-ns (symbol nm))) (str "no such namespace: " nm) + :else (format-var-names (symbol nm))))) + +(defn tool-var-doc [args] + (let [nm (str (tool-arg args "name"))] + (if-not (seq nm) + "var-doc: missing name" + (let [v (resolve (symbol nm))] + (if-not v + (str "no such var: " nm) + (let [m (or (meta v) {}) + doc (:doc m) + arglists (:arglists m)] + (str nm + (when arglists (str "\n" (pr-str arglists))) + "\n" + (or doc "loaded, no docstring.")))))))) + +(defn call-tool + "Run a vibe tool by name. args is a map (string or keyword keys) or a + JSON object string. Always returns a string the model can read." + [name args] + (let [args (as-arg-map args)] + (cond + (= name "list-namespaces") (tool-list-namespaces) + (= name "ns-publics") (tool-ns-publics args) + (= name "var-doc") (tool-var-doc args) + :else (str "unknown tool: " name)))) + +(defn- openai-endpoint [cfg provider] + (or (:url cfg) + (when (= :chat-completions (openai-api cfg)) + (openai-chat-url)) + (:url provider))) + +(defn- completion-turn [cfg provider messages tools] (let [key (api-key cfg provider) model (or (:model cfg) (:model provider)) - url (or (:url cfg) (:url provider))] + url (openai-endpoint cfg provider) + api (openai-api cfg)] (when-not key (throw (str "no API key -- put {:key \"...\"} in " (config-path) " (or export " (:key-env provider) ")"))) (let [body ((:body provider) {:model model :system (:system cfg) - :user user-message - :max-tokens (:max-tokens cfg)}) + :messages messages + :max-tokens (:max-tokens cfg) + :tools tools + :reasoning-effort (:reasoning-effort cfg) + :api api}) resp (http/post url (json/write-json body) {:headers ((:headers provider) key)}) status (:status resp)] (when-not (and (<= 200 status) (< status 300)) - (throw (str "HTTP " status ": " (one-line (:body resp))))) + ;; Some endpoints (chat/completions on gpt-5.6, local servers) 400 + ;; on tools. One retry without them still has the namespace catalog. + (if (and (= 400 status) (seq tools)) + (completion-turn cfg provider messages nil) + (throw (str "HTTP " status ": " (http-error-text (:body resp)))))) (let [parsed (json/read-json (:body resp)) - content ((:extract provider) parsed)] - (when-not (seq content) - (throw (str "no content in response: " (one-line (:body resp))))) - content)))) + result ((:parse provider) parsed)] + (when (and (not (seq (:content result))) + (not (seq (:tool-calls result)))) + (throw (str "no content in response: " (http-error-text (:body resp))))) + result)))) + +(defn- append-tool-round [messages parsed] + (into (conj messages {:role "assistant" + :content (:content parsed) + :tool-calls (:tool-calls parsed) + :output (:output parsed)}) + (mapv (fn [c] + {:role "tool" + :tool-call-id (:id c) + :content (call-tool (:name c) (:args c))}) + (:tool-calls parsed)))) + +(defn- request-completion [cfg provider user-message] + (loop [messages [{:role "user" :content user-message}] + round 0] + (let [max-rounds (or (:max-tool-rounds cfg) 4) + use-tools (and (:tools cfg) (< round max-rounds)) + parsed (completion-turn cfg provider messages + (when use-tools vibe-tools))] + (if (and (seq (:tool-calls parsed)) use-tools) + (recur (append-tool-round messages parsed) (inc round)) + (let [content (:content parsed)] + (when-not (seq content) + (throw "no content in response")) + content))))) ;; --- call-site context --------------------------------------------------- ;; @@ -252,16 +815,142 @@ "The prompt actually sent. Splitting this out (pure, no I/O) is what makes the interesting half of vibe testable." [request ctx] - (if-not ctx - (str "Request: " request "\n\n" - "There is no surrounding file; reply with standalone let-go code.") - (str "Request: " request "\n\n" - "File: " (or (:filename ctx) "*scratch*") "\n" - "The " marker " below starts at column " (:column ctx) - ", so indent any continuation lines to match.\n\n" - "----- begin file -----\n" - (:text ctx) - "\n----- end file -----"))) + (str "Request: " request "\n\n" + (if-not ctx + "There is no surrounding file; reply with standalone let-go code.\n\n" + (str "File: " (or (:filename ctx) "*scratch*") "\n" + "The " marker " below starts at column " (:column ctx) + ", so indent any continuation lines to match.\n\n" + "----- begin file -----\n" + (:text ctx) + "\n----- end file -----\n\n")) + "----- namespaces -----\n" + (namespace-catalog (when ctx (:text ctx))))) + +(defn- span-covering [spans i] + (some (fn [sp] + (when (and (<= (:start sp) i) (< i (:end sp))) + sp)) + spans)) + +(defn- rewrite-at [text n i] + (some (fn [[from to]] + (let [m (count from)] + (when (and (<= (+ i m) n) + (= from (subs text i (+ i m))) + (or (zero? i) + (not (ns-name-char? (nth text (dec i)))))) + [m to]))) + clojure-ns-rewrites)) + +(defn rewrite-clojure-ns + "Replace JVM-Clojure qualified prefixes with let-go's canonical names, + leaving strings and comments alone. clojure.core/ is stripped." + [code] + (let [spans (:spans (ls/scan code)) + n (count code)] + (loop [i 0, out ""] + (cond + (>= i n) out + :else + (if-let [sp (span-covering spans i)] + (recur (:end sp) (str out (subs code i (:end sp)))) + (if-let [[m to] (rewrite-at code n i)] + (recur (+ i m) (str out to)) + (recur (inc i) (str out (nth code i))))))))) + +(defn- symbol-end [text n i] + (loop [j i] + (if (and (< j n) + (or (ns-name-char? (nth text j)) + (= \/ (nth text j)))) + (recur (inc j)) + j))) + +(defn- letter-or-sym-start? [ch] + (let [c (int ch)] + (or (<= 97 c 122) (<= 65 c 90) + (contains? #{42 43 33 63 95 61 45} c)))) + +(defn qualified-prefixes + "Namespace prefixes of qualified symbols in `code`, skipping strings, + comments, and keywords." + [text] + (let [scanned (ls/scan text) + n (count text)] + (loop [i 0, acc []] + (cond + (>= i n) (vec (distinct acc)) + (ls/in-string-or-comment? scanned i) + (recur (inc i) acc) + (= \: (nth text i)) + (recur (symbol-end text n (inc i)) acc) + (letter-or-sym-start? (nth text i)) + (let [j (symbol-end text n i) + s (subs text i j) + slash (string/index-of s "/")] + (if (and slash (pos? slash) (< slash (dec (count s)))) + (recur j (conj acc (subs s 0 slash))) + (recur j acc))) + :else + (recur (inc i) acc))))) + +(defn needed-libs + "Canonical namespace names `code` uses that `file-text`'s ns form does + not already require or alias, and that are actually loaded." + [code file-text] + (let [idx (ns-require-index file-text) + known-alias? (set (keys (:aliases idx))) + known-lib? (:libs idx)] + (vec + (distinct + (filter (fn [p] + (and (not (known-alias? p)) + (not (known-lib? p)) + (catalog-worthy? p) + (find-ns (symbol p)))) + (qualified-prefixes code)))))) + +(defn- trailing-indent [inner] + (let [i (string/last-index-of inner "\n")] + (if i + (let [line (subs inner (inc i)) + n (count line) + w (loop [j 0] + (if (and (< j n) (contains? #{\space \tab} (nth line j))) + (recur (inc j)) + j))] + (subs line 0 w)) + " "))) + +(defn add-require + "Insert `[lib]` into the file's `(:require ...)`, creating the clause if + needed. Returns `text` unchanged when there is no ns form." + [text lib] + (if-let [[ns-s ns-e] (find-ns-form text)] + (if-let [[req-s req-e] (find-require-list text ns-s ns-e)] + (let [close (dec req-e) + inner (subs text (inc req-s) close) + spec (str "[" lib "]") + insert (if (string/includes? inner "\n") + (str "\n" (trailing-indent inner) spec) + (str " " spec))] + (str (subs text 0 close) insert (subs text close))) + (let [close (dec ns-e) + insert (str "\n (:require [" lib "])")] + (str (subs text 0 close) insert (subs text close)))) + text)) + +(defn ensure-requires + "Add each of `libs` to the ns form that is not already required." + [text libs] + (reduce (fn [t lib] + (let [idx (ns-require-index t)] + (if (contains? (:libs idx) lib) + t + (add-require t lib)))) + text + libs)) (defn vibe "Ask the configured model for let-go code and return it as a string. @@ -279,16 +968,12 @@ provider (get providers (:provider cfg))] (when-not provider (throw (str "unknown vibe provider " (pr-str (:provider cfg))))) - (strip-fences (request-completion cfg provider - (user-message request *vibe-context*)))))) + (rewrite-clojure-ns + (strip-fences (request-completion cfg provider + (user-message request *vibe-context*))))))) ;; --- finding the (vibe ...) form ---------------------------------------- -(defn- name-char? [ch] - (let [c (int ch)] - (or (<= 97 c 122) (<= 65 c 90) (<= 48 c 57) - (contains? #{45 95 33 63 42 43 62 60 61} c)))) - (defn vibe-head-at? "Does a `(vibe ` form open at `offset`? Checks the character after the name too, so (vibes ...) doesn't match." @@ -331,6 +1016,35 @@ (assoc :cursor-row row :cursor-col col :goal-col nil) buf/clamp-cursor)))) +(defn apply-vibe-code + "Rewrite JVM-Clojure prefixes, splice `code` over [start end), and add + any missing requires to the ns form. One undo step." + [state start end code] + (if-not (buf/writable? state) + (assoc state :message "Buffer is read-only") + (let [code (rewrite-clojure-ns (string/trim code)) + text (buf/buffer-text state) + spliced (str (subs text 0 start) code (subs text end)) + libs (needed-libs code spliced) + new-text (ensure-requires spliced libs) + ns-at (first (find-ns-form spliced)) + shift (if (and ns-at (< ns-at start)) + (- (count new-text) (count spliced)) + 0) + added (sort (remove (:libs (ns-require-index spliced)) + (:libs (ns-require-index new-text)))) + state (-> (buf/push-undo state) + (assoc :lines (buf/split-lines new-text) :modified? true)) + [row col] (buf/row-col-of state (+ start shift (count code)))] + (-> state + (assoc :cursor-row row :cursor-col col :goal-col nil) + buf/clamp-cursor + (assoc :message + (if (seq added) + (str "vibed " (count code) " chars, required " + (string/join ", " added)) + (str "vibed " (count code) " chars"))))))) + ;; --- the command --------------------------------------------------------- (defn eval-vibe-form @@ -352,7 +1066,7 @@ buffer may have moved while the future ran. Does no I/O." [state start end outcome] (if-let [err (:err outcome)] - (assoc state :message (str "vibe error: " (one-line (error-message err)))) + (assoc state :message (str "vibe error: " (format-echo (error-message err)))) (let [text (buf/buffer-text state)] (if-not (and start end (<= end (count text)) (vibe-head-at? text start)) (assoc state :message "vibe: form moved before it could run") @@ -363,9 +1077,8 @@ ;; miserable way to find out. (if-not (string? result) (assoc state :message (str "vibe: expected a string, got " - (one-line (pr-str result)))) - (-> (splice-offsets state start end result) - (assoc :message (str "vibed " (count result) " chars"))))))))) + (format-echo (pr-str result)))) + (apply-vibe-code state start end result))))))) (defn vibe-then "resolve-vibe as a (fn [state outcome] -> state) for spawn-task." @@ -382,7 +1095,8 @@ "Replace the (vibe \"...\") form around point with what the model writes. Snapshots the form and starts the HTTP call in a future so the editor keeps reading keys -- spawn-task (see legmacs.buffers), not a blocking - pending-task." + pending-task. Namespaces the code uses that the file does not require + are added to the ns form in the same undo step." [state] (let [text (buf/buffer-text state) point (buf/offset-of state (:cursor-row state) (:cursor-col state))] diff --git a/test/vibe_test.lg b/test/vibe_test.lg index e39e7ea..49f95a4 100644 --- a/test/vibe_test.lg +++ b/test/vibe_test.lg @@ -164,6 +164,13 @@ (is (string/includes? (:message s*) "vibe error")) (is (string/includes? (:message s*) "unknown vibe provider")))) + (testing "a long API error is not truncated; the echo area wraps it" + (let [msg (apply str (repeat 80 "xy")) + s (buf/new-state nil ["(vibe \"a thing\")"]) + s* (vibe/resolve-vibe s 0 16 {:err (str "HTTP 400: " msg)})] + (is (string/includes? (:message s*) msg)) + (is (not (string/includes? (:message s*) "..."))))) + (testing "a string outcome splices over the form" (let [s (buf/new-state nil ["(vibe \"a thing\")"]) s* (vibe/resolve-vibe s 0 16 {:ok "42"})] @@ -185,3 +192,194 @@ (testing "with-context restores *vibe-context* even when the body throws" (try (vibe/with-context {:text "x"} (fn [] (throw "boom"))) (catch e nil)) (is (nil? vibe/*vibe-context*)))) + +(deftest default-system-prompt-steers-away-from-jvm-clojure + (let [p vibe/default-system-prompt] + (is (string/includes? p "string/join")) + (is (string/includes? p "clojure.string")) + (is (string/includes? p "docstring")) + (is (string/includes? p "ns-publics")))) + +(deftest namespace-catalog-lists-live-stdlib + (let [cat (vibe/namespace-catalog)] + (is (string/includes? cat "string:")) + (is (string/includes? cat "join")) + (is (string/includes? cat "json:")) + (is (string/includes? cat "read-json")))) + +(deftest user-message-includes-the-namespace-catalog + (let [msg (vibe/user-message "make it go" nil)] + (is (string/includes? msg "----- namespaces -----")) + (is (string/includes? msg "string:")))) + +(deftest user-message-lists-the-file-requires + (let [text (str "(ns foo\n (:require [string]\n [json]))\n" vibe/marker) + msg (vibe/user-message "x" {:text text :filename "foo.lg" :column 0})] + (is (string/includes? msg "This file's ns form requires:")) + (is (string/includes? msg "json")))) + +(deftest rewrite-clojure-ns-uses-canonical-names + (testing "the prefixes models actually emit" + (is (= "string/join" (vibe/rewrite-clojure-ns "clojure.string/join"))) + (is (= "map" (vibe/rewrite-clojure-ns "clojure.core/map"))) + (is (= "async/chan" (vibe/rewrite-clojure-ns "clojure.core.async/chan"))) + (is (= "io/slurp" (vibe/rewrite-clojure-ns "clojure.java.io/slurp")))) + + (testing "strings and comments are left alone" + (is (= "\"clojure.string/join\"" + (vibe/rewrite-clojure-ns "\"clojure.string/join\""))) + (is (= ";; clojure.string/join" + (vibe/rewrite-clojure-ns ";; clojure.string/join"))))) + +(deftest qualified-prefixes-skip-strings-and-keywords + (is (= ["string" "os"] + (vibe/qualified-prefixes "(defn f [] (string/join (os/getenv \"FOO/BAR\")))"))) + (is (= [] (vibe/qualified-prefixes ":string/foo"))) + (is (= [] (vibe/qualified-prefixes "\"string/join\"")))) + +(deftest ns-require-index-reads-libspecs + (let [idx (vibe/ns-require-index + "(ns foo\n (:require [string]\n [legmacs.buffer :as buf]\n [json :as json]))")] + (is (= #{"string" "legmacs.buffer" "json"} (:libs idx))) + (is (= {"buf" "legmacs.buffer" "json" "json"} (:aliases idx)))) + + (testing "no ns form" + (is (= {:libs #{} :aliases {}} (vibe/ns-require-index "(+ 1 2)"))))) + +(deftest ensure-requires-edits-the-ns-form + (testing "creates a :require clause when the ns form has none" + (is (= "(ns foo\n (:require [string]))" + (vibe/ensure-requires "(ns foo)" ["string"])))) + + (testing "appends to a one-line :require" + (is (= "(ns foo (:require [string] [json]))" + (vibe/ensure-requires "(ns foo (:require [string]))" ["json"])))) + + (testing "does not duplicate a lib that is already required" + (is (= "(ns foo (:require [string]))" + (vibe/ensure-requires "(ns foo (:require [string]))" ["string"])))) + + (testing "a file with no ns form is left alone" + (is (= "(string/join x)" + (vibe/ensure-requires "(string/join x)" ["string"]))))) + +(deftest needed-libs-skips-aliases-and-unknowns + (let [file "(ns foo\n (:require [legmacs.buffer :as buf]\n [string]))"] + (is (= ["json"] + (vibe/needed-libs "(json/read-json s)" file))) + (is (= [] + (vibe/needed-libs "(buf/insert-string s \"x\")" file))) + (is (= [] + (vibe/needed-libs "(string/join \",\" xs)" file))) + (is (= [] + (vibe/needed-libs "(no-such-ns/foo)" file))))) + +(deftest call-tool-inspects-the-live-runtime + (is (string/includes? (vibe/call-tool "list-namespaces" {}) "string")) + (is (string/includes? (vibe/call-tool "ns-publics" {"ns" "string"}) "join")) + (is (string/includes? (vibe/call-tool "ns-publics" {:ns "json"}) "read-json")) + (is (string/includes? (vibe/call-tool "var-doc" {"name" "string/join"}) + "string/join")) + (is (string/includes? (vibe/call-tool "ns-publics" {"ns" "nope"}) + "no such namespace")) + (is (string/includes? (vibe/call-tool "mystery" {}) "unknown tool"))) + +(deftest resolve-vibe-adds-missing-requires-in-one-undo + (let [s (buf/new-state nil ["(ns foo)" "" "(vibe \"x\")"]) + text (buf/buffer-text s) + [start end] (vibe/find-vibe-form text 12) + s* (vibe/resolve-vibe s start end + {:ok "(defn bar [] (string/join \",\" xs))"}) + out (buf/buffer-text s*)] + (testing "the code lands and the ns form gains the require" + (is (string/includes? out "(:require [string])")) + (is (string/includes? out "(defn bar [] (string/join \",\" xs))")) + (is (string/includes? (:message s*) "required string"))) + + (testing "one undo restores the vibe form and the original ns" + (is (= ["(ns foo)" "" "(vibe \"x\")"] (:lines (buf/undo s*)))))) + + (testing "clojure.string/ in the reply is rewritten before the require is added" + (let [s (buf/new-state nil ["(ns foo)" "(vibe \"x\")"]) + text (buf/buffer-text s) + [start end] (vibe/find-vibe-form text 10) + s* (vibe/resolve-vibe s start end {:ok "clojure.string/join"}) + out (buf/buffer-text s*)] + (is (string/includes? out "string/join")) + (is (not (string/includes? out "clojure.string"))) + (is (string/includes? out "[string]"))))) + +(deftest http-error-text-pulls-the-api-message + (testing "OpenAI's nested error.message, not the raw JSON" + (is (= "Function tools with reasoning_effort are not supported" + (vibe/http-error-text + "{\"error\":{\"message\":\"Function tools with reasoning_effort are not supported\",\"type\":\"invalid_request_error\"}}")))) + + (testing "a non-JSON body is returned flattened, not truncated" + (let [raw (str "oops\n" (apply str (repeat 80 "z")))] + (is (string/includes? (vibe/http-error-text raw) "zzzz")) + (is (not (string/includes? (vibe/http-error-text raw) "\n"))) + (is (not (string/includes? (vibe/http-error-text raw) "...")))))) + +(deftest openai-api-picks-responses-unless-the-url-is-chat-completions + (testing "the default, and an explicit override" + (is (= :responses (vibe/openai-api {}))) + (is (= :chat-completions (vibe/openai-api {:api :chat-completions}))) + (is (= :responses (vibe/openai-api {:api :responses})))) + + (testing "a llama.cpp-style url infers the older shape" + (is (= :chat-completions + (vibe/openai-api {:url "http://127.0.0.1:8080/v1/chat/completions"}))))) + +(deftest openai-body-speaks-responses-by-default + (let [body (vibe/openai-body {:model "gpt-5.6-luna" + :system "sys" + :messages [{:role "user" :content "hi"}] + :max-tokens 8192 + :tools vibe/vibe-tools + :reasoning-effort :high})] + (is (= "sys" (:instructions body))) + (is (vector? (:input body))) + (is (= {:effort "high"} (:reasoning body))) + (is (= 8192 (:max_output_tokens body))) + (is (false? (:store body))) + (is (= "function" (:type (first (:tools body))))) + (is (= "list-namespaces" (:name (first (:tools body))))) + (is (nil? (:messages body))) + (is (nil? (:reasoning_effort body))))) + +(deftest openai-chat-completions-body-still-exists + (let [body (vibe/openai-body {:api :chat-completions + :model "gpt-5.6-luna" + :system "sys" + :messages [{:role "user" :content "hi"}] + :max-tokens 100 + :tools vibe/vibe-tools + :reasoning-effort :high})] + (testing "tools on chat/completions coerce effort to none" + (is (= "none" (:reasoning_effort body))) + (is (seq (:messages body))) + (is (nil? (:input body)))))) + +(deftest openai-responses-parse-reads-output-items + (testing "plain text" + (let [p (vibe/openai-responses-parse + {"output" [{"type" "message" + "content" [{"type" "output_text" "text" "(+ 1 2)"}]}]})] + (is (= "(+ 1 2)" (:content p))) + (is (= [] (:tool-calls p))))) + + (testing "a function_call keeps the raw output so the next turn can echo it" + (let [item {"type" "function_call" + "call_id" "call_1" + "name" "ns-publics" + "arguments" "{\"ns\":\"os\"}"} + p (vibe/openai-responses-parse {"output" [item]})] + (is (= "ns-publics" (:name (first (:tool-calls p))))) + (is (= "os" (get (:args (first (:tool-calls p))) "ns"))) + (is (= [item] (:output p)))))) + +(deftest effort-str-accepts-keywords-and-strings + (is (= "medium" (vibe/effort-str :medium))) + (is (= "high" (vibe/effort-str "high"))) + (is (nil? (vibe/effort-str nil))))