From 6249d8a25139f40ad3e17d294ff00eda3db84549 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Fri, 8 May 2026 22:21:27 +0300 Subject: [PATCH] feat: AI-assisted compilation and code generation - New src/ai_assist.nim module with DeepSeek/OpenAI/MiMo API support - AI explains compiler errors automatically when compilation fails - New CLI command: cljnim ai '' for code generation - REPL :ai command for interactive AI assistance - API keys read from environment vars (DEEPSEEK_API_KEY, OPENAI_API_KEY, MIMO_API_KEY) - Tests for prompt building and response formatting - Updated README with AI integration docs --- .gitignore | 2 + Makefile | 18 ++++- README.md | 82 +++++++++++++++++--- src/ai_assist.nim | 160 +++++++++++++++++++++++++++++++++++++++ src/cljnim.nim | 66 +++++++++++++--- src/repl.nim | 41 +++++++--- tests/test_ai_assist.nim | 45 +++++++++++ 7 files changed, 382 insertions(+), 32 deletions(-) create mode 100644 src/ai_assist.nim create mode 100644 tests/test_ai_assist.nim diff --git a/.gitignore b/.gitignore index 6f00fe4..56f76bf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ test_pvec test_pmap test_deps test_eval +test_macros +test_ai_assist books/ diff --git a/Makefile b/Makefile index ccb326f..9ef021f 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: build test clean run-example bench bench-aot +.PHONY: build test check clean run-example bench bench-aot NIMCACHE = nimcache build: - nim c -o:cljnim src/cljnim.nim + nim c -d:ssl -o:cljnim src/cljnim.nim test: nim c -r tests/test_reader.nim @@ -11,7 +11,9 @@ test: nim c -r tests/test_pvec.nim nim c -r tests/test_pmap.nim nim c -r tests/test_deps.nim + nim c -r tests/test_macros.nim nim c -r tests/test_eval.nim + nim c --path:src -r tests/test_ai_assist.nim test-reader: nim c -r tests/test_reader.nim @@ -19,6 +21,13 @@ test-reader: test-emitter: nim c -r tests/test_emitter.nim +check: build test + ./cljnim run examples/hello.clj + ./cljnim run examples/math.clj + ./cljnim run examples/core.clj + ./cljnim run examples/interop.clj + ./cljnim run examples/macros.clj + run-example: build ./cljnim run examples/hello.clj @@ -36,3 +45,8 @@ clean: find . -name "test_reader" -delete find . -name "test_emitter" -delete find . -name "test_deps" -delete + find . -name "test_pvec" -delete + find . -name "test_pmap" -delete + find . -name "test_eval" -delete + find . -name "test_macros" -delete + find . -name "test_ai_assist" -delete diff --git a/README.md b/README.md index 659a594..cd153e0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,58 @@ $ ./cljnim repl --json See [`docs/AI_FIRST.md`](docs/AI_FIRST.md) for the full AI integration design. +## AI-Powered Assistance + +Clojure/Nim can use paid AI APIs (DeepSeek, OpenAI, Xiaomi MiMo) to help you write and debug code. + +### Setup + +Set one of these environment variables: + +```bash +# DeepSeek (recommended, fast & cheap) +export DEEPSEEK_API_KEY="sk-..." + +# OpenAI / OpenRouter +export OPENAI_API_KEY="sk-..." + +# Xiaomi MiMo +export MIMO_API_KEY="..." +``` + +### Features + +**Auto-error explanation** — When compilation fails, the compiler automatically asks AI for a fix: +```bash +$ ./cljnim run broken.clj +Compilation failed +Error: type mismatch: got but expected +💡 AI Suggestion: + The function `greet` expects a string but you passed an integer. + Fix: (defn greet [name] (str "Hello " name)) +``` + +**REPL AI command** — Generate code interactively: +```bash +$ ./cljnim repl +user> :ai function that calculates fibonacci using loop/recur +🤖 Thinking... +💡 AI Suggestion: + (defn fib [n] + (loop [a 0 b 1 i n] + (if (= i 0) + a + (recur b (+ a b) (dec i))))) +``` + +**CLI code generation** — Generate code from terminal: +```bash +$ ./cljnim ai "function that reads a file line by line" +(defn read-lines [filename] + (let [content (slurp filename)] + (clojure.string/split-lines content))) +``` + ## Architecture ``` @@ -131,14 +183,25 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for details. │ ├── reader.nim # Clojure reader / EDN parser │ ├── emitter.nim # Nim code generator │ ├── repl.nim # Human & AI REPL +│ ├── eval.nim # Tree-walking interpreter +│ ├── deps.nim # Dependency resolution +│ ├── core.nim # Core runtime functions │ ├── types.nim # Clojure value types │ ├── macros.nim # Macro expansion engine │ └── runtime.nim # Runtime implementation ├── lib/ -│ └── cljnim_runtime.nim # Clojure runtime in Nim +│ ├── cljnim_runtime.nim # Clojure runtime in Nim +│ ├── cljnim_pvec.nim # Persistent Vector (HAMT) +│ ├── cljnim_pmap.nim # Persistent Map (HAMT) +│ └── cljnim_async.nim # core.async channels ├── examples/ │ ├── hello.clj -│ └── math.clj +│ ├── math.clj +│ ├── core.clj +│ ├── macros.clj +│ ├── ffi.clj +│ ├── interop.clj +│ └── ai_tools.clj ├── docs/ # Documentation (EN + BG) ├── tests/ ├── Makefile @@ -148,19 +211,18 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for details. ## Supported Features ### Working Now ✅ -- Reader: numbers, strings, symbols, keywords, lists `()`, vectors `[]` +- Reader: numbers, strings, symbols, keywords, lists `()`, vectors `[]`, maps `{}` - Special forms: `def`, `defn`, `fn`, `let`, `if`, `do`, `quote` - Arithmetic: `+`, `-`, `*`, `/`, `=`, `<`, `>` -- Functions: `println` +- Functions: `println`, `map`, `filter`, `reduce` - Human REPL and JSON REPL - AOT compilation to native binaries - -### In Progress 🔄 -- Persistent data structures (Vector, Map, Set) -- `defmacro` system +- Persistent data structures (Vector, Map, Set) via HAMT +- Macro system (`defmacro`, `->`, `->>`, `and`, `or`, `when`, `cond`) - Nim/C interop -- File operations from REPL -- Git operations from REPL +- File and Git operations from REPL +- Tree-walking interpreter for fast REPL eval +- Atoms, Agents, and core.async channels See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the full roadmap. diff --git a/src/ai_assist.nim b/src/ai_assist.nim new file mode 100644 index 0000000..99b15e0 --- /dev/null +++ b/src/ai_assist.nim @@ -0,0 +1,160 @@ +# AI Assistance for Clojure/Nim Compiler +# Supports DeepSeek API and OpenAI-compatible APIs (Xiaomi MiMo, etc.) +# API keys are read from environment variables — never hardcoded. + +import std/[httpclient, json, os, strutils, uri] + +type + AiProvider* = enum + aiDeepSeek + aiOpenAiCompatible + + AiConfig* = object + provider*: AiProvider + apiKey*: string + baseUrl*: string + model*: string + timeoutMs*: int + + AiResponse* = object + ok*: bool + suggestion*: string + rawJson*: string + +proc detectConfig*(): AiConfig = + ## Auto-detect AI configuration from environment variables + result.timeoutMs = 15000 + + # DeepSeek + let deepseekKey = getEnv("DEEPSEEK_API_KEY", "") + if deepseekKey.len > 0: + return AiConfig( + provider: aiDeepSeek, + apiKey: deepseekKey, + baseUrl: "https://api.deepseek.com", + model: getEnv("DEEPSEEK_MODEL", "deepseek-chat"), + timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000")) + ) + + # OpenAI-compatible (Xiaomi MiMo, OpenRouter, etc.) + let openaiKey = getEnv("OPENAI_API_KEY", "") + if openaiKey.len > 0: + return AiConfig( + provider: aiOpenAiCompatible, + apiKey: openaiKey, + baseUrl: getEnv("OPENAI_BASE_URL", "https://api.openai.com"), + model: getEnv("OPENAI_MODEL", "gpt-4o-mini"), + timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000")) + ) + + # Xiaomi MiMo (OpenAI-compatible) + let mimoKey = getEnv("MIMO_API_KEY", "") + if mimoKey.len > 0: + return AiConfig( + provider: aiOpenAiCompatible, + apiKey: mimoKey, + baseUrl: getEnv("MIMO_BASE_URL", "https://api.mi-mo.ai"), + model: getEnv("MIMO_MODEL", "mimo-chat"), + timeoutMs: parseInt(getEnv("AI_TIMEOUT_MS", "15000")) + ) + + # No API key found + return AiConfig(provider: aiDeepSeek, apiKey: "", baseUrl: "", model: "", timeoutMs: 0) + +proc hasAiConfig*(): bool = + detectConfig().apiKey.len > 0 + +proc buildErrorPrompt*(errorMsg, sourceCode, fileName: string): string = + ## Build a prompt for the AI to analyze a compiler error + result = """You are an expert Clojure/Nim compiler assistant. The user got a compilation error. + +**File:** """ & fileName & """ + +**Source code:** +```clojure +""" & sourceCode & """ +``` + +**Compiler error:** +``` +""" & errorMsg & """ +``` + +Please explain the error in simple terms and suggest a fix. Keep your response under 200 words. +If the error is in Clojure code, show the corrected Clojure snippet. +Respond in the same language as the user's source code comments (Bulgarian or English). +""" + +proc buildGenerationPrompt*(description: string): string = + ## Build a prompt for AI code generation + result = """You are an expert Clojure programmer. Generate a Clojure function based on this description: + +""" & description & """ + +Requirements: +- Use idiomatic Clojure +- Include docstring +- Use loop/recur instead of recursion if possible +- Return ONLY the Clojure code, no explanations +""" + +proc callAiApi*(config: AiConfig, prompt: string): AiResponse = + ## Call the AI API and return the response + if config.apiKey.len == 0: + return AiResponse(ok: false, suggestion: "No AI API key configured. Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY environment variable.") + + let client = newHttpClient(timeout = config.timeoutMs) + defer: client.close() + + let url = config.baseUrl & "/v1/chat/completions" + let body = %*{ + "model": config.model, + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 800 + } + + client.headers["Authorization"] = "Bearer " & config.apiKey + client.headers["Content-Type"] = "application/json" + + try: + let resp = client.post(url, body = $body) + let respBody = resp.body + result.rawJson = respBody + + if resp.code.int != 200: + return AiResponse(ok: false, suggestion: "AI API error (HTTP " & $resp.code.int & "): " & respBody) + + let jsonResp = parseJson(respBody) + if jsonResp.hasKey("choices") and jsonResp["choices"].len > 0: + let content = jsonResp["choices"][0]["message"]["content"].getStr("") + return AiResponse(ok: true, suggestion: content, rawJson: respBody) + else: + return AiResponse(ok: false, suggestion: "Unexpected AI API response format", rawJson: respBody) + + except CatchableError as e: + return AiResponse(ok: false, suggestion: "AI request failed: " & e.msg) + +proc explainError*(errorMsg, sourceCode, fileName: string): AiResponse = + ## High-level helper: explain a compiler error using AI + let config = detectConfig() + let prompt = buildErrorPrompt(errorMsg, sourceCode, fileName) + return callAiApi(config, prompt) + +proc generateCode*(description: string): AiResponse = + ## High-level helper: generate Clojure code from description + let config = detectConfig() + let prompt = buildGenerationPrompt(description) + return callAiApi(config, prompt) + +proc formatSuggestion*(response: AiResponse): string = + ## Format AI response for terminal display + if not response.ok: + return "💡 AI: " & response.suggestion + + var res = "💡 AI Suggestion:\n" + for line in response.suggestion.splitLines(): + res.add(" " & line & "\n") + return res diff --git a/src/cljnim.nim b/src/cljnim.nim index 521f615..3976ed2 100644 --- a/src/cljnim.nim +++ b/src/cljnim.nim @@ -1,6 +1,6 @@ # Clojure/Nim — AI-First Clojure Compiler import os, osproc, strutils, times -import reader, emitter, types, repl, macros, deps +import reader, emitter, types, repl, macros, deps, ai_assist proc getLibPath(): string = let appDir = getAppDir() @@ -44,7 +44,7 @@ proc extractRequires(forms: seq[CljVal]): seq[(string, string)] = alias = req.items[2].symName result.add((libName, alias)) -proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) = +proc compileFileInternal(inputPath: string, outputPath: string, extraPaths: seq[string] = @[], libMode: bool = false) = let source = readFile(inputPath) let forms = reader.readAll(source) @@ -97,11 +97,17 @@ proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] f.items[0].kind == ckSymbol and f.items[0].symName == "ns"): allForms.add(f) - let nimCode = emitter.emitProgram(allForms) + let nimCode = if libMode: emitter.emitProgramLib(allForms) else: emitter.emitProgram(allForms) writeFile(outputPath, nimCode) echo "Generated: ", outputPath -proc nimCompile(nimPath: string, binPath: string, release: bool = false): int = +proc compileFile*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) = + compileFileInternal(inputPath, outputPath, extraPaths, false) + +proc compileFileLib*(inputPath: string, outputPath: string, extraPaths: seq[string] = @[]) = + compileFileInternal(inputPath, outputPath, extraPaths, true) + +proc nimCompile(nimPath: string, binPath: string, release: bool = false): tuple[exitCode: int, output: string] = let libPath = getLibPath() var cmd = "nim c" if release: @@ -109,7 +115,8 @@ proc nimCompile(nimPath: string, binPath: string, release: bool = false): int = cmd.add(" --path:" & quoteShell(libPath)) cmd.add(" -o:" & quoteShell(binPath) & " " & quoteShell(nimPath)) echo "Compiling: ", cmd - execCmd(cmd) + let (output, exitCode) = execCmdEx(cmd) + return (exitCode, output) proc makeTempDir(): string = let pid = getCurrentProcessId() @@ -143,9 +150,14 @@ proc runFile*(inputPath: string) = if useCache: echo "Using cached: ", cacheNimPath - let compileResult = nimCompile(cacheNimPath, cacheBinPath) + let (compileResult, compileOut) = nimCompile(cacheNimPath, cacheBinPath) if compileResult != 0: stderr.writeLine("Compilation failed (cached)") + stderr.writeLine(compileOut) + if ai_assist.hasAiConfig(): + let source = readFile(inputPath) + let aiRes = ai_assist.explainError(compileOut, source, inputPath) + stderr.writeLine(ai_assist.formatSuggestion(aiRes)) quit(1) try: removeDir(buildDir) except: discard let runResult = execCmd(cacheBinPath) @@ -153,9 +165,14 @@ proc runFile*(inputPath: string) = compileFile(inputPath, nimPath, depPaths) - let compileResult = nimCompile(nimPath, binPath) + let (compileResult, compileOut) = nimCompile(nimPath, binPath) if compileResult != 0: stderr.writeLine("Compilation failed") + stderr.writeLine(compileOut) + if ai_assist.hasAiConfig(): + let source = readFile(inputPath) + let aiRes = ai_assist.explainError(compileOut, source, inputPath) + stderr.writeLine(ai_assist.formatSuggestion(aiRes)) try: removeDir(buildDir) except: discard quit(1) @@ -188,6 +205,7 @@ proc main() = echo " cljnim repl [--json] Start REPL (human or AI mode)" echo " cljnim deps Resolve dependencies from deps.edn" echo " cljnim -e '' Evaluate expression" + echo " cljnim ai '' Generate Clojure code with AI" echo "" echo "REPL Commands:" echo " :quit, :q Exit REPL" @@ -215,15 +233,19 @@ proc main() = let forms = reader.readAll(code) let nimCode = emitter.emitProgram(forms) writeFile(nimPath, nimCode) - let compileResult = nimCompile(nimPath, binPath) + let (compileResult, compileOut) = nimCompile(nimPath, binPath) if compileResult != 0: stderr.writeLine("Compilation failed") + stderr.writeLine(compileOut) + if ai_assist.hasAiConfig(): + let aiRes = ai_assist.explainError(compileOut, code, "-e expression") + stderr.writeLine(ai_assist.formatSuggestion(aiRes)) try: removeDir(buildDir) except: discard quit(1) let runResult = execCmd(binPath) try: removeDir(buildDir) except: discard quit(runResult) - + case cmd of "compile": if args.len < 2: @@ -232,6 +254,14 @@ proc main() = let inputPath = args[1] let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim") compileFile(inputPath, outputPath) + + of "compile-lib": + if args.len < 2: + stderr.writeLine("Error: Missing input file") + quit(1) + let inputPath = args[1] + let outputPath = if args.len >= 3: args[2] else: inputPath.changeFileExt("nim") + compileFileLib(inputPath, outputPath) of "run": if args.len < 2: @@ -298,6 +328,24 @@ proc main() = else: echo "No dependencies to resolve" + of "ai": + if not ai_assist.hasAiConfig(): + stderr.writeLine("Error: No AI API key configured.") + stderr.writeLine("Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY environment variable.") + quit(1) + if args.len < 2: + stderr.writeLine("Error: Missing description for AI generation") + stderr.writeLine("Usage: cljnim ai 'function that reverses a list'") + quit(1) + let description = args[1] + echo "🤖 Asking AI to generate code..." + let aiRes = ai_assist.generateCode(description) + if aiRes.ok: + echo aiRes.suggestion + else: + stderr.writeLine(aiRes.suggestion) + quit(1) + else: stderr.writeLine("Unknown command: " & cmd) quit(1) diff --git a/src/repl.nim b/src/repl.nim index ccb989b..e91d291 100644 --- a/src/repl.nim +++ b/src/repl.nim @@ -1,6 +1,6 @@ # AI-First REPL for Clojure/Nim import os, osproc, strutils, json, times, net -import reader, emitter, types, macros, eval +import reader, emitter, types, macros, eval, ai_assist type ReplMode* = enum @@ -193,13 +193,18 @@ proc evalForm*(state: var ReplState, formStr: string): JsonNode = let elapsed = (epochTime() - startTime) * 1000 if not ok: + var errObj = %*{ + "type": "compiler/error", + "message": error, + "form": formStr + } + if ai_assist.hasAiConfig(): + let aiRes = ai_assist.explainError(error, formStr, "REPL input") + if aiRes.ok: + errObj["ai_suggestion"] = %aiRes.suggestion return %*{ "status": "error", - "error": { - "type": "compiler/error", - "message": error, - "form": formStr - }, + "error": errObj, "meta": { "ns": state.ns, "ms": elapsed @@ -262,6 +267,7 @@ proc runHumanRepl*(state: var ReplState) = echo ":defs List defined vars" echo ":clear Clear all definitions" echo ":ns Show current namespace" + echo ":ai Ask AI to generate Clojure code" of ":defs": for d in state.defs: if d.kind == ckList and d.items.len > 1 and d.items[1].kind == ckSymbol: @@ -272,11 +278,25 @@ proc runHumanRepl*(state: var ReplState) = of ":ns": echo state.ns else: - let res = evalForm(state, cmd) - if res["status"].getStr() == "ok": - echo "=> " & res["result"]["printed"].getStr() + if cmd.startsWith(":ai "): + let description = cmd[4..^1].strip() + if description.len == 0: + echo "Usage: :ai " + elif not ai_assist.hasAiConfig(): + echo "No AI API key configured. Set DEEPSEEK_API_KEY, OPENAI_API_KEY, or MIMO_API_KEY." + else: + echo "🤖 Thinking..." + let aiRes = ai_assist.generateCode(description) + echo ai_assist.formatSuggestion(aiRes) else: - echo "Error: " & res["error"]["message"].getStr() + let res = evalForm(state, cmd) + if res["status"].getStr() == "ok": + echo "=> " & res["result"]["printed"].getStr() + else: + echo "Error: " & res["error"]["message"].getStr() + if res["error"].hasKey("ai_suggestion"): + echo "" + echo ai_assist.formatSuggestion(AiResponse(ok: true, suggestion: res["error"]["ai_suggestion"].getStr(""))) proc runJsonRepl*(state: var ReplState) = var readyMsg = %*{ "status": "ready", "ns": state.ns, "mode": "json" } @@ -385,7 +405,6 @@ proc handleClient(state: var ReplState, client: Socket) = var readyMsg = $(%*{ "status": "ready", "ns": state.ns, "mode": "nrepl" }) & "\n" client.send(readyMsg) - var buf = "" while true: try: var line = "" diff --git a/tests/test_ai_assist.nim b/tests/test_ai_assist.nim new file mode 100644 index 0000000..6e4c5ec --- /dev/null +++ b/tests/test_ai_assist.nim @@ -0,0 +1,45 @@ +# Tests for AI assistance module (no real API calls) + +import unittest, strutils +import os +import ai_assist + +suite "AI Config Detection": + test "detectConfig returns empty when no env vars set": + # Note: this test assumes no API keys are set in test environment + # If keys ARE set, it will detect them + let cfg = detectConfig() + # At minimum, the function should not crash + check cfg.timeoutMs >= 0 + + test "hasAiConfig returns false when no keys set (if env empty)": + let key = getEnv("DEEPSEEK_API_KEY", "") + let okey = getEnv("OPENAI_API_KEY", "") + let mkey = getEnv("MIMO_API_KEY", "") + if key.len == 0 and okey.len == 0 and mkey.len == 0: + check hasAiConfig() == false + +suite "Prompt Building": + test "buildErrorPrompt includes filename and source": + let prompt = buildErrorPrompt("type mismatch", "(defn foo [x] x)", "test.clj") + check "test.clj" in prompt + check "type mismatch" in prompt + check "(defn foo [x] x)" in prompt + + test "buildGenerationPrompt includes description": + let prompt = buildGenerationPrompt("reverse a list") + check "reverse a list" in prompt + check "Clojure" in prompt + +suite "Response Formatting": + test "formatSuggestion shows error when not ok": + let resp = AiResponse(ok: false, suggestion: "No key configured") + let formatted = formatSuggestion(resp) + check "💡 AI:" in formatted + check "No key configured" in formatted + + test "formatSuggestion shows suggestion when ok": + let resp = AiResponse(ok: true, suggestion: "Use (reverse coll)") + let formatted = formatSuggestion(resp) + check "💡 AI Suggestion:" in formatted + check "Use (reverse coll)" in formatted