summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
committerPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
commit765eda955eb811d08d867ff4d3914fc6d60c22dd (patch)
treefdc87da6af9d86dbda2ea9ab08244e93fd167188
parent1b01e35c34b953cbf51298f4650dc3215c382a4f (diff)
refactor(config): drop env-based config (except OPENAI_API_KEY)
- Switch to config-file-only; only OPENAI_API_KEY read from env.\n- llm: replace env autodetect with Config + NewFromConfig; add newOpenAI/newOllama.\n- lsp: NewServer now accepts injected llm.Client.\n- cli: remove env overrides; extend appConfig with provider-specific fields; build client from config + OPENAI_API_KEY.\n- docs: update README (config-only, defaults to OpenAI, minimal example); simplify flags table.\n- add config.json.example.\n- prompts: enforce ;text; (no spaces) and add ;;text; to remove entire line; tests added.
-rw-r--r--.gitignore18
-rw-r--r--IDEAS.md15
-rw-r--r--README.md63
-rw-r--r--cmd/hexai/main.go131
-rw-r--r--config.json.example17
-rwxr-xr-xhexaibin0 -> 8914340 bytes
-rw-r--r--internal/llm/ollama.go17
-rw-r--r--internal/llm/openai.go84
-rw-r--r--internal/llm/provider.go55
-rw-r--r--internal/lsp/handlers.go106
-rw-r--r--internal/lsp/handlers_test.go57
-rw-r--r--internal/lsp/server.go9
12 files changed, 397 insertions, 175 deletions
diff --git a/.gitignore b/.gitignore
index 237ce54..f73b447 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1 @@
-# Build artifacts and caches
-bin/
-.gocache/
-.gomodcache/
-
-# Local binaries and logs
-hexai
-*.log
-
-# OS/editor files
-.DS_Store
-Thumbs.db
-*.swp
-*.swo
-.idea/
-.vscode/
-
+./hexai
diff --git a/IDEAS.md b/IDEAS.md
index de47d6e..d7ef0d0 100644
--- a/IDEAS.md
+++ b/IDEAS.md
@@ -15,6 +15,7 @@
### New features
+* [ ] Use hexai as a gh copilot... CLI replacemant for command line questions
* [ ] Resolve diagnostics code action feature
* [X] LSP server to be used with the Helix text editor
* [X] Code completion using LLMs
@@ -64,15 +65,5 @@ command = "hexai"
## Prompting
-* Write a new function: ``
-* In-place code add: ``
-
-# Summary:
-# - Refactor and modularize code, add unit tests
-# - Address TODOs and improve diagnostics/code actions
-# - Integrate LLM-based code/text completion, code generation, and inline chat
-# - Support multiple LLMs (Ollama, OpenAI, Claude, Gemini)
-# - Enable code block actions (commenting, refactoring)
-# - Provide Helix LSP server integration and usage notes
-# - Facilitate prompting for code generation and actions:w
-
+* Write a new function: `;Implement a function that adds two numbers;`
+* Replace a whole line: `some other text here ;Implement a function that adds two numbers;`
diff --git a/README.md b/README.md
index 5b6dc9c..40102e4 100644
--- a/README.md
+++ b/README.md
@@ -4,38 +4,36 @@
Hexai, the AI LSP for the Helix editor.
-At the moment this project is only in the proof of concept phase.
+At the moment this project is only in the proof of PoC phase.
## LLM provider
Hexai exposes a simple LLM provider interface. It supports OpenAI and a local
-Ollama server. Provider selection and models are configured via environment
-variables.
+Ollama server. Provider selection and models are configured via a JSON
+configuration file.
### Selecting a provider
-- Set `HEXAI_LLM_PROVIDER` to `openai` or `ollama` to force a provider.
-- If not set, Hexai auto‑detects:
- - Uses OpenAI when `OPENAI_API_KEY` is present.
- - Uses Ollama when any `OLLAMA_*` variables are present.
- - Otherwise, Hexai falls back to a basic, local completion.
+- Set `provider` in the config file to `openai` or `ollama`.
+- If omitted, Hexai defaults to `openai`.
### OpenAI configuration
-- Required: `OPENAI_API_KEY` — your OpenAI API key.
-- Optional: `OPENAI_MODEL` — model name (default: `gpt-4o-mini`).
-- Optional: `OPENAI_BASE_URL` — override the API base (e.g., a compatible endpoint).
+- Required: `OPENAI_API_KEY` — provided via environment variable only.
+- In config file:
+ - `openai_model` — model name (default: `gpt-4o-mini`).
+ - `openai_base_url` — API base (default: `https://api.openai.com/v1`).
### Ollama configuration (local)
-- Optional: `OLLAMA_MODEL` — model name/tag (default: `qwen2.5-coder:latest`).
-- Optional: `OLLAMA_BASE_URL` or `OLLAMA_HOST` — base URL to Ollama
- (default: `http://localhost:11434`).
+- In config file:
+ - `ollama_model` — model name/tag (default: `qwen2.5-coder:latest`).
+ - `ollama_base_url` — base URL to Ollama (default: `http://localhost:11434`).
Notes:
- For Ollama, ensure the model is available locally (e.g., `ollama pull qwen2.5-coder:latest`).
- If you run Ollama in OpenAI‑compatible mode, you may alternatively use the
- OpenAI provider with `OPENAI_BASE_URL` pointing to your local endpoint.
+ OpenAI provider with `openai_base_url` in the config pointing to your local endpoint.
## CLI usage and configuration
@@ -52,12 +50,13 @@ Notes:
### Flags quick reference
-| Flag | Env override | Description |
-|-------------------------|----------------------------|----------------------------------------------------|
-| `-log` | — | Path to log file (optional). |
-| `-version` | — | Print version and exit. |
+| Flag | Description |
+|------------|--------------------------------------|
+| `-log` | Path to log file (optional). |
+| `-version` | Print version and exit. |
-Configuration is via JSON file and environment variables (env has precedence).
+Configuration is via a JSON file only. Environment variables are not used
+except for `OPENAI_API_KEY`.
### JSON config file
@@ -72,18 +71,24 @@ Configuration is via JSON file and environment variables (env has precedence).
"max_context_tokens": 4000,
"log_preview_limit": 100,
"no_disk_io": true,
- "provider": "ollama" // or "openai"
+ "provider": "ollama", // or "openai"
+ // OpenAI-only options
+ "openai_model": "gpt-4.1",
+ "openai_base_url": "https://api.openai.com/v1",
+ // Ollama-only options
+ "ollama_model": "qwen2.5-coder:latest",
+ "ollama_base_url": "http://localhost:11434"
}
```
-### Environment overrides (take precedence)
+Minimal config (defaults to OpenAI):
-- `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`
-- `HEXAI_LOG_PREVIEW_LIMIT`, `HEXAI_NO_DISK_IO`
-- `HEXAI_LLM_PROVIDER` (forces provider)
+```
+{}
+```
+
+Ensure `OPENAI_API_KEY` is set in your environment.
-### Environment quick reference (providers)
+### Environment
-- `HEXAI_LLM_PROVIDER`: `openai` | `ollama` (optional; otherwise auto‑detect).
-- OpenAI: `OPENAI_API_KEY` (required), `OPENAI_MODEL`, `OPENAI_BASE_URL`.
-- Ollama: `OLLAMA_MODEL`, `OLLAMA_BASE_URL` or `OLLAMA_HOST`.
+- Only `OPENAI_API_KEY` is read from the environment when `provider` is `openai`.
diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go
new file mode 100644
index 0000000..7cd5296
--- /dev/null
+++ b/cmd/hexai/main.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "hexai/internal"
+ "hexai/internal/logging"
+ "hexai/internal/lsp"
+ "hexai/internal/llm"
+)
+
+func main() {
+ var logPath string
+ var showVersion bool
+ flag.StringVar(&logPath, "log", "/tmp/hexai.log", "path to log file (optional)")
+ flag.BoolVar(&showVersion, "version", false, "print version and exit")
+ flag.Parse()
+
+ if showVersion {
+ log.Println(internal.Version)
+ return
+ }
+
+ // Configure logging (path flag only)
+ logger := log.New(os.Stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix)
+ if logPath != "" {
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ logger.Fatalf("failed to open log file: %v", err)
+ }
+ defer f.Close()
+ logger.SetOutput(f)
+ }
+ logging.Bind(logger)
+
+ // Load config file
+ cfg := loadConfig(logger)
+
+ // Normalize and apply logging config
+ cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode))
+ if cfg.LogPreviewLimit >= 0 {
+ logging.SetLogPreviewLimit(cfg.LogPreviewLimit)
+ }
+
+ // Build LLM client from config (only OPENAI_API_KEY may come from env)
+ var client llm.Client
+ {
+ llmCfg := llm.Config{
+ Provider: cfg.Provider,
+ OpenAIBaseURL: cfg.OpenAIBaseURL,
+ OpenAIModel: cfg.OpenAIModel,
+ OllamaBaseURL: cfg.OllamaBaseURL,
+ OllamaModel: cfg.OllamaModel,
+ }
+ oaKey := os.Getenv("OPENAI_API_KEY")
+ if c, err := llm.NewFromConfig(llmCfg, oaKey); err != nil {
+ logging.Logf("lsp ", "llm disabled: %v", err)
+ } else {
+ client = c
+ logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel())
+ }
+ }
+
+ server := lsp.NewServer(os.Stdin, os.Stdout, logger, logPath != "", cfg.MaxTokens, cfg.ContextMode, cfg.ContextWindowLines, cfg.MaxContextTokens, cfg.NoDiskIO, client)
+ if err := server.Run(); err != nil {
+ logger.Fatalf("server error: %v", err)
+ }
+}
+
+// appConfig holds user-configurable settings.
+type appConfig struct {
+ MaxTokens int `json:"max_tokens"`
+ ContextMode string `json:"context_mode"`
+ ContextWindowLines int `json:"context_window_lines"`
+ MaxContextTokens int `json:"max_context_tokens"`
+ LogPreviewLimit int `json:"log_preview_limit"`
+ NoDiskIO bool `json:"no_disk_io"`
+ Provider string `json:"provider"`
+ // Provider-specific options
+ OpenAIBaseURL string `json:"openai_base_url"`
+ OpenAIModel string `json:"openai_model"`
+ OllamaBaseURL string `json:"ollama_base_url"`
+ OllamaModel string `json:"ollama_model"`
+}
+
+func loadConfig(logger *log.Logger) appConfig {
+ // Defaults (mirror prior sensible values)
+ cfg := appConfig{
+ MaxTokens: 4000,
+ ContextMode: "always-full",
+ ContextWindowLines: 120,
+ MaxContextTokens: 4000,
+ LogPreviewLimit: 100,
+ NoDiskIO: true,
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return cfg
+ }
+ path := filepath.Join(home, ".config", "hexai", "config.json")
+ f, err := os.Open(path)
+ if err != nil {
+ return cfg
+ }
+ defer f.Close()
+ dec := json.NewDecoder(f)
+ var fileCfg appConfig
+ if err := dec.Decode(&fileCfg); err != nil {
+ logger.Printf("invalid config file %s: %v", path, err)
+ return cfg
+ }
+ // Merge: file overrides defaults when provided
+ if fileCfg.MaxTokens > 0 { cfg.MaxTokens = fileCfg.MaxTokens }
+ if strings.TrimSpace(fileCfg.ContextMode) != "" { cfg.ContextMode = fileCfg.ContextMode }
+ if fileCfg.ContextWindowLines > 0 { cfg.ContextWindowLines = fileCfg.ContextWindowLines }
+ if fileCfg.MaxContextTokens > 0 { cfg.MaxContextTokens = fileCfg.MaxContextTokens }
+ if fileCfg.LogPreviewLimit >= 0 { cfg.LogPreviewLimit = fileCfg.LogPreviewLimit }
+ cfg.NoDiskIO = fileCfg.NoDiskIO
+ if strings.TrimSpace(fileCfg.Provider) != "" { cfg.Provider = fileCfg.Provider }
+ // Provider-specific options
+ if strings.TrimSpace(fileCfg.OpenAIBaseURL) != "" { cfg.OpenAIBaseURL = fileCfg.OpenAIBaseURL }
+ if strings.TrimSpace(fileCfg.OpenAIModel) != "" { cfg.OpenAIModel = fileCfg.OpenAIModel }
+ if strings.TrimSpace(fileCfg.OllamaBaseURL) != "" { cfg.OllamaBaseURL = fileCfg.OllamaBaseURL }
+ if strings.TrimSpace(fileCfg.OllamaModel) != "" { cfg.OllamaModel = fileCfg.OllamaModel }
+ return cfg
+}
diff --git a/config.json.example b/config.json.example
new file mode 100644
index 0000000..4dda9d0
--- /dev/null
+++ b/config.json.example
@@ -0,0 +1,17 @@
+{
+ "max_tokens": 4000,
+ "context_mode": "file-on-new-func",
+ "context_window_lines": 120,
+ "max_context_tokens": 4000,
+ "log_preview_limit": 100,
+ "no_disk_io": true,
+
+ "provider": "openai",
+
+ "openai_model": "gpt-4.1",
+ "openai_base_url": "https://api.openai.com/v1",
+
+ "ollama_model": "qwen2.5-coder:latest",
+ "ollama_base_url": "http://localhost:11434"
+}
+
diff --git a/hexai b/hexai
new file mode 100755
index 0000000..ccf2c06
--- /dev/null
+++ b/hexai
Binary files differ
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
index 495b5c2..db3e06b 100644
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"net/http"
- "os"
"strings"
"time"
@@ -21,22 +20,16 @@ type ollamaClient struct {
defaultModel string
}
-func newOllamaFromEnv() Client {
- // Prefer OLLAMA_BASE_URL, fall back to OLLAMA_HOST, then default.
- base := strings.TrimSpace(os.Getenv("OLLAMA_BASE_URL"))
- if base == "" {
- base = strings.TrimSpace(os.Getenv("OLLAMA_HOST"))
+func newOllama(baseURL, model string) Client {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "http://localhost:11434"
}
- if base == "" {
- base = "http://localhost:11434"
- }
- model := strings.TrimSpace(os.Getenv("OLLAMA_MODEL"))
- if model == "" {
+ if strings.TrimSpace(model) == "" {
model = "qwen2.5-coder:latest"
}
return &ollamaClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
- baseURL: strings.TrimRight(base, "/"),
+ baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: model,
}
}
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index dbcee4d..03e894a 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"net/http"
- "os"
+ "strings"
"time"
"hexai/internal/logging"
@@ -15,27 +15,27 @@ import (
// openAIClient implements Client against OpenAI's Chat Completions API.
type openAIClient struct {
- httpClient *http.Client
- apiKey string
- baseURL string
- defaultModel string
+ httpClient *http.Client
+ apiKey string
+ baseURL string
+ defaultModel string
}
// Colors and base styling are provided by logging.go
-func newOpenAIFromEnv(apiKey string) Client {
- base := os.Getenv("OPENAI_BASE_URL")
- if base == "" {
- base = "https://api.openai.com/v1"
+// newOpenAI constructs an OpenAI client using explicit configuration values.
+// The apiKey may be empty; calls will fail until a valid key is supplied.
+func newOpenAI(baseURL, model, apiKey string) Client {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "https://api.openai.com/v1"
}
- model := os.Getenv("OPENAI_MODEL")
- if model == "" {
- model = "gpt-4o-mini"
+ if strings.TrimSpace(model) == "" {
+ model = "gpt-4.1"
}
return &openAIClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
- baseURL: base,
+ baseURL: baseURL,
defaultModel: model,
}
}
@@ -82,10 +82,10 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
o.Model = c.defaultModel
}
start := time.Now()
- logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
for i, m := range messages {
- // Sending context (cyan)
- logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
+ // Sending context (cyan)
+ logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
}
req := oaChatRequest{Model: o.Model}
req.Messages = make([]oaMessage, len(messages))
@@ -108,7 +108,7 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
return "", err
}
endpoint := c.baseURL + "/chat/completions"
- logging.Logf("llm/openai ", "POST %s", endpoint)
+ logging.Logf("llm/openai ", "POST %s", endpoint)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
c.logf("new request error: %v", err)
@@ -118,34 +118,34 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(httpReq)
- if err != nil {
- logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
+ if err != nil {
+ logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var apiErr oaChatResponse
_ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if apiErr.Error != nil && apiErr.Error.Message != "" {
- logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
- }
- logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
- }
+ if apiErr.Error != nil && apiErr.Error.Message != "" {
+ logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
+ }
+ logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
+ }
var out oaChatResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
- if len(out.Choices) == 0 {
- logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
- return "", errors.New("openai: no choices returned")
- }
- content := out.Choices[0].Message.Content
- // Received context (green)
- logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
- return content, nil
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
+ if len(out.Choices) == 0 {
+ logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
+ return "", errors.New("openai: no choices returned")
+ }
+ content := out.Choices[0].Message.Content
+ // Received context (green)
+ logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
+ return content, nil
}
// small helper to keep return type consistent
@@ -161,5 +161,5 @@ func trimPreview(s string, n int) string {
}
// Provider metadata
-func (c *openAIClient) Name() string { return "openai" }
-func (c *openAIClient) DefaultModel() string { return c.defaultModel }
+func (c *openAIClient) Name() string { return "openai" }
+func (c *openAIClient) DefaultModel() string { return c.defaultModel }
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index f7dad31..c7367ed 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -3,7 +3,6 @@ package llm
import (
"context"
"errors"
- "os"
"strings"
)
@@ -42,34 +41,34 @@ func WithStop(stop ...string) RequestOption {
return func(o *Options) { o.Stop = append([]string{}, stop...) }
}
-// NewDefault returns the default provider using environment configuration.
-// Selection order:
-// 1) HEXAI_LLM_PROVIDER=openai|ollama
-// 2) If OPENAI_API_KEY is set -> OpenAI
-// 3) If any OLLAMA_* vars are set -> Ollama
-func NewDefault() (Client, error) {
- // Explicit provider selection
- if p := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_LLM_PROVIDER"))); p != "" {
- switch p {
- case "openai":
- apiKey := os.Getenv("OPENAI_API_KEY")
- if apiKey == "" {
- return nil, errors.New("OPENAI_API_KEY is not set")
- }
- return newOpenAIFromEnv(apiKey), nil
- case "ollama":
- return newOllamaFromEnv(), nil
- default:
- return nil, errors.New("unknown HEXAI_LLM_PROVIDER: " + p)
- }
- }
+// Config defines provider configuration read from the Hexai config file.
+type Config struct {
+ Provider string
+ // OpenAI options
+ OpenAIBaseURL string
+ OpenAIModel string
+ // Ollama options
+ OllamaBaseURL string
+ OllamaModel string
+}
- // Auto-detect
- if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
- return newOpenAIFromEnv(apiKey), nil
+// NewFromConfig creates an LLM client using only the supplied configuration.
+// The OpenAI API key is supplied separately and may be read from the environment
+// by the caller; other environment-based configuration is not used.
+func NewFromConfig(cfg Config, openAIAPIKey string) (Client, error) {
+ p := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if p == "" {
+ p = "openai"
}
- if os.Getenv("OLLAMA_BASE_URL") != "" || os.Getenv("OLLAMA_HOST") != "" || os.Getenv("OLLAMA_MODEL") != "" {
- return newOllamaFromEnv(), nil
+ switch p {
+ case "openai":
+ if strings.TrimSpace(openAIAPIKey) == "" {
+ return nil, errors.New("missing OPENAI_API_KEY for provider openai")
+ }
+ return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey), nil
+ case "ollama":
+ return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel), nil
+ default:
+ return nil, errors.New("unknown LLM provider: " + p)
}
- return nil, errors.New("no LLM provider configured (set OPENAI_API_KEY or HEXAI_LLM_PROVIDER/OLLAMA_*)")
}
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index d8c13a1..8edfbb6 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -237,36 +237,86 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
// Supported form (inclusive):
-// - ";...;" (optional single space after trailing ';')
+// - ";...;" where there is no space immediately after the first ';'
+// and no space immediately before the last ';'. An optional single space
+// after the trailing ';' is also removed for cleanliness.
// Multiple markers per line are supported.
func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- return nil
- }
- var edits []TextEdit
- for i, line := range d.lines {
- // Scan for ;...; markers
- startSemi := 0
- for startSemi < len(line) {
- j := strings.Index(line[startSemi:], ";")
- if j < 0 {
- break
- }
- j += startSemi
- k := strings.Index(line[j+1:], ";")
- if k < 0 {
- break
- }
- endChar := j + 1 + k + 1 // include trailing ';'
- if endChar < len(line) && line[endChar] == ' ' {
- endChar++
- }
- edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
- startSemi = endChar
- }
- }
- return edits
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return nil
+ }
+ var edits []TextEdit
+ for i, line := range d.lines {
+ // If the line contains a double-semicolon trigger of the form
+ // ";;text;" (no space after the ";;" and no space before the closing ';'),
+ // remove the entire line.
+ removeWholeLine := false
+ {
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";;")
+ if j < 0 { break }
+ j += pos
+ // ensure there's a non-space after the two semicolons
+ if j+2 >= len(line) || line[j+2] == ' ' { pos = j + 2; continue }
+ // find closing ';' after the content
+ k := strings.Index(line[j+2:], ";")
+ if k < 0 { break }
+ closeIdx := j + 2 + k
+ // ensure char before closing ';' is not a space
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue }
+ removeWholeLine = true
+ break
+ }
+ }
+ if removeWholeLine {
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: 0}, End: Position{Line: i, Character: len(line)}}, NewText: ""})
+ continue
+ }
+ // Scan for ;...; markers that have no spaces directly inside the semicolons
+ startSemi := 0
+ for startSemi < len(line) {
+ j := strings.Index(line[startSemi:], ";")
+ if j < 0 {
+ break
+ }
+ j += startSemi
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 {
+ break
+ }
+ // Require no space immediately after the first ';'
+ if j+1 >= len(line) || line[j+1] == ' ' {
+ startSemi = j + 1
+ continue
+ }
+ // Ignore patterns that start with double semicolon here; handled above
+ if line[j+1] == ';' {
+ startSemi = j + 2
+ continue
+ }
+ // Index of the closing ';'
+ closeIdx := j + 1 + k
+ // Require no space immediately before the closing ';'
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ startSemi = closeIdx + 1
+ continue
+ }
+ // Require at least one character between the semicolons
+ if closeIdx-(j+1) < 1 {
+ startSemi = closeIdx + 1
+ continue
+ }
+ endChar := closeIdx + 1 // include trailing ';'
+ if endChar < len(line) && line[endChar] == ' ' {
+ endChar++
+ }
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
+ startSemi = endChar
+ }
+ }
+ return edits
}
func inParamList(current string, cursor int) bool {
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go
index 6ce1e5d..3ebddfb 100644
--- a/internal/lsp/handlers_test.go
+++ b/internal/lsp/handlers_test.go
@@ -147,3 +147,60 @@ no markers here`
t.Fatalf("e0 start not at ;")
}
}
+
+func TestCollectPromptRemovalEdits_SkipSpacedMarkers(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///y.go"
+ // Only ;ok; should be removed; "; spaced ;" must be ignored
+ src := `prefix ;ok; middle ; spaced ; suffix`
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 1 {
+ t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits))
+ }
+ // Ensure the removed region starts at the first ';' of ;ok;
+ line := s.getDocument(uri).lines[0]
+ wantStart := strings.Index(line, ";ok;")
+ if wantStart < 0 {
+ t.Fatalf("test setup: could not find ;ok; in %q", line)
+ }
+ if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart {
+ t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart)
+ }
+}
+
+func TestCollectPromptRemovalEdits_DoubleSemicolonRemovesWholeLine(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///z.go"
+ line0 := "keep"
+ line1 := ";;todo; remove this whole line"
+ line2 := "keep ;ok; end"
+ src := strings.Join([]string{line0, line1, line2}, "\n")
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 2 {
+ t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits))
+ }
+ // Find the whole-line removal for line1
+ found := false
+ for _, e := range edits {
+ if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("did not find whole-line removal edit for line 1")
+ }
+}
+
+func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///w.go"
+ src := "prefix ;; spaced ; suffix"
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 0 {
+ t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits))
+ }
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index ef51636..bfdbca2 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -35,7 +35,7 @@ type Server struct {
startTime time.Time
}
-func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool) *Server {
+func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, maxTokens int, contextMode string, windowLines int, maxContextTokens int, noDiskIO bool, client llm.Client) *Server {
s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
if maxTokens <= 0 {
maxTokens = 500
@@ -55,12 +55,7 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool, ma
s.maxContextTokens = maxContextTokens
s.noDiskIO = noDiskIO
s.startTime = time.Now()
- if c, err := llm.NewDefault(); err != nil {
- logging.Logf("lsp ", "llm disabled: %v", err)
- } else {
- s.llmClient = c
- logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel())
- }
+ s.llmClient = client
return s
}