Compare commits

..

5 Commits

Author SHA1 Message Date
Dax Raad 15a1a43703 sync
release / goreleaser (push) Has been cancelled
2025-04-21 20:31:47 -04:00
Dax Raad 82e92bab78 tag
release / goreleaser (push) Has been cancelled
2025-04-21 20:16:36 -04:00
Dax Raad 946ae15d9d ci test 2025-04-21 20:13:20 -04:00
Dax Raad 54edc4ec31 ci test
release / goreleaser (push) Has been cancelled
2025-04-21 20:07:54 -04:00
Dax Raad 60d159afc1 sync 2025-04-21 20:01:44 -04:00
145 changed files with 5467 additions and 14704 deletions
+1 -4
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- dev
- opencode
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -25,11 +25,8 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: ">=1.23.2"
cache: true
cache-dependency-path: go.sum
- run: go mod download
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
+1 -4
View File
@@ -25,16 +25,13 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: ">=1.23.2"
cache: true
cache-dependency-path: go.sum
- run: go mod download
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
+11 -22
View File
@@ -8,15 +8,11 @@ builds:
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}}
main: ./main.go
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of uname.
name_template: >-
opencode-
{{- if eq .Os "darwin" }}mac-
@@ -26,6 +22,7 @@ archives:
{{- else if eq .Arch "#86" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
@@ -34,27 +31,19 @@ checksum:
snapshot:
name_template: "0.0.0-{{ .Timestamp }}"
aurs:
- name: opencode
homepage: "https://github.com/sst/opencode"
description: "terminal based agent that can build anything"
maintainers:
- "dax"
- "adam"
license: "MIT"
- homepage: "https://github.com/opencode-ai/opencode"
description: "Deploy anything"
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git"
provides:
- opencode
conflicts:
- opencode
git_url: "ssh://aur@aur.archlinux.org/opencode.git"
license: "MIT"
package: |-
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
brews:
- repository:
owner: sst
name: homebrew-tap
# brews:
# - repository:
# owner: opencode-ai
# name: homebrew-tap
nfpms:
- maintainer: kujtimiihoxha
- maintainer: opencode
description: terminal based agent that can build anything
formats:
- deb
+6 -1
View File
@@ -1,3 +1,8 @@
{
"$schema": "./opencode-schema.json"
"$schema": "./opencode-schema.json",
"lsp": {
"gopls": {
"command": "gopls"
}
}
}
-24
View File
@@ -1,24 +0,0 @@
# OpenCode Development Context
## Build Commands
- Build: `go build`
- Run: `go run main.go`
- Test: `go test ./...`
- Test single package: `go test ./internal/package/...`
- Test single test: `go test ./internal/package -run TestName`
- Verbose test: `go test -v ./...`
- Coverage: `go test -cover ./...`
- Lint: `go vet ./...`
- Format: `go fmt ./...`
- Build snapshot: `./scripts/snapshot`
## Code Style
- Use Go 1.24+ features
- Follow standard Go formatting (gofmt)
- Use table-driven tests with t.Parallel() when possible
- Error handling: check errors immediately, return early
- Naming: CamelCase for exported, camelCase for unexported
- Imports: standard library first, then external, then internal
- Use context.Context for cancellation and timeouts
- Prefer interfaces for dependencies to enable testing
- Use testify for assertions in tests
+21 -222
View File
@@ -1,4 +1,4 @@
# OpenCode
# OpenCode
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
@@ -11,7 +11,7 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
## Features
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, and Groq
- **Session Management**: Save and manage multiple conversation sessions
- **Tool Integration**: AI can execute commands, search files, and modify code
- **Vim-like Editor**: Integrated editor with text input capabilities
@@ -22,36 +22,9 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
## Installation
### Using the Install Script
```bash
# Install the latest version
curl -fsSL https://opencode.ai/install | bash
# Install a specific version
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
```
### Using Homebrew (macOS and Linux)
```bash
brew install sst/tap/opencode
```
### Using AUR (Arch Linux)
```bash
# Using yay
yay -S opencode-bin
# Using paru
paru -S opencode-bin
```
### Using Go
```bash
go install github.com/sst/opencode@latest
# Coming soon
go install github.com/kujtimiihoxha/opencode@latest
```
## Configuration
@@ -66,18 +39,15 @@ OpenCode looks for configuration in the following locations:
You can configure OpenCode using environment variables:
| Environment Variable | Purpose |
| -------------------------- | ------------------------------------------------------ |
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
| `GROQ_API_KEY` | For Groq models |
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
| `AWS_REGION` | For AWS Bedrock (Claude) |
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
| Environment Variable | Purpose |
| ----------------------- | ------------------------ |
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
| `GROQ_API_KEY` | For Groq models |
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
| `AWS_REGION` | For AWS Bedrock (Claude) |
### Configuration File Structure
@@ -94,18 +64,10 @@ You can configure OpenCode using environment variables:
"anthropic": {
"apiKey": "your-api-key",
"disabled": false
},
"groq": {
"apiKey": "your-api-key",
"disabled": false
},
"openrouter": {
"apiKey": "your-api-key",
"disabled": false
}
},
"agents": {
"primary": {
"coder": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
@@ -169,23 +131,6 @@ OpenCode supports a variety of AI models from different providers:
- Claude 3.7 Sonnet
### Groq
- Llama 4 Maverick (17b-128e-instruct)
- Llama 4 Scout (17b-16e-instruct)
- QWEN QWQ-32b
- Deepseek R1 distill Llama 70b
- Llama 3.3 70b Versatile
### Azure OpenAI
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
- GPT-4.5 Preview
- GPT-4o family (gpt-4o, gpt-4o-mini)
- O1 family (o1, o1-mini)
- O3 family (o3, o3-mini)
- O4 Mini
## Usage
```bash
@@ -219,7 +164,6 @@ opencode -c /path/to/project
| `Ctrl+L` | View logs |
| `Ctrl+A` | Switch session |
| `Ctrl+K` | Command dialog |
| `Ctrl+O` | Toggle model selection dialog |
| `Esc` | Close current overlay/dialog or return to previous mode |
### Chat Page Shortcuts
@@ -249,16 +193,6 @@ opencode -c /path/to/project
| `Enter` | Select session |
| `Esc` | Close dialog |
### Model Dialog Shortcuts
| Shortcut | Action |
| ---------- | ----------------- |
| `↑` or `k` | Move up |
| `↓` or `j` | Move down |
| `←` or `h` | Previous provider |
| `→` or `l` | Next provider |
| `Esc` | Close dialog |
### Permission Dialog Shortcuts
| Shortcut | Action |
@@ -295,81 +229,12 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
### Other Tools
| Tool | Description | Parameters |
| ------- | ------------------------------- | ----------------------------------------------------------- |
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
## Theming
OpenCode supports multiple themes for customizing the appearance of the terminal interface.
### Available Themes
The following predefined themes are available:
- `opencode` (default)
- `catppuccin`
- `dracula`
- `flexoki`
- `gruvbox`
- `monokai`
- `onedark`
- `tokyonight`
- `tron`
- `custom` (user-defined)
### Setting a Theme
You can set a theme in your `.opencode.json` configuration file:
```json
{
"tui": {
"theme": "monokai"
}
}
```
### Custom Themes
You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
```json
{
"tui": {
"theme": "custom",
"customTheme": {
"primary": "#ffcc00",
"secondary": "#00ccff",
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
"error": "#ff0000"
}
}
}
```
#### Color Definition Formats
Custom theme colors support two formats:
1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
#### Available Color Keys
You can define any of the following color keys in your `customTheme`:
- Base colors: `primary`, `secondary`, `accent`
- Status colors: `error`, `warning`, `success`, `info`
- Text colors: `text`, `textMuted`, `textEmphasized`
- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
- Border colors: `borderNormal`, `borderFocused`, `borderDim`
- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
| Tool | Description | Parameters |
| ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- |
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
## Architecture
@@ -386,72 +251,6 @@ OpenCode is built with a modular architecture:
- **internal/session**: Session management
- **internal/lsp**: Language Server Protocol integration
## Custom Commands
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
### Creating Custom Commands
Custom commands are predefined prompts stored as Markdown files in one of three locations:
1. **User Commands** (prefixed with `user:`):
```
$XDG_CONFIG_HOME/opencode/commands/
```
(typically `~/.config/opencode/commands/` on Linux/macOS)
or
```
$HOME/.opencode/commands/
```
2. **Project Commands** (prefixed with `project:`):
```
<PROJECT DIR>/.opencode/commands/
```
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
```markdown
RUN git ls-files
READ README.md
```
This creates a command called `user:prime-context`.
### Command Arguments
You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
```markdown
RUN git show $ARGUMENTS
```
When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
### Organizing Commands
You can organize commands in subdirectories:
```
~/.config/opencode/commands/git/commit.md
```
This creates a command with ID `user:git:commit`.
### Using Custom Commands
1. Press `Ctrl+K` to open the command dialog
2. Select your custom command (prefixed with either `user:` or `project:`)
3. Press Enter to execute the command
The content of the command file will be sent as a message to the AI assistant.
## MCP (Model Context Protocol)
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
@@ -542,7 +341,7 @@ While the LSP client implementation supports the full LSP protocol (including co
```bash
# Clone the repository
git clone https://github.com/sst/opencode.git
git clone https://github.com/kujtimiihoxha/opencode.git
cd opencode
# Build
+28 -75
View File
@@ -7,42 +7,18 @@ import (
"sync"
"time"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
"github.com/kujtimiihoxha/opencode/internal/app"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/db"
"github.com/kujtimiihoxha/opencode/internal/llm/agent"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
"github.com/kujtimiihoxha/opencode/internal/tui"
zone "github.com/lrstanley/bubblezone"
"github.com/spf13/cobra"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/discovery"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/version"
)
type SessionIDHandler struct {
slog.Handler
app *app.App
}
func (h *SessionIDHandler) Handle(ctx context.Context, r slog.Record) error {
if h.app != nil {
sessionID := h.app.CurrentSession.ID
if sessionID != "" {
r.AddAttrs(slog.String("session_id", sessionID))
}
}
return h.Handler.Handle(ctx, r)
}
func (h *SessionIDHandler) WithApp(app *app.App) *SessionIDHandler {
h.app = app
return h
}
var rootCmd = &cobra.Command{
Use: "OpenCode",
Short: "A terminal AI assistant for software development",
@@ -55,17 +31,6 @@ to assist developers in writing, debugging, and understanding code directly from
cmd.Help()
return nil
}
if cmd.Flag("version").Changed {
fmt.Println(version.Version)
return nil
}
// Setup logging
lvl := new(slog.LevelVar)
textHandler := slog.NewTextHandler(logging.NewSlogWriter(), &slog.HandlerOptions{Level: lvl})
sessionAwareHandler := &SessionIDHandler{Handler: textHandler}
logger := slog.New(sessionAwareHandler)
slog.SetDefault(logger)
// Load the config
debug, _ := cmd.Flags().GetBool("debug")
@@ -83,17 +48,11 @@ to assist developers in writing, debugging, and understanding code directly from
}
cwd = c
}
_, err := config.Load(cwd, debug, lvl)
_, err := config.Load(cwd, debug)
if err != nil {
return err
}
// Run LSP auto-discovery
if err := discovery.IntegrateLSPServers(cwd); err != nil {
slog.Warn("Failed to auto-discover LSP servers", "error", err)
// Continue anyway, this is not a fatal error
}
// Connect DB, this will also run migrations
conn, err := db.Connect()
if err != nil {
@@ -106,16 +65,16 @@ to assist developers in writing, debugging, and understanding code directly from
app, err := app.New(ctx, conn)
if err != nil {
slog.Error("Failed to create app", "error", err)
logging.Error("Failed to create app: %v", err)
return err
}
sessionAwareHandler.WithApp(app)
// Set up the TUI
zone.NewGlobal()
program := tea.NewProgram(
tui.New(app),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
// Initialize MCP tools in the background
@@ -139,11 +98,11 @@ to assist developers in writing, debugging, and understanding code directly from
for {
select {
case <-tuiCtx.Done():
slog.Info("TUI message handler shutting down")
logging.Info("TUI message handler shutting down")
return
case msg, ok := <-ch:
if !ok {
slog.Info("TUI message channel closed")
logging.Info("TUI message channel closed")
return
}
program.Send(msg)
@@ -153,19 +112,19 @@ to assist developers in writing, debugging, and understanding code directly from
// Cleanup function for when the program exits
cleanup := func() {
// Shutdown the app
app.Shutdown()
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app.Shutdown()
// Then cancel TUI message handler
tuiCancel()
// Wait for TUI message handler to finish
tuiWg.Wait()
slog.Info("All goroutines cleaned up")
logging.Info("All goroutines cleaned up")
}
// Run the TUI
@@ -173,18 +132,18 @@ to assist developers in writing, debugging, and understanding code directly from
cleanup()
if err != nil {
slog.Error("TUI error", "error", err)
logging.Error("TUI error: %v", err)
return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)
logging.Info("TUI exited with result: %v", result)
return nil
},
}
// attemptTUIRecovery tries to recover the TUI after a panic
func attemptTUIRecovery(program *tea.Program) {
slog.Info("Attempting to recover TUI after panic")
logging.Info("Attempting to recover TUI after panic")
// We could try to restart the TUI or gracefully exit
// For now, we'll just quit the program to avoid further issues
@@ -201,7 +160,7 @@ func initMCPTools(ctx context.Context, app *app.App) {
// Set this up once with proper error handling
agent.GetMcpTools(ctxWithTimeout, app.Permissions)
slog.Info("MCP message handling goroutine exiting")
logging.Info("MCP message handling goroutine exiting")
}()
}
@@ -218,16 +177,12 @@ func setupSubscriber[T any](
defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
subCh := subscriber(ctx)
if subCh == nil {
slog.Warn("subscription channel is nil", "name", name)
return
}
for {
select {
case event, ok := <-subCh:
if !ok {
slog.Info("subscription channel closed", "name", name)
logging.Info("subscription channel closed", "name", name)
return
}
@@ -236,13 +191,13 @@ func setupSubscriber[T any](
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
slog.Warn("message dropped due to slow consumer", "name", name)
logging.Warn("message dropped due to slow consumer", "name", name)
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
logging.Info("subscription cancelled", "name", name)
return
}
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
logging.Info("subscription cancelled", "name", name)
return
}
}
@@ -255,14 +210,13 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch)
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
cleanupFunc := func() {
slog.Info("Cancelling all subscriptions")
logging.Info("Cancelling all subscriptions")
cancel() // Signal all goroutines to stop
waitCh := make(chan struct{})
@@ -274,10 +228,10 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
select {
case <-waitCh:
slog.Info("All subscription goroutines completed successfully")
logging.Info("All subscription goroutines completed successfully")
close(ch) // Only close after all writers are confirmed done
case <-time.After(5 * time.Second):
slog.Warn("Timed out waiting for some subscription goroutines to complete")
logging.Warn("Timed out waiting for some subscription goroutines to complete")
close(ch)
}
}
@@ -293,7 +247,6 @@ func Execute() {
func init() {
rootCmd.Flags().BoolP("help", "h", false, "Help")
rootCmd.Flags().BoolP("version", "v", false, "Version")
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
}
+2 -3
View File
@@ -46,7 +46,7 @@ Here's an example configuration that conforms to the schema:
}
},
"agents": {
"primary": {
"coder": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": "medium"
@@ -61,5 +61,4 @@ Here's an example configuration that conforms to the schema:
}
}
}
```
```
+4 -77
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"os"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
)
// JSONSchemaType represents a JSON Schema type
@@ -77,78 +77,6 @@ func generateSchema() map[string]any {
"default": false,
}
schema["properties"].(map[string]any)["contextPaths"] = map[string]any{
"type": "array",
"description": "Context paths for the application",
"items": map[string]any{
"type": "string",
},
"default": []string{
".github/copilot-instructions.md",
".cursorrules",
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
"opencode.md",
"opencode.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
},
}
schema["properties"].(map[string]any)["tui"] = map[string]any{
"type": "object",
"description": "Terminal User Interface configuration",
"properties": map[string]any{
"theme": map[string]any{
"type": "string",
"description": "TUI theme name",
"default": "opencode",
"enum": []string{
"opencode",
"catppuccin",
"dracula",
"flexoki",
"gruvbox",
"monokai",
"onedark",
"tokyonight",
"tron",
"custom",
},
},
"customTheme": map[string]any{
"type": "object",
"description": "Custom theme color definitions",
"additionalProperties": map[string]any{
"oneOf": []map[string]any{
{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
{
"type": "object",
"properties": map[string]any{
"dark": map[string]any{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
"light": map[string]any{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
},
"required": []string{"dark", "light"},
"additionalProperties": false,
},
},
},
},
},
}
// Add MCP servers
schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
"type": "object",
@@ -224,9 +152,7 @@ func generateSchema() map[string]any {
string(models.ProviderOpenAI),
string(models.ProviderGemini),
string(models.ProviderGROQ),
string(models.ProviderOpenRouter),
string(models.ProviderBedrock),
string(models.ProviderAzure),
}
providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
@@ -274,7 +200,7 @@ func generateSchema() map[string]any {
// Add specific agent properties
agentProperties := map[string]any{}
knownAgents := []string{
string(config.AgentPrimary),
string(config.AgentCoder),
string(config.AgentTask),
string(config.AgentTitle),
}
@@ -333,3 +259,4 @@ func generateSchema() map[string]any {
return schema
}
+46 -31
View File
@@ -1,45 +1,53 @@
module github.com/sst/opencode
module github.com/kujtimiihoxha/opencode
go 1.24.0
toolchain go1.24.2
require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/PuerkitoBio/goquery v1.9.2
github.com/alecthomas/chroma/v2 v2.15.0
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
github.com/aymanbagabas/go-udiff v0.2.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.8.0
github.com/fsnotify/fsnotify v1.8.0
github.com/go-git/go-git/v5 v5.15.0
github.com/go-logfmt/logfmt v0.6.0
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/generative-ai-go v0.19.0
github.com/google/uuid v1.6.0
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mark3labs/mcp-go v0.17.0
github.com/mattn/go-runewidth v0.0.16
github.com/mattn/go-sqlite3 v1.14.24
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/ncruces/go-sqlite3 v0.25.0
github.com/openai/openai-go v0.1.0-beta.2
github.com/pressly/goose/v3 v3.24.2
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
github.com/stretchr/testify v1.10.0
google.golang.org/api v0.215.0
)
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
@@ -60,69 +68,76 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genai v1.3.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/grpc v1.67.3 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+109 -72
View File
@@ -1,21 +1,26 @@
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -26,8 +31,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
@@ -76,6 +85,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
@@ -84,18 +95,26 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -104,6 +123,16 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.15.0 h1:f5Qn0W0F7ry1iN0ZwIU5m/n7/BKB4hiZfc+zlZx7ly0=
github.com/go-git/go-git/v5 v5.15.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -113,10 +142,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
@@ -129,12 +160,19 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -142,8 +180,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -157,10 +195,12 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -169,25 +209,19 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -203,8 +237,9 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -218,14 +253,13 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -236,6 +270,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -246,38 +282,35 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
@@ -287,18 +320,23 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -320,6 +358,7 @@ golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@@ -327,33 +366,31 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genai v1.3.0 h1:tXhPJF30skOjnnDY7ZnjK3q7IKy4PuAlEA0fk7uEaEI=
google.golang.org/genai v1.3.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8=
google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP=opencode
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
ORANGE='\033[38;2;255;140;0m'
NC='\033[0m' # No Color
requested_version=${VERSION:-}
os=$(uname -s | tr '[:upper:]' '[:lower:]')
if [[ "$os" == "darwin" ]]; then
os="mac"
fi
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
filename="$APP-$os-$arch.tar.gz"
case "$filename" in
*"-linux-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
;;
*"-mac-"*)
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
;;
*)
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
if [[ $? -ne 0 ]]; then
echo "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
fi
print_message() {
local level=$1
local message=$2
local color=""
case $level in
info) color="${GREEN}" ;;
warning) color="${YELLOW}" ;;
error) color="${RED}" ;;
esac
echo -e "${color}${message}${NC}"
}
check_version() {
if command -v opencode >/dev/null 2>&1; then
opencode_path=$(which opencode)
## TODO: check if version is installed
# installed_version=$(opencode version)
installed_version="0.0.1"
installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "Installed version: ${YELLOW}$installed_version."
else
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
exit 0
fi
fi
}
download_and_install() {
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
mkdir -p opencodetmp && cd opencodetmp
curl -# -L $url | tar xz
mv opencode $INSTALL_DIR
cd .. && rm -rf opencodetmp
}
check_version
download_and_install
add_to_path() {
local config_file=$1
local command=$2
if [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
else
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " $command"
fi
}
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
current_shell=$(basename "$SHELL")
case $current_shell in
fish)
config_files="$HOME/.config/fish/config.fish"
;;
zsh)
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
;;
bash)
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
;;
ash)
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
;;
sh)
config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
;;
*)
# Default case if none of the above matches
config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
;;
esac
config_file=""
for file in $config_files; do
if [[ -f $file ]]; then
config_file=$file
break
fi
done
if [[ -z $config_file ]]; then
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
exit 1
fi
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
case $current_shell in
fish)
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
;;
zsh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
bash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
ash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
sh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
esac
fi
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> $GITHUB_PATH
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi
+29 -82
View File
@@ -7,30 +7,24 @@ import (
"sync"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/theme"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/db"
"github.com/kujtimiihoxha/opencode/internal/history"
"github.com/kujtimiihoxha/opencode/internal/llm/agent"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/message"
"github.com/kujtimiihoxha/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/session"
)
type App struct {
CurrentSession *session.Session
Logs logging.Service
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
Status status.Service
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
PrimaryAgent agent.Service
CoderAgent agent.Service
LSPClients map[string]*lsp.Client
@@ -42,59 +36,28 @@ type App struct {
}
func New(ctx context.Context, conn *sql.DB) (*App, error) {
err := logging.InitService(conn)
if err != nil {
slog.Error("Failed to initialize logging service", "error", err)
return nil, err
}
err = session.InitService(conn)
if err != nil {
slog.Error("Failed to initialize session service", "error", err)
return nil, err
}
err = message.InitService(conn)
if err != nil {
slog.Error("Failed to initialize message service", "error", err)
return nil, err
}
err = history.InitService(conn)
if err != nil {
slog.Error("Failed to initialize history service", "error", err)
return nil, err
}
err = permission.InitService()
if err != nil {
slog.Error("Failed to initialize permission service", "error", err)
return nil, err
}
err = status.InitService()
if err != nil {
slog.Error("Failed to initialize status service", "error", err)
return nil, err
}
q := db.New(conn)
sessions := session.NewService(q)
messages := message.NewService(q)
files := history.NewService(q, conn)
app := &App{
CurrentSession: &session.Session{},
Logs: logging.GetService(),
Sessions: session.GetService(),
Messages: message.GetService(),
History: history.GetService(),
Permissions: permission.GetService(),
Status: status.GetService(),
LSPClients: make(map[string]*lsp.Client),
Sessions: sessions,
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(),
LSPClients: make(map[string]*lsp.Client),
}
// Initialize theme based on configuration
app.initTheme()
// Initialize LSP clients in the background
go app.initLSPClients(ctx)
app.PrimaryAgent, err = agent.NewAgent(
config.AgentPrimary,
var err error
app.CoderAgent, err = agent.NewAgent(
config.AgentCoder,
app.Sessions,
app.Messages,
agent.PrimaryAgentTools(
agent.CoderAgentTools(
app.Permissions,
app.Sessions,
app.Messages,
@@ -103,29 +66,13 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
),
)
if err != nil {
slog.Error("Failed to create primary agent", "error", err)
logging.Error("Failed to create coder agent", err)
return nil, err
}
return app, nil
}
// initTheme sets the application theme based on the configuration
func (app *App) initTheme() {
cfg := config.Get()
if cfg == nil || cfg.TUI.Theme == "" {
return // Use default theme
}
// Try to set the theme from config
err := theme.SetTheme(cfg.TUI.Theme)
if err != nil {
slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
} else {
slog.Debug("Set theme from config", "theme", cfg.TUI.Theme)
}
}
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {
// Cancel all watcher goroutines
@@ -145,7 +92,7 @@ func (app *App) Shutdown() {
for name, client := range clients {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := client.Shutdown(shutdownCtx); err != nil {
slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
logging.Error("Failed to shutdown LSP client", "name", name, "error", err)
}
cancel()
}
+19 -27
View File
@@ -4,12 +4,10 @@ import (
"context"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/watcher"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/lsp/watcher"
)
func (app *App) initLSPClients(ctx context.Context) {
@@ -20,29 +18,29 @@ func (app *App) initLSPClients(ctx context.Context) {
// Start each client initialization in its own goroutine
go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
}
slog.Info("LSP clients initialization started in background")
logging.Info("LSP clients initialization started in background")
}
// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
// Create a specific context for initialization with a timeout
slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
// Create the LSP client
lspClient, err := lsp.NewClient(ctx, command, args...)
if err != nil {
slog.Error("Failed to create LSP client for", name, err)
logging.Error("Failed to create LSP client for", name, err)
return
}
// Create a longer timeout for initialization (some servers take time to start)
initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Initialize with the initialization context
_, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
if err != nil {
slog.Error("Initialize failed", "name", name, "error", err)
logging.Error("Initialize failed", "name", name, "error", err)
// Clean up the client to prevent resource leaks
lspClient.Close()
return
@@ -50,22 +48,22 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
// Wait for the server to be ready
if err := lspClient.WaitForServerReady(initCtx); err != nil {
slog.Error("Server failed to become ready", "name", name, "error", err)
logging.Error("Server failed to become ready", "name", name, "error", err)
// We'll continue anyway, as some functionality might still work
lspClient.SetServerState(lsp.StateError)
} else {
slog.Info("LSP server is ready", "name", name)
logging.Info("LSP server is ready", "name", name)
lspClient.SetServerState(lsp.StateReady)
}
slog.Info("LSP client initialized", "name", name)
logging.Info("LSP client initialized", "name", name)
// Create a child context that can be canceled when the app is shutting down
watchCtx, cancelFunc := context.WithCancel(ctx)
// Create a context with the server name for better identification
watchCtx = context.WithValue(watchCtx, "serverName", name)
// Create the workspace watcher
workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
@@ -94,7 +92,7 @@ func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceW
})
workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
slog.Info("Workspace watcher stopped", "client", name)
logging.Info("Workspace watcher stopped", "client", name)
}
// restartLSPClient attempts to restart a crashed or failed LSP client
@@ -103,7 +101,7 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
cfg := config.Get()
clientConfig, exists := cfg.LSP[name]
if !exists {
slog.Error("Cannot restart client, configuration not found", "client", name)
logging.Error("Cannot restart client, configuration not found", "client", name)
return
}
@@ -120,15 +118,9 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = oldClient.Shutdown(shutdownCtx)
cancel()
// Ensure we close the client to free resources
_ = oldClient.Close()
}
// Wait a moment before restarting to avoid rapid restart cycles
time.Sleep(1 * time.Second)
// Create a new client using the shared function
app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
slog.Info("Successfully restarted LSP client", "client", name)
logging.Info("Successfully restarted LSP client", "client", name)
}
+198 -408
View File
@@ -2,16 +2,14 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/spf13/viper"
"github.com/sst/opencode/internal/llm/models"
)
// MCPType defines the type of MCP (Model Control Protocol) server.
@@ -36,9 +34,9 @@ type MCPServer struct {
type AgentName string
const (
AgentPrimary AgentName = "primary"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
AgentCoder AgentName = "coder"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
)
// Agent defines configuration for different LLM models and their token limits.
@@ -67,24 +65,16 @@ type LSPConfig struct {
Options any `json:"options"`
}
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
Theme string `json:"theme,omitempty"`
CustomTheme map[string]any `json:"customTheme,omitempty"`
}
// Config is the main configuration structure for the application.
type Config struct {
Data Data `json:"data"`
WorkingDir string `json:"wd,omitempty"`
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"`
Agents map[AgentName]Agent `json:"agents"`
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"`
Data Data `json:"data"`
WorkingDir string `json:"wd,omitempty"`
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"`
Agents map[AgentName]Agent `json:"agents"`
Debug bool `json:"debug,omitempty"`
DebugLSP bool `json:"debugLSP,omitempty"`
}
// Application constants
@@ -92,33 +82,15 @@ const (
defaultDataDirectory = ".opencode"
defaultLogLevel = "info"
appName = "opencode"
MaxTokensFallbackDefault = 4096
)
var defaultContextPaths = []string{
".github/copilot-instructions.md",
".cursorrules",
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
"CONTEXT.md",
"CONTEXT.local.md",
"opencode.md",
"opencode.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
}
// Global configuration instance
var cfg *Config
// Load initializes the configuration from environment variables and config files.
// If debug is true, debug mode is enabled and log level is set to debug.
// It returns an error if configuration loading fails.
func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
func Load(workingDir string, debug bool) (*Config, error) {
if cfg != nil {
return cfg, nil
}
@@ -132,6 +104,7 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
configureViper()
setDefaults(debug)
setProviderDefaults()
// Read global config
if err := readConfig(viper.ReadInConfig()); err != nil {
@@ -141,21 +114,45 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
// Load and merge local config
mergeLocalConfig(workingDir)
setProviderDefaults()
// Apply configuration to the struct
if err := viper.Unmarshal(cfg); err != nil {
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
}
applyDefaultValues()
defaultLevel := slog.LevelInfo
if cfg.Debug {
defaultLevel = slog.LevelDebug
}
lvl.Set(defaultLevel)
slog.SetLogLoggerLevel(defaultLevel)
if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
// if file does not exist create it
if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
return cfg, fmt.Errorf("failed to create directory: %w", err)
}
if _, err := os.Create(loggingFile); err != nil {
return cfg, fmt.Errorf("failed to create log file: %w", err)
}
}
sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
return cfg, fmt.Errorf("failed to open log file: %w", err)
}
// Configure logger
logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
Level: defaultLevel,
}))
slog.SetDefault(logger)
} else {
// Configure logger
logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
Level: defaultLevel,
}))
slog.SetDefault(logger)
}
// Validate configuration
if err := Validate(); err != nil {
@@ -180,7 +177,6 @@ func configureViper() {
viper.SetConfigType("json")
viper.AddConfigPath("$HOME")
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
viper.SetEnvPrefix(strings.ToUpper(appName))
viper.AutomaticEnv()
}
@@ -188,8 +184,6 @@ func configureViper() {
// setDefaults configures default values for configuration options.
func setDefaults(debug bool) {
viper.SetDefault("data.directory", defaultDataDirectory)
viper.SetDefault("contextPaths", defaultContextPaths)
viper.SetDefault("tui.theme", "opencode")
if debug {
viper.SetDefault("debug", true)
@@ -200,104 +194,50 @@ func setDefaults(debug bool) {
}
}
// setProviderDefaults configures LLM provider defaults based on provider provided by
// environment variables and configuration file.
// setProviderDefaults configures LLM provider defaults based on environment variables.
// the default model priority is:
// 1. Anthropic
// 2. OpenAI
// 3. Google Gemini
// 4. AWS Bedrock
func setProviderDefaults() {
// Set all API keys we can find in the environment
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
viper.SetDefault("providers.anthropic.apiKey", apiKey)
}
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.openai.apiKey", apiKey)
}
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.gemini.apiKey", apiKey)
}
// Groq configuration
if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
viper.SetDefault("providers.groq.apiKey", apiKey)
}
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
viper.SetDefault("providers.openrouter.apiKey", apiKey)
}
if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.xai.apiKey", apiKey)
}
if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
// api-key may be empty when using Entra ID credentials that's okay
viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
}
// Use this order to set the default models
// 1. Anthropic
// 2. OpenAI
// 3. Google Gemini
// 4. Groq
// 5. OpenRouter
// 6. AWS Bedrock
// 7. Azure
// Anthropic configuration
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
return
}
// OpenAI configuration
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini)
return
viper.SetDefault("agents.coder.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq)
}
// Google Gemini configuration
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Gemini25)
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.gemini.apiKey", apiKey)
viper.SetDefault("agents.coder.model", models.Gemini25)
viper.SetDefault("agents.task.model", models.Gemini25Flash)
viper.SetDefault("agents.title.model", models.Gemini25Flash)
return
}
// Groq configuration
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq)
return
// OpenAI configuration
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
viper.SetDefault("providers.openai.apiKey", apiKey)
viper.SetDefault("agents.coder.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini)
}
// OpenRouter configuration
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
return
// Anthropic configuration
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
viper.SetDefault("providers.anthropic.apiKey", apiKey)
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
}
// XAI configuration
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.XAIGrok3Beta)
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
return
}
// AWS Bedrock configuration
if hasAWSCredentials() {
viper.SetDefault("agents.primary.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
return
}
// Azure OpenAI configuration
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
viper.SetDefault("agents.primary.model", models.AzureGPT41)
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
return
}
}
@@ -365,138 +305,8 @@ func applyDefaultValues() {
}
}
// It validates model IDs and providers, ensuring they are supported.
func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Check if model exists
model, modelExists := models.SupportedModels[agent.Model]
if !modelExists {
slog.Warn("unsupported model configured, reverting to default",
"agent", name,
"configured_model", agent.Model)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
return nil
}
// Check if provider for the model is configured
provider := model.Provider
providerCfg, providerExists := cfg.Providers[provider]
if !providerExists {
// Provider not configured, check if we have environment variables
apiKey := getProviderAPIKey(provider)
if apiKey == "" {
slog.Warn("provider not configured for model, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
} else {
// Add provider with API key from environment
cfg.Providers[provider] = Provider{
APIKey: apiKey,
}
slog.Info("added provider from environment", "provider", provider)
}
} else if providerCfg.Disabled || providerCfg.APIKey == "" {
// Provider is disabled or has no API key
slog.Warn("provider is disabled or has no API key, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
}
// Validate max tokens
if agent.MaxTokens <= 0 {
slog.Warn("invalid max tokens, setting to default",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens)
// Update the agent with default max tokens
updatedAgent := cfg.Agents[name]
if model.DefaultMaxTokens > 0 {
updatedAgent.MaxTokens = model.DefaultMaxTokens
} else {
updatedAgent.MaxTokens = MaxTokensFallbackDefault
}
cfg.Agents[name] = updatedAgent
} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
// Ensure max tokens doesn't exceed half the context window (reasonable limit)
slog.Warn("max tokens exceeds half the context window, adjusting",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens,
"context_window", model.ContextWindow)
// Update the agent with adjusted max tokens
updatedAgent := cfg.Agents[name]
updatedAgent.MaxTokens = model.ContextWindow / 2
cfg.Agents[name] = updatedAgent
}
// Validate reasoning effort for models that support reasoning
if model.CanReason && provider == models.ProviderOpenAI {
if agent.ReasoningEffort == "" {
// Set default reasoning effort for models that support it
slog.Info("setting default reasoning effort for model that supports reasoning",
"agent", name,
"model", agent.Model)
// Update the agent with default reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = "medium"
cfg.Agents[name] = updatedAgent
} else {
// Check if reasoning effort is valid (low, medium, high)
effort := strings.ToLower(agent.ReasoningEffort)
if effort != "low" && effort != "medium" && effort != "high" {
slog.Warn("invalid reasoning effort, setting to medium",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
// Update the agent with valid reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = "medium"
cfg.Agents[name] = updatedAgent
}
}
} else if !model.CanReason && agent.ReasoningEffort != "" {
// Model doesn't support reasoning but reasoning effort is set
slog.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
// Update the agent to remove reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = ""
cfg.Agents[name] = updatedAgent
}
return nil
}
// Validate checks if the configuration is valid and applies defaults where needed.
// It validates model IDs and providers, ensuring they are supported.
func Validate() error {
if cfg == nil {
return fmt.Errorf("config not loaded")
@@ -504,15 +314,137 @@ func Validate() error {
// Validate agent models
for name, agent := range cfg.Agents {
if err := validateAgent(cfg, name, agent); err != nil {
return err
// Check if model exists
model, modelExists := models.SupportedModels[agent.Model]
if !modelExists {
logging.Warn("unsupported model configured, reverting to default",
"agent", name,
"configured_model", agent.Model)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
continue
}
// Check if provider for the model is configured
provider := model.Provider
providerCfg, providerExists := cfg.Providers[provider]
if !providerExists {
// Provider not configured, check if we have environment variables
apiKey := getProviderAPIKey(provider)
if apiKey == "" {
logging.Warn("provider not configured for model, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
} else {
// Add provider with API key from environment
cfg.Providers[provider] = Provider{
APIKey: apiKey,
}
logging.Info("added provider from environment", "provider", provider)
}
} else if providerCfg.Disabled || providerCfg.APIKey == "" {
// Provider is disabled or has no API key
logging.Warn("provider is disabled or has no API key, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
}
// Validate max tokens
if agent.MaxTokens <= 0 {
logging.Warn("invalid max tokens, setting to default",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens)
// Update the agent with default max tokens
updatedAgent := cfg.Agents[name]
if model.DefaultMaxTokens > 0 {
updatedAgent.MaxTokens = model.DefaultMaxTokens
} else {
updatedAgent.MaxTokens = 4096 // Fallback default
}
cfg.Agents[name] = updatedAgent
} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
// Ensure max tokens doesn't exceed half the context window (reasonable limit)
logging.Warn("max tokens exceeds half the context window, adjusting",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens,
"context_window", model.ContextWindow)
// Update the agent with adjusted max tokens
updatedAgent := cfg.Agents[name]
updatedAgent.MaxTokens = model.ContextWindow / 2
cfg.Agents[name] = updatedAgent
}
// Validate reasoning effort for models that support reasoning
if model.CanReason && provider == models.ProviderOpenAI {
if agent.ReasoningEffort == "" {
// Set default reasoning effort for models that support it
logging.Info("setting default reasoning effort for model that supports reasoning",
"agent", name,
"model", agent.Model)
// Update the agent with default reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = "medium"
cfg.Agents[name] = updatedAgent
} else {
// Check if reasoning effort is valid (low, medium, high)
effort := strings.ToLower(agent.ReasoningEffort)
if effort != "low" && effort != "medium" && effort != "high" {
logging.Warn("invalid reasoning effort, setting to medium",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
// Update the agent with valid reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = "medium"
cfg.Agents[name] = updatedAgent
}
}
} else if !model.CanReason && agent.ReasoningEffort != "" {
// Model doesn't support reasoning but reasoning effort is set
logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
// Update the agent to remove reasoning effort
updatedAgent := cfg.Agents[name]
updatedAgent.ReasoningEffort = ""
cfg.Agents[name] = updatedAgent
}
}
// Validate providers
for provider, providerCfg := range cfg.Providers {
if providerCfg.APIKey == "" && !providerCfg.Disabled {
slog.Warn("provider has no API key, marking as disabled", "provider", provider)
logging.Warn("provider has no API key, marking as disabled", "provider", provider)
providerCfg.Disabled = true
cfg.Providers[provider] = providerCfg
}
@@ -521,7 +453,7 @@ func Validate() error {
// Validate LSP configurations
for language, lspConfig := range cfg.LSP {
if lspConfig.Command == "" && !lspConfig.Disabled {
slog.Warn("LSP configuration has no command, marking as disabled", "language", language)
logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
lspConfig.Disabled = true
cfg.LSP[language] = lspConfig
}
@@ -541,10 +473,6 @@ func getProviderAPIKey(provider models.ModelProvider) string {
return os.Getenv("GEMINI_API_KEY")
case models.ProviderGROQ:
return os.Getenv("GROQ_API_KEY")
case models.ProviderAzure:
return os.Getenv("AZURE_OPENAI_API_KEY")
case models.ProviderOpenRouter:
return os.Getenv("OPENROUTER_API_KEY")
case models.ProviderBedrock:
if hasAWSCredentials() {
return "aws-credentials-available"
@@ -596,34 +524,6 @@ func setDefaultModelForAgent(agent AgentName) bool {
return true
}
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
var model models.ModelID
maxTokens := int64(5000)
reasoningEffort := ""
switch agent {
case AgentTitle:
model = models.OpenRouterClaude35Haiku
maxTokens = 80
case AgentTask:
model = models.OpenRouterClaude37Sonnet
default:
model = models.OpenRouterClaude37Sonnet
}
// Check if model supports reasoning
if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
reasoningEffort = "medium"
}
cfg.Agents[agent] = Agent{
Model: model,
MaxTokens: maxTokens,
ReasoningEffort: reasoningEffort,
}
return true
}
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
var model models.ModelID
maxTokens := int64(5000)
@@ -685,113 +585,3 @@ func WorkingDirectory() string {
}
return cfg.WorkingDir
}
// GetHostname returns the system hostname or "User" if it can't be determined
func GetHostname() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "User", err
}
return hostname, nil
}
// GetUsername returns the current user's username
func GetUsername() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "User", err
}
return currentUser.Username, nil
}
func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
if cfg == nil {
panic("config not loaded")
}
existingAgentCfg := cfg.Agents[agentName]
model, ok := models.SupportedModels[modelID]
if !ok {
return fmt.Errorf("model %s not supported", modelID)
}
maxTokens := existingAgentCfg.MaxTokens
if model.DefaultMaxTokens > 0 {
maxTokens = model.DefaultMaxTokens
}
newAgentCfg := Agent{
Model: modelID,
MaxTokens: maxTokens,
ReasoningEffort: existingAgentCfg.ReasoningEffort,
}
cfg.Agents[agentName] = newAgentCfg
if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
// revert config update on failure
cfg.Agents[agentName] = existingAgentCfg
return fmt.Errorf("failed to update agent model: %w", err)
}
return nil
}
// UpdateTheme updates the theme in the configuration and writes it to the config file.
func UpdateTheme(themeName string) error {
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Update the in-memory config
cfg.TUI.Theme = themeName
// Get the config file path
configFile := viper.ConfigFileUsed()
var configData []byte
if configFile == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
configData = data
}
// Parse the JSON
var configMap map[string]any
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]any)
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]any{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
configMap["tui"] = tuiConfig
}
// Write the updated config back to file
updatedData, err := json.MarshalIndent(configMap, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
+1
View File
@@ -58,3 +58,4 @@ func MarkProjectInitialized() error {
return nil
}
+38 -15
View File
@@ -6,13 +6,14 @@ import (
"os"
"path/filepath"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/sst/opencode/internal/config"
"log/slog"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
)
func Connect() (*sql.DB, error) {
@@ -47,22 +48,44 @@ func Connect() (*sql.DB, error) {
for _, pragma := range pragmas {
if _, err = db.Exec(pragma); err != nil {
slog.Error("Failed to set pragma", pragma, err)
logging.Error("Failed to set pragma", pragma, err)
} else {
slog.Debug("Set pragma", "pragma", pragma)
logging.Debug("Set pragma", "pragma", pragma)
}
}
goose.SetBaseFS(FS)
if err := goose.SetDialect("sqlite3"); err != nil {
slog.Error("Failed to set dialect", "error", err)
return nil, fmt.Errorf("failed to set dialect: %w", err)
// Initialize schema from embedded file
d, err := iofs.New(FS, "migrations")
if err != nil {
logging.Error("Failed to open embedded migrations", "error", err)
db.Close()
return nil, fmt.Errorf("failed to open embedded migrations: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
slog.Error("Failed to apply migrations", "error", err)
return nil, fmt.Errorf("failed to apply migrations: %w", err)
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
logging.Error("Failed to create SQLite driver", "error", err)
db.Close()
return nil, fmt.Errorf("failed to create SQLite driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", d, "ql", driver)
if err != nil {
logging.Error("Failed to create migration instance", "error", err)
db.Close()
return nil, fmt.Errorf("failed to create migration instance: %w", err)
}
err = m.Up()
if err != nil && err != migrate.ErrNoChange {
logging.Error("Migration failed", "error", err)
db.Close()
return nil, fmt.Errorf("failed to apply schema: %w", err)
} else if err == migrate.ErrNoChange {
logging.Info("No schema changes to apply")
} else {
logging.Info("Schema migration applied successfully")
}
return db, nil
}
+47 -87
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
@@ -27,9 +27,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil {
return nil, fmt.Errorf("error preparing query CreateFile: %w", err)
}
if q.createLogStmt, err = db.PrepareContext(ctx, createLog); err != nil {
return nil, fmt.Errorf("error preparing query CreateLog: %w", err)
}
if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
}
@@ -63,9 +60,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
}
if q.listAllLogsStmt, err = db.PrepareContext(ctx, listAllLogs); err != nil {
return nil, fmt.Errorf("error preparing query ListAllLogs: %w", err)
}
if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
}
@@ -75,15 +69,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil {
return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err)
}
if q.listLogsBySessionStmt, err = db.PrepareContext(ctx, listLogsBySession); err != nil {
return nil, fmt.Errorf("error preparing query ListLogsBySession: %w", err)
}
if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
}
if q.listMessagesBySessionAfterStmt, err = db.PrepareContext(ctx, listMessagesBySessionAfter); err != nil {
return nil, fmt.Errorf("error preparing query ListMessagesBySessionAfter: %w", err)
}
if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
}
@@ -109,11 +97,6 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing createFileStmt: %w", cerr)
}
}
if q.createLogStmt != nil {
if cerr := q.createLogStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing createLogStmt: %w", cerr)
}
}
if q.createMessageStmt != nil {
if cerr := q.createMessageStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -169,11 +152,6 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
}
}
if q.listAllLogsStmt != nil {
if cerr := q.listAllLogsStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listAllLogsStmt: %w", cerr)
}
}
if q.listFilesByPathStmt != nil {
if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -189,21 +167,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr)
}
}
if q.listLogsBySessionStmt != nil {
if cerr := q.listLogsBySessionStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listLogsBySessionStmt: %w", cerr)
}
}
if q.listMessagesBySessionStmt != nil {
if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
}
}
if q.listMessagesBySessionAfterStmt != nil {
if cerr := q.listMessagesBySessionAfterStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listMessagesBySessionAfterStmt: %w", cerr)
}
}
if q.listNewFilesStmt != nil {
if cerr := q.listNewFilesStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
@@ -266,63 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
}
type Queries struct {
db DBTX
tx *sql.Tx
createFileStmt *sql.Stmt
createLogStmt *sql.Stmt
createMessageStmt *sql.Stmt
createSessionStmt *sql.Stmt
deleteFileStmt *sql.Stmt
deleteMessageStmt *sql.Stmt
deleteSessionStmt *sql.Stmt
deleteSessionFilesStmt *sql.Stmt
deleteSessionMessagesStmt *sql.Stmt
getFileStmt *sql.Stmt
getFileByPathAndSessionStmt *sql.Stmt
getMessageStmt *sql.Stmt
getSessionByIDStmt *sql.Stmt
listAllLogsStmt *sql.Stmt
listFilesByPathStmt *sql.Stmt
listFilesBySessionStmt *sql.Stmt
listLatestSessionFilesStmt *sql.Stmt
listLogsBySessionStmt *sql.Stmt
listMessagesBySessionStmt *sql.Stmt
listMessagesBySessionAfterStmt *sql.Stmt
listNewFilesStmt *sql.Stmt
listSessionsStmt *sql.Stmt
updateFileStmt *sql.Stmt
updateMessageStmt *sql.Stmt
updateSessionStmt *sql.Stmt
db DBTX
tx *sql.Tx
createFileStmt *sql.Stmt
createMessageStmt *sql.Stmt
createSessionStmt *sql.Stmt
deleteFileStmt *sql.Stmt
deleteMessageStmt *sql.Stmt
deleteSessionStmt *sql.Stmt
deleteSessionFilesStmt *sql.Stmt
deleteSessionMessagesStmt *sql.Stmt
getFileStmt *sql.Stmt
getFileByPathAndSessionStmt *sql.Stmt
getMessageStmt *sql.Stmt
getSessionByIDStmt *sql.Stmt
listFilesByPathStmt *sql.Stmt
listFilesBySessionStmt *sql.Stmt
listLatestSessionFilesStmt *sql.Stmt
listMessagesBySessionStmt *sql.Stmt
listNewFilesStmt *sql.Stmt
listSessionsStmt *sql.Stmt
updateFileStmt *sql.Stmt
updateMessageStmt *sql.Stmt
updateSessionStmt *sql.Stmt
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
tx: tx,
createFileStmt: q.createFileStmt,
createLogStmt: q.createLogStmt,
createMessageStmt: q.createMessageStmt,
createSessionStmt: q.createSessionStmt,
deleteFileStmt: q.deleteFileStmt,
deleteMessageStmt: q.deleteMessageStmt,
deleteSessionStmt: q.deleteSessionStmt,
deleteSessionFilesStmt: q.deleteSessionFilesStmt,
deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
getFileStmt: q.getFileStmt,
getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
getMessageStmt: q.getMessageStmt,
getSessionByIDStmt: q.getSessionByIDStmt,
listAllLogsStmt: q.listAllLogsStmt,
listFilesByPathStmt: q.listFilesByPathStmt,
listFilesBySessionStmt: q.listFilesBySessionStmt,
listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
listLogsBySessionStmt: q.listLogsBySessionStmt,
listMessagesBySessionStmt: q.listMessagesBySessionStmt,
listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt,
listNewFilesStmt: q.listNewFilesStmt,
listSessionsStmt: q.listSessionsStmt,
updateFileStmt: q.updateFileStmt,
updateMessageStmt: q.updateMessageStmt,
updateSessionStmt: q.updateSessionStmt,
db: tx,
tx: tx,
createFileStmt: q.createFileStmt,
createMessageStmt: q.createMessageStmt,
createSessionStmt: q.createSessionStmt,
deleteFileStmt: q.deleteFileStmt,
deleteMessageStmt: q.deleteMessageStmt,
deleteSessionStmt: q.deleteSessionStmt,
deleteSessionFilesStmt: q.deleteSessionFilesStmt,
deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
getFileStmt: q.getFileStmt,
getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
getMessageStmt: q.getMessageStmt,
getSessionByIDStmt: q.getSessionByIDStmt,
listFilesByPathStmt: q.listFilesByPathStmt,
listFilesBySessionStmt: q.listFilesBySessionStmt,
listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
listMessagesBySessionStmt: q.listMessagesBySessionStmt,
listNewFilesStmt: q.listNewFilesStmt,
listSessionsStmt: q.listSessionsStmt,
updateFileStmt: q.updateFileStmt,
updateMessageStmt: q.updateMessageStmt,
updateSessionStmt: q.updateSessionStmt,
}
}
+14 -20
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: files.sql
package db
@@ -15,11 +15,13 @@ INSERT INTO files (
session_id,
path,
content,
version
version,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
RETURNING id, session_id, path, content, version, created_at, updated_at
`
type CreateFileParams struct {
@@ -45,7 +47,6 @@ func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, e
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -73,7 +74,7 @@ func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) erro
}
const getFile = `-- name: GetFile :one
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE id = ? LIMIT 1
`
@@ -87,7 +88,6 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -95,7 +95,7 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
}
const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE path = ? AND session_id = ?
ORDER BY created_at DESC
@@ -116,7 +116,6 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -124,7 +123,7 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
}
const listFilesByPath = `-- name: ListFilesByPath :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE path = ?
ORDER BY created_at DESC
@@ -145,7 +144,6 @@ func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, err
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -163,7 +161,7 @@ func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, err
}
const listFilesBySession = `-- name: ListFilesBySession :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE session_id = ?
ORDER BY created_at ASC
@@ -184,7 +182,6 @@ func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]F
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -202,7 +199,7 @@ func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]F
}
const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
SELECT f.id, f.session_id, f.path, f.content, f.version, f.is_new, f.created_at, f.updated_at
SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
FROM files f
INNER JOIN (
SELECT path, MAX(created_at) as max_created_at
@@ -228,7 +225,6 @@ func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string)
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -246,7 +242,7 @@ func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string)
}
const listNewFiles = `-- name: ListNewFiles :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE is_new = 1
ORDER BY created_at DESC
@@ -267,7 +263,6 @@ func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -289,9 +284,9 @@ UPDATE files
SET
content = ?,
version = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
RETURNING id, session_id, path, content, version, created_at, updated_at
`
type UpdateFileParams struct {
@@ -309,7 +304,6 @@ func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, e
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
-137
View File
@@ -1,137 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: logs.sql
package db
import (
"context"
"database/sql"
)
const createLog = `-- name: CreateLog :one
INSERT INTO logs (
id,
session_id,
timestamp,
level,
message,
attributes
) VALUES (
?,
?,
?,
?,
?,
?
) RETURNING id, session_id, timestamp, level, message, attributes, created_at, updated_at
`
type CreateLogParams struct {
ID string `json:"id"`
SessionID sql.NullString `json:"session_id"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Attributes sql.NullString `json:"attributes"`
}
func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) {
row := q.queryRow(ctx, q.createLogStmt, createLog,
arg.ID,
arg.SessionID,
arg.Timestamp,
arg.Level,
arg.Message,
arg.Attributes,
)
var i Log
err := row.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAllLogs = `-- name: ListAllLogs :many
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
ORDER BY timestamp DESC
LIMIT ?
`
func (q *Queries) ListAllLogs(ctx context.Context, limit int64) ([]Log, error) {
rows, err := q.query(ctx, q.listAllLogsStmt, listAllLogs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Log{}
for rows.Next() {
var i Log
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLogsBySession = `-- name: ListLogsBySession :many
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
WHERE session_id = ?
ORDER BY timestamp DESC
`
func (q *Queries) ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) {
rows, err := q.query(ctx, q.listLogsBySessionStmt, listLogsBySession, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Log{}
for rows.Next() {
var i Log
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+9 -51
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: messages.sql
package db
@@ -16,9 +16,11 @@ INSERT INTO messages (
session_id,
role,
parts,
model
model,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
`
@@ -134,63 +136,19 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
return items, nil
}
const listMessagesBySessionAfter = `-- name: ListMessagesBySessionAfter :many
SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
FROM messages
WHERE session_id = ? AND created_at > ?
ORDER BY created_at ASC
`
type ListMessagesBySessionAfterParams struct {
SessionID string `json:"session_id"`
CreatedAt string `json:"created_at"`
}
func (q *Queries) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) {
rows, err := q.query(ctx, q.listMessagesBySessionAfterStmt, listMessagesBySessionAfter, arg.SessionID, arg.CreatedAt)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Message{}
for rows.Next() {
var i Message
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Role,
&i.Parts,
&i.Model,
&i.CreatedAt,
&i.UpdatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateMessage = `-- name: UpdateMessage :exec
UPDATE messages
SET
parts = ?,
finished_at = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
`
type UpdateMessageParams struct {
Parts string `json:"parts"`
FinishedAt sql.NullString `json:"finished_at"`
ID string `json:"id"`
Parts string `json:"parts"`
FinishedAt sql.NullInt64 `json:"finished_at"`
ID string `json:"id"`
}
func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
@@ -0,0 +1,10 @@
DROP TRIGGER IF EXISTS update_sessions_updated_at;
DROP TRIGGER IF EXISTS update_messages_updated_at;
DROP TRIGGER IF EXISTS update_files_updated_at;
DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS files;
@@ -0,0 +1,80 @@
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
parent_session_id TEXT,
title TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens>= 0),
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
created_at INTEGER NOT NULL -- Unix timestamp in milliseconds
);
CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
AFTER UPDATE ON sessions
BEGIN
UPDATE sessions SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
-- Files
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
path TEXT NOT NULL,
content TEXT NOT NULL,
version TEXT NOT NULL,
created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
UNIQUE(path, session_id, version)
);
CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
CREATE TRIGGER IF NOT EXISTS update_files_updated_at
AFTER UPDATE ON files
BEGIN
UPDATE files SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
-- Messages
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
parts TEXT NOT NULL default '[]',
model TEXT,
created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
finished_at INTEGER, -- Unix timestamp in milliseconds
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id);
CREATE TRIGGER IF NOT EXISTS update_messages_updated_at
AFTER UPDATE ON messages
BEGIN
UPDATE messages SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_insert
AFTER INSERT ON messages
BEGIN
UPDATE sessions SET
message_count = message_count + 1
WHERE id = new.session_id;
END;
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_delete
AFTER DELETE ON messages
BEGIN
UPDATE sessions SET
message_count = message_count - 1
WHERE id = old.session_id;
END;
@@ -1,125 +0,0 @@
-- +goose Up
-- +goose StatementBegin
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
parent_session_id TEXT,
title TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0),
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
summary TEXT,
summarized_at TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
);
CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
AFTER UPDATE ON sessions
BEGIN
UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
WHERE id = new.id;
END;
-- Files
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
path TEXT NOT NULL,
content TEXT NOT NULL,
version TEXT NOT NULL,
is_new INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
UNIQUE(path, session_id, version)
);
CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
CREATE TRIGGER IF NOT EXISTS update_files_updated_at
AFTER UPDATE ON files
BEGIN
UPDATE files SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
WHERE id = new.id;
END;
-- Messages
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
parts TEXT NOT NULL default '[]',
model TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
finished_at TEXT,
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id);
CREATE TRIGGER IF NOT EXISTS update_messages_updated_at
AFTER UPDATE ON messages
BEGIN
UPDATE messages SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
WHERE id = new.id;
END;
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_insert
AFTER INSERT ON messages
BEGIN
UPDATE sessions SET
message_count = message_count + 1
WHERE id = new.session_id;
END;
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_delete
AFTER DELETE ON messages
BEGIN
UPDATE sessions SET
message_count = message_count - 1
WHERE id = old.session_id;
END;
-- Logs
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
attributes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
);
CREATE INDEX logs_session_id_idx ON logs(session_id);
CREATE INDEX logs_timestamp_idx ON logs(timestamp);
CREATE TRIGGER IF NOT EXISTS update_logs_updated_at
AFTER UPDATE ON logs
BEGIN
UPDATE logs SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
WHERE id = new.id;
END;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS update_sessions_updated_at;
DROP TRIGGER IF EXISTS update_messages_updated_at;
DROP TRIGGER IF EXISTS update_files_updated_at;
DROP TRIGGER IF EXISTS update_logs_updated_at;
DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
DROP TABLE IF EXISTS logs;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS sessions;
-- +goose StatementEnd
+13 -27
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
@@ -9,25 +9,13 @@ import (
)
type File struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Path string `json:"path"`
Content string `json:"content"`
Version string `json:"version"`
IsNew sql.NullInt64 `json:"is_new"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Log struct {
ID string `json:"id"`
SessionID sql.NullString `json:"session_id"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Attributes sql.NullString `json:"attributes"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
SessionID string `json:"session_id"`
Path string `json:"path"`
Content string `json:"content"`
Version string `json:"version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Message struct {
@@ -36,9 +24,9 @@ type Message struct {
Role string `json:"role"`
Parts string `json:"parts"`
Model sql.NullString `json:"model"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
FinishedAt sql.NullString `json:"finished_at"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
FinishedAt sql.NullInt64 `json:"finished_at"`
}
type Session struct {
@@ -49,8 +37,6 @@ type Session struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
Summary sql.NullString `json:"summary"`
SummarizedAt sql.NullString `json:"summarized_at"`
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CreatedAt int64 `json:"created_at"`
}
+1 -6
View File
@@ -1,17 +1,15 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
import (
"context"
"database/sql"
)
type Querier interface {
CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
CreateLog(ctx context.Context, arg CreateLogParams) (Log, error)
CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteFile(ctx context.Context, id string) error
@@ -23,13 +21,10 @@ type Querier interface {
GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
GetMessage(ctx context.Context, id string) (Message, error)
GetSessionByID(ctx context.Context, id string) (Session, error)
ListAllLogs(ctx context.Context, limit int64) ([]Log, error)
ListFilesByPath(ctx context.Context, path string) ([]File, error)
ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error)
ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error)
ListNewFiles(ctx context.Context) ([]File, error)
ListSessions(ctx context.Context) ([]Session, error)
UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
+15 -33
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: sessions.sql
package db
@@ -19,8 +19,8 @@ INSERT INTO sessions (
prompt_tokens,
completion_tokens,
cost,
summary,
summarized_at
updated_at,
created_at
) VALUES (
?,
?,
@@ -29,9 +29,9 @@ INSERT INTO sessions (
?,
?,
?,
?,
?
) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
strftime('%s', 'now'),
strftime('%s', 'now')
) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
`
type CreateSessionParams struct {
@@ -42,8 +42,6 @@ type CreateSessionParams struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
Summary sql.NullString `json:"summary"`
SummarizedAt sql.NullString `json:"summarized_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
@@ -55,8 +53,6 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
arg.PromptTokens,
arg.CompletionTokens,
arg.Cost,
arg.Summary,
arg.SummarizedAt,
)
var i Session
err := row.Scan(
@@ -67,8 +63,6 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
@@ -86,7 +80,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
}
const getSessionByID = `-- name: GetSessionByID :one
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
FROM sessions
WHERE id = ? LIMIT 1
`
@@ -102,8 +96,6 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
@@ -111,7 +103,7 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
}
const listSessions = `-- name: ListSessions :many
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
FROM sessions
WHERE parent_session_id is NULL
ORDER BY created_at DESC
@@ -134,8 +126,6 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
); err != nil {
@@ -158,21 +148,17 @@ SET
title = ?,
prompt_tokens = ?,
completion_tokens = ?,
cost = ?,
summary = ?,
summarized_at = ?
cost = ?
WHERE id = ?
RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
`
type UpdateSessionParams struct {
Title string `json:"title"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
Summary sql.NullString `json:"summary"`
SummarizedAt sql.NullString `json:"summarized_at"`
ID string `json:"id"`
Title string `json:"title"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
ID string `json:"id"`
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
@@ -181,8 +167,6 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
arg.PromptTokens,
arg.CompletionTokens,
arg.Cost,
arg.Summary,
arg.SummarizedAt,
arg.ID,
)
var i Session
@@ -194,8 +178,6 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
+5 -3
View File
@@ -28,9 +28,11 @@ INSERT INTO files (
session_id,
path,
content,
version
version,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING *;
@@ -39,7 +41,7 @@ UPDATE files
SET
content = ?,
version = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
RETURNING *;
-26
View File
@@ -1,26 +0,0 @@
-- name: CreateLog :one
INSERT INTO logs (
id,
session_id,
timestamp,
level,
message,
attributes
) VALUES (
?,
?,
?,
?,
?,
?
) RETURNING *;
-- name: ListLogsBySession :many
SELECT * FROM logs
WHERE session_id = ?
ORDER BY timestamp DESC;
-- name: ListAllLogs :many
SELECT * FROM logs
ORDER BY timestamp DESC
LIMIT ?;
+5 -9
View File
@@ -9,21 +9,17 @@ FROM messages
WHERE session_id = ?
ORDER BY created_at ASC;
-- name: ListMessagesBySessionAfter :many
SELECT *
FROM messages
WHERE session_id = ? AND created_at > ?
ORDER BY created_at ASC;
-- name: CreateMessage :one
INSERT INTO messages (
id,
session_id,
role,
parts,
model
model,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING *;
@@ -32,7 +28,7 @@ UPDATE messages
SET
parts = ?,
finished_at = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?;
+5 -7
View File
@@ -7,8 +7,8 @@ INSERT INTO sessions (
prompt_tokens,
completion_tokens,
cost,
summary,
summarized_at
updated_at,
created_at
) VALUES (
?,
?,
@@ -17,8 +17,8 @@ INSERT INTO sessions (
?,
?,
?,
?,
?
strftime('%s', 'now'),
strftime('%s', 'now')
) RETURNING *;
-- name: GetSessionByID :one
@@ -38,9 +38,7 @@ SET
title = ?,
prompt_tokens = ?,
completion_tokens = ?,
cost = ?,
summary = ?,
summarized_at = ?
cost = ?
WHERE id = ?
RETURNING *;
+474 -296
View File
@@ -4,20 +4,24 @@ import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/aymanbagabas/go-udiff"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/theme"
)
// -------------------------------------------------------------------------
@@ -69,6 +73,143 @@ type linePair struct {
right *DiffLine
}
// -------------------------------------------------------------------------
// Style Configuration
// -------------------------------------------------------------------------
// StyleConfig defines styling for diff rendering
type StyleConfig struct {
ShowHeader bool
ShowHunkHeader bool
FileNameFg lipgloss.Color
// Background colors
RemovedLineBg lipgloss.Color
AddedLineBg lipgloss.Color
ContextLineBg lipgloss.Color
HunkLineBg lipgloss.Color
RemovedLineNumberBg lipgloss.Color
AddedLineNamerBg lipgloss.Color
// Foreground colors
HunkLineFg lipgloss.Color
RemovedFg lipgloss.Color
AddedFg lipgloss.Color
LineNumberFg lipgloss.Color
RemovedHighlightFg lipgloss.Color
AddedHighlightFg lipgloss.Color
// Highlight settings
HighlightStyle string
RemovedHighlightBg lipgloss.Color
AddedHighlightBg lipgloss.Color
}
// StyleOption is a function that modifies a StyleConfig
type StyleOption func(*StyleConfig)
// NewStyleConfig creates a StyleConfig with default values
func NewStyleConfig(opts ...StyleOption) StyleConfig {
// Default color scheme
config := StyleConfig{
ShowHeader: true,
ShowHunkHeader: true,
FileNameFg: lipgloss.Color("#a0a0a0"),
RemovedLineBg: lipgloss.Color("#3A3030"),
AddedLineBg: lipgloss.Color("#303A30"),
ContextLineBg: lipgloss.Color("#212121"),
HunkLineBg: lipgloss.Color("#212121"),
HunkLineFg: lipgloss.Color("#a0a0a0"),
RemovedFg: lipgloss.Color("#7C4444"),
AddedFg: lipgloss.Color("#478247"),
LineNumberFg: lipgloss.Color("#888888"),
HighlightStyle: "dracula",
RemovedHighlightBg: lipgloss.Color("#612726"),
AddedHighlightBg: lipgloss.Color("#256125"),
RemovedLineNumberBg: lipgloss.Color("#332929"),
AddedLineNamerBg: lipgloss.Color("#293229"),
RemovedHighlightFg: lipgloss.Color("#FADADD"),
AddedHighlightFg: lipgloss.Color("#DAFADA"),
}
// Apply all provided options
for _, opt := range opts {
opt(&config)
}
return config
}
// Style option functions
func WithFileNameFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.FileNameFg = color }
}
func WithRemovedLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedLineBg = color }
}
func WithAddedLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedLineBg = color }
}
func WithContextLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.ContextLineBg = color }
}
func WithRemovedFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedFg = color }
}
func WithAddedFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedFg = color }
}
func WithLineNumberFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.LineNumberFg = color }
}
func WithHighlightStyle(style string) StyleOption {
return func(s *StyleConfig) { s.HighlightStyle = style }
}
func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
return func(s *StyleConfig) {
s.RemovedHighlightBg = bg
s.RemovedHighlightFg = fg
}
}
func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
return func(s *StyleConfig) {
s.AddedHighlightBg = bg
s.AddedHighlightFg = fg
}
}
func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
}
func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.AddedLineNamerBg = color }
}
func WithHunkLineBg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.HunkLineBg = color }
}
func WithHunkLineFg(color lipgloss.Color) StyleOption {
return func(s *StyleConfig) { s.HunkLineFg = color }
}
func WithShowHeader(show bool) StyleOption {
return func(s *StyleConfig) { s.ShowHeader = show }
}
func WithShowHunkHeader(show bool) StyleOption {
return func(s *StyleConfig) { s.ShowHunkHeader = show }
}
// -------------------------------------------------------------------------
// Parse Configuration
// -------------------------------------------------------------------------
@@ -97,6 +238,7 @@ func WithContextSize(size int) ParseOption {
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
Style StyleConfig
}
// SideBySideOption modifies a SideBySideConfig
@@ -106,6 +248,7 @@ type SideBySideOption func(*SideBySideConfig)
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
Style: NewStyleConfig(),
}
for _, opt := range opts {
@@ -124,6 +267,20 @@ func WithTotalWidth(width int) SideBySideOption {
}
}
// WithStyle sets the styling configuration
func WithStyle(style StyleConfig) SideBySideOption {
return func(s *SideBySideConfig) {
s.Style = style
}
}
// WithStyleOptions applies the specified style options
func WithStyleOptions(opts ...StyleOption) SideBySideOption {
return func(s *SideBySideConfig) {
s.Style = NewStyleConfig(opts...)
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
@@ -230,7 +387,7 @@ func ParseUnifiedDiff(diff string) (DiffResult, error) {
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
func HighlightIntralineChanges(h *Hunk) {
func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
var updated []DiffLine
dmp := diffmatchpatch.New()
@@ -324,8 +481,6 @@ func pairLines(lines []DiffLine) []linePair {
// SyntaxHighlight applies syntax highlighting to text based on file extension
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
@@ -341,175 +496,93 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
if f == nil {
f = formatters.Fallback
}
// Dynamic theme based on current theme values
syntaxThemeXml := fmt.Sprintf(`
<style name="opencode-theme">
theme := `
<style name="vscode-dark-plus">
<!-- Base colors -->
<entry type="Background" style="bg:%s"/>
<entry type="Text" style="%s"/>
<entry type="Other" style="%s"/>
<entry type="Error" style="%s"/>
<!-- Keywords -->
<entry type="Keyword" style="%s"/>
<entry type="KeywordConstant" style="%s"/>
<entry type="KeywordDeclaration" style="%s"/>
<entry type="KeywordNamespace" style="%s"/>
<entry type="KeywordPseudo" style="%s"/>
<entry type="KeywordReserved" style="%s"/>
<entry type="KeywordType" style="%s"/>
<entry type="Background" style="bg:#1E1E1E"/>
<entry type="Text" style="#D4D4D4"/>
<entry type="Other" style="#D4D4D4"/>
<entry type="Error" style="#F44747"/>
<!-- Keywords - using the Control flow / Special keywords color -->
<entry type="Keyword" style="#C586C0"/>
<entry type="KeywordConstant" style="#4FC1FF"/>
<entry type="KeywordDeclaration" style="#C586C0"/>
<entry type="KeywordNamespace" style="#C586C0"/>
<entry type="KeywordPseudo" style="#C586C0"/>
<entry type="KeywordReserved" style="#C586C0"/>
<entry type="KeywordType" style="#4EC9B0"/>
<!-- Names -->
<entry type="Name" style="%s"/>
<entry type="NameAttribute" style="%s"/>
<entry type="NameBuiltin" style="%s"/>
<entry type="NameBuiltinPseudo" style="%s"/>
<entry type="NameClass" style="%s"/>
<entry type="NameConstant" style="%s"/>
<entry type="NameDecorator" style="%s"/>
<entry type="NameEntity" style="%s"/>
<entry type="NameException" style="%s"/>
<entry type="NameFunction" style="%s"/>
<entry type="NameLabel" style="%s"/>
<entry type="NameNamespace" style="%s"/>
<entry type="NameOther" style="%s"/>
<entry type="NameTag" style="%s"/>
<entry type="NameVariable" style="%s"/>
<entry type="NameVariableClass" style="%s"/>
<entry type="NameVariableGlobal" style="%s"/>
<entry type="NameVariableInstance" style="%s"/>
<entry type="Name" style="#D4D4D4"/>
<entry type="NameAttribute" style="#9CDCFE"/>
<entry type="NameBuiltin" style="#4EC9B0"/>
<entry type="NameBuiltinPseudo" style="#9CDCFE"/>
<entry type="NameClass" style="#4EC9B0"/>
<entry type="NameConstant" style="#4FC1FF"/>
<entry type="NameDecorator" style="#DCDCAA"/>
<entry type="NameEntity" style="#9CDCFE"/>
<entry type="NameException" style="#4EC9B0"/>
<entry type="NameFunction" style="#DCDCAA"/>
<entry type="NameLabel" style="#C8C8C8"/>
<entry type="NameNamespace" style="#4EC9B0"/>
<entry type="NameOther" style="#9CDCFE"/>
<entry type="NameTag" style="#569CD6"/>
<entry type="NameVariable" style="#9CDCFE"/>
<entry type="NameVariableClass" style="#9CDCFE"/>
<entry type="NameVariableGlobal" style="#9CDCFE"/>
<entry type="NameVariableInstance" style="#9CDCFE"/>
<!-- Literals -->
<entry type="Literal" style="%s"/>
<entry type="LiteralDate" style="%s"/>
<entry type="LiteralString" style="%s"/>
<entry type="LiteralStringBacktick" style="%s"/>
<entry type="LiteralStringChar" style="%s"/>
<entry type="LiteralStringDoc" style="%s"/>
<entry type="LiteralStringDouble" style="%s"/>
<entry type="LiteralStringEscape" style="%s"/>
<entry type="LiteralStringHeredoc" style="%s"/>
<entry type="LiteralStringInterpol" style="%s"/>
<entry type="LiteralStringOther" style="%s"/>
<entry type="LiteralStringRegex" style="%s"/>
<entry type="LiteralStringSingle" style="%s"/>
<entry type="LiteralStringSymbol" style="%s"/>
<!-- Numbers -->
<entry type="LiteralNumber" style="%s"/>
<entry type="LiteralNumberBin" style="%s"/>
<entry type="LiteralNumberFloat" style="%s"/>
<entry type="LiteralNumberHex" style="%s"/>
<entry type="LiteralNumberInteger" style="%s"/>
<entry type="LiteralNumberIntegerLong" style="%s"/>
<entry type="LiteralNumberOct" style="%s"/>
<entry type="Literal" style="#CE9178"/>
<entry type="LiteralDate" style="#CE9178"/>
<entry type="LiteralString" style="#CE9178"/>
<entry type="LiteralStringBacktick" style="#CE9178"/>
<entry type="LiteralStringChar" style="#CE9178"/>
<entry type="LiteralStringDoc" style="#CE9178"/>
<entry type="LiteralStringDouble" style="#CE9178"/>
<entry type="LiteralStringEscape" style="#d7ba7d"/>
<entry type="LiteralStringHeredoc" style="#CE9178"/>
<entry type="LiteralStringInterpol" style="#CE9178"/>
<entry type="LiteralStringOther" style="#CE9178"/>
<entry type="LiteralStringRegex" style="#d16969"/>
<entry type="LiteralStringSingle" style="#CE9178"/>
<entry type="LiteralStringSymbol" style="#CE9178"/>
<!-- Numbers - using the numberLiteral color -->
<entry type="LiteralNumber" style="#b5cea8"/>
<entry type="LiteralNumberBin" style="#b5cea8"/>
<entry type="LiteralNumberFloat" style="#b5cea8"/>
<entry type="LiteralNumberHex" style="#b5cea8"/>
<entry type="LiteralNumberInteger" style="#b5cea8"/>
<entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
<entry type="LiteralNumberOct" style="#b5cea8"/>
<!-- Operators -->
<entry type="Operator" style="%s"/>
<entry type="OperatorWord" style="%s"/>
<entry type="Punctuation" style="%s"/>
<!-- Comments -->
<entry type="Comment" style="%s"/>
<entry type="CommentHashbang" style="%s"/>
<entry type="CommentMultiline" style="%s"/>
<entry type="CommentSingle" style="%s"/>
<entry type="CommentSpecial" style="%s"/>
<entry type="CommentPreproc" style="%s"/>
<entry type="Operator" style="#D4D4D4"/>
<entry type="OperatorWord" style="#C586C0"/>
<entry type="Punctuation" style="#D4D4D4"/>
<!-- Comments - standard VSCode Dark+ comment color -->
<entry type="Comment" style="#6A9955"/>
<entry type="CommentHashbang" style="#6A9955"/>
<entry type="CommentMultiline" style="#6A9955"/>
<entry type="CommentSingle" style="#6A9955"/>
<entry type="CommentSpecial" style="#6A9955"/>
<entry type="CommentPreproc" style="#C586C0"/>
<!-- Generic styles -->
<entry type="Generic" style="%s"/>
<entry type="GenericDeleted" style="%s"/>
<entry type="GenericEmph" style="italic %s"/>
<entry type="GenericError" style="%s"/>
<entry type="GenericHeading" style="bold %s"/>
<entry type="GenericInserted" style="%s"/>
<entry type="GenericOutput" style="%s"/>
<entry type="GenericPrompt" style="%s"/>
<entry type="GenericStrong" style="bold %s"/>
<entry type="GenericSubheading" style="bold %s"/>
<entry type="GenericTraceback" style="%s"/>
<entry type="Generic" style="#D4D4D4"/>
<entry type="GenericDeleted" style="#F44747"/>
<entry type="GenericEmph" style="italic #D4D4D4"/>
<entry type="GenericError" style="#F44747"/>
<entry type="GenericHeading" style="bold #D4D4D4"/>
<entry type="GenericInserted" style="#b5cea8"/>
<entry type="GenericOutput" style="#808080"/>
<entry type="GenericPrompt" style="#D4D4D4"/>
<entry type="GenericStrong" style="bold #D4D4D4"/>
<entry type="GenericSubheading" style="bold #D4D4D4"/>
<entry type="GenericTraceback" style="#F44747"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="%s"/>
<entry type="TextWhitespace" style="#D4D4D4"/>
</style>
`,
getColor(t.Background()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(t.Error()), // Error
`
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
r := strings.NewReader(theme)
style := chroma.MustNewXMLStyle(r)
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
@@ -531,14 +604,6 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
return f.Format(w, s, it)
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
if lipgloss.HasDarkBackground() {
return adaptiveColor.Dark
}
return adaptiveColor.Light
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
var buf bytes.Buffer
@@ -550,11 +615,11 @@ func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) stri
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
return
}
@@ -564,7 +629,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
// -------------------------------------------------------------------------
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
) string {
// Find all ANSI sequences in the content
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
@@ -602,10 +668,6 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
inSelection := false
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
@@ -640,17 +702,12 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
// Apply background highlight
sb.WriteString("\x1b[48;2;")
r, g, b, _ = bgColor.RGBA()
r, g, b, _ := highlightBg.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
sb.WriteString("\x1b[0m")
sb.WriteString("\x1b[49m") // Reset only background
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
@@ -666,98 +723,50 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
return sb.String()
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
dl *DiffLine,
colWidth int,
isLeftColumn bool,
t theme.Theme,
) string {
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
// Determine line style based on line type and column
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightType LineType
var highlightColor lipgloss.AdaptiveColor
if isLeftColumn {
// Left column logic
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved()
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for left column
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
} else {
// Right column logic
switch dl.Kind {
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for right column
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
}
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
case LineAdded:
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
marker = "?"
bgStyle = contextLineStyle
case LineContext:
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
marker = contextLineStyle.Render(" ")
bgStyle = contextLineStyle
}
// Format line number
lineNum := ""
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
prefix := lineNumberStyle.Render(lineNum + " " + marker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
// Apply intra-line highlighting for removed lines
if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
}
// Add a padding space for added/removed lines
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
// Add a padding space for removed lines
if dl.Kind == LineRemoved {
content = bgStyle.Render(" ") + content
}
@@ -767,19 +776,67 @@ func renderDiffColumnLine(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
),
)
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
return contextLineStyle.Width(colWidth).Render("")
}
_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
switch dl.Kind {
case LineAdded:
marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = contextLineStyle.Render(" ")
bgStyle = contextLineStyle
}
// Format line number
lineNum := ""
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + marker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting for added lines
if dl.Kind == LineAdded && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
}
// Add a padding space for added lines
if dl.Kind == LineAdded {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
),
)
}
// -------------------------------------------------------------------------
@@ -796,7 +853,7 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
HighlightIntralineChanges(&hunkCopy, config.Style)
// Pair lines for side-by-side display
pairs := pairLines(hunkCopy.Lines)
@@ -808,8 +865,8 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
rightWidth := config.TotalWidth - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
sb.WriteString(leftStr + rightStr + "\n")
}
@@ -818,7 +875,6 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
@@ -826,14 +882,53 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
var sb strings.Builder
config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
if config.Style.ShowHeader {
removeIcon := lipgloss.NewStyle().
Background(config.Style.RemovedLineBg).
Foreground(config.Style.RemovedFg).
Render("⏹")
addIcon := lipgloss.NewStyle().
Background(config.Style.AddedLineBg).
Foreground(config.Style.AddedFg).
Render("⏹")
fileName := lipgloss.NewStyle().
Background(config.Style.ContextLineBg).
Foreground(config.Style.FileNameFg).
Render(" " + diffResult.OldFile)
sb.WriteString(
lipgloss.NewStyle().
Background(t.DiffHunkHeader()).
Foreground(t.Background()).
Background(config.Style.ContextLineBg).
Padding(0, 1, 0, 1).
Foreground(config.Style.FileNameFg).
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderBottom(true).
BorderForeground(config.Style.FileNameFg).
BorderBackground(config.Style.ContextLineBg).
Width(config.TotalWidth).
Render(h.Header) + "\n",
Render(
lipgloss.JoinHorizontal(lipgloss.Top,
removeIcon,
addIcon,
fileName,
),
) + "\n",
)
}
for _, h := range diffResult.Hunks {
// Render hunk header
if config.Style.ShowHunkHeader {
sb.WriteString(
lipgloss.NewStyle().
Background(config.Style.HunkLineBg).
Foreground(config.Style.HunkLineFg).
Width(config.TotalWidth).
Render(h.Header) + "\n",
)
}
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
}
@@ -847,23 +942,106 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
cwd := config.WorkingDirectory()
fileName = strings.TrimPrefix(fileName, cwd)
fileName = strings.TrimPrefix(fileName, "/")
// Create temporary directory for git operations
tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
if err != nil {
logging.Error("Failed to create temp directory for git diff", "error", err)
return "", 0, 0
}
defer os.RemoveAll(tempDir)
edits := udiff.Strings(beforeContent, afterContent)
unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8)
var (
additions = 0
removals = 0
)
lines := strings.SplitSeq(unified, "\n")
for line := range lines {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
additions++
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
removals++
}
// Initialize git repo
repo, err := git.PlainInit(tempDir, false)
if err != nil {
logging.Error("Failed to initialize git repository", "error", err)
return "", 0, 0
}
return unified, additions, removals
wt, err := repo.Worktree()
if err != nil {
logging.Error("Failed to get git worktree", "error", err)
return "", 0, 0
}
// Write the "before" content and commit it
fullPath := filepath.Join(tempDir, fileName)
if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
logging.Error("Failed to create directory for file", "error", err)
return "", 0, 0
}
if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
logging.Error("Failed to write before content to file", "error", err)
return "", 0, 0
}
_, err = wt.Add(fileName)
if err != nil {
logging.Error("Failed to add file to git", "error", err)
return "", 0, 0
}
beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
Author: &object.Signature{
Name: "OpenCode",
Email: "coder@opencode.ai",
When: time.Now(),
},
})
if err != nil {
logging.Error("Failed to commit before content", "error", err)
return "", 0, 0
}
// Write the "after" content and commit it
if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
logging.Error("Failed to write after content to file", "error", err)
return "", 0, 0
}
_, err = wt.Add(fileName)
if err != nil {
logging.Error("Failed to add file to git", "error", err)
return "", 0, 0
}
afterCommit, err := wt.Commit("After", &git.CommitOptions{
Author: &object.Signature{
Name: "OpenCode",
Email: "coder@opencode.ai",
When: time.Now(),
},
})
if err != nil {
logging.Error("Failed to commit after content", "error", err)
return "", 0, 0
}
// Get the diff between the two commits
beforeCommitObj, err := repo.CommitObject(beforeCommit)
if err != nil {
logging.Error("Failed to get before commit object", "error", err)
return "", 0, 0
}
afterCommitObj, err := repo.CommitObject(afterCommit)
if err != nil {
logging.Error("Failed to get after commit object", "error", err)
return "", 0, 0
}
patch, err := beforeCommitObj.Patch(afterCommitObj)
if err != nil {
logging.Error("Failed to create git diff patch", "error", err)
return "", 0, 0
}
// Count additions and removals
additions := 0
removals := 0
for _, fileStat := range patch.Stats() {
additions += fileStat.Addition
removals += fileStat.Deletion
}
return patch.String(), additions, removals
}
-103
View File
@@ -1,103 +0,0 @@
package diff
import (
"fmt"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"
)
// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences
func TestApplyHighlighting(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
// Test cases
tests := []struct {
name string
content string
segments []Segment
segmentType LineType
expectContains string
}{
{
name: "Simple text with no ANSI",
content: "This is a test",
segments: []Segment{{Start: 0, End: 4, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI foreground",
content: "This \x1b[32mis\x1b[0m a test", // "is" in green
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI background",
content: "This \x1b[42mis\x1b[0m a test", // "is" with green background
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with complex ANSI styling",
content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
}
for _, tc := range tests {
tc := tc // Capture range variable for parallel testing
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg)
// Verify the result contains the expected sequence
assert.Contains(t, result, tc.expectContains,
"Result should contain full reset sequence")
// Print the result for manual inspection if needed
if t.Failed() {
fmt.Printf("Original: %q\nResult: %q\n", tc.content, result)
}
})
}
}
// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments
func TestApplyHighlightingWithMultipleSegments(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
content := "This is a test with multiple segments to highlight"
segments := []Segment{
{Start: 0, End: 4, Type: LineAdded}, // "This"
{Start: 8, End: 9, Type: LineAdded}, // "a"
{Start: 15, End: 23, Type: LineAdded}, // "multiple"
}
result := applyHighlighting(content, segments, LineAdded, mockHighlightBg)
// Verify the result contains the full reset sequence
assert.Contains(t, result, "\x1b[0m",
"Result should contain full reset sequence")
}
+252
View File
@@ -0,0 +1,252 @@
package history
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/kujtimiihoxha/opencode/internal/db"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
)
const (
InitialVersion = "initial"
)
type File struct {
ID string
SessionID string
Path string
Content string
Version string
CreatedAt int64
UpdatedAt int64
}
type Service interface {
pubsub.Suscriber[File]
Create(ctx context.Context, sessionID, path, content string) (File, error)
CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
Get(ctx context.Context, id string) (File, error)
GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
ListBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
Update(ctx context.Context, file File) (File, error)
Delete(ctx context.Context, id string) error
DeleteSessionFiles(ctx context.Context, sessionID string) error
}
type service struct {
*pubsub.Broker[File]
db *sql.DB
q *db.Queries
}
func NewService(q *db.Queries, db *sql.DB) Service {
return &service{
Broker: pubsub.NewBroker[File](),
q: q,
db: db,
}
}
func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
return s.createWithVersion(ctx, sessionID, path, content, InitialVersion)
}
func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
// Get the latest version for this path
files, err := s.q.ListFilesByPath(ctx, path)
if err != nil {
return File{}, err
}
if len(files) == 0 {
// No previous versions, create initial
return s.Create(ctx, sessionID, path, content)
}
// Get the latest version
latestFile := files[0] // Files are ordered by created_at DESC
latestVersion := latestFile.Version
// Generate the next version
var nextVersion string
if latestVersion == InitialVersion {
nextVersion = "v1"
} else if strings.HasPrefix(latestVersion, "v") {
versionNum, err := strconv.Atoi(latestVersion[1:])
if err != nil {
// If we can't parse the version, just use a timestamp-based version
nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
} else {
nextVersion = fmt.Sprintf("v%d", versionNum+1)
}
} else {
// If the version format is unexpected, use a timestamp-based version
nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
}
return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
}
func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
// Maximum number of retries for transaction conflicts
const maxRetries = 3
var file File
var err error
// Retry loop for transaction conflicts
for attempt := range maxRetries {
// Start a transaction
tx, txErr := s.db.Begin()
if txErr != nil {
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
}
// Create a new queries instance with the transaction
qtx := s.q.WithTx(tx)
// Try to create the file within the transaction
dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
ID: uuid.New().String(),
SessionID: sessionID,
Path: path,
Content: content,
Version: version,
})
if txErr != nil {
// Rollback the transaction
tx.Rollback()
// Check if this is a uniqueness constraint violation
if strings.Contains(txErr.Error(), "UNIQUE constraint failed") {
if attempt < maxRetries-1 {
// If we have retries left, generate a new version and try again
if strings.HasPrefix(version, "v") {
versionNum, parseErr := strconv.Atoi(version[1:])
if parseErr == nil {
version = fmt.Sprintf("v%d", versionNum+1)
continue
}
}
// If we can't parse the version, use a timestamp-based version
version = fmt.Sprintf("v%d", time.Now().Unix())
continue
}
}
return File{}, txErr
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
}
file = s.fromDBItem(dbFile)
s.Publish(pubsub.CreatedEvent, file)
return file, nil
}
return file, err
}
func (s *service) Get(ctx context.Context, id string) (File, error) {
dbFile, err := s.q.GetFile(ctx, id)
if err != nil {
return File{}, err
}
return s.fromDBItem(dbFile), nil
}
func (s *service) GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
dbFile, err := s.q.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
Path: path,
SessionID: sessionID,
})
if err != nil {
return File{}, err
}
return s.fromDBItem(dbFile), nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
dbFiles, err := s.q.ListFilesBySession(ctx, sessionID)
if err != nil {
return nil, err
}
files := make([]File, len(dbFiles))
for i, dbFile := range dbFiles {
files[i] = s.fromDBItem(dbFile)
}
return files, nil
}
func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
dbFiles, err := s.q.ListLatestSessionFiles(ctx, sessionID)
if err != nil {
return nil, err
}
files := make([]File, len(dbFiles))
for i, dbFile := range dbFiles {
files[i] = s.fromDBItem(dbFile)
}
return files, nil
}
func (s *service) Update(ctx context.Context, file File) (File, error) {
dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
ID: file.ID,
Content: file.Content,
Version: file.Version,
})
if err != nil {
return File{}, err
}
updatedFile := s.fromDBItem(dbFile)
s.Publish(pubsub.UpdatedEvent, updatedFile)
return updatedFile, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
file, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteFile(ctx, id)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, file)
return nil
}
func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
files, err := s.ListBySession(ctx, sessionID)
if err != nil {
return err
}
for _, file := range files {
err = s.Delete(ctx, file.ID)
if err != nil {
return err
}
}
return nil
}
func (s *service) fromDBItem(item db.File) File {
return File{
ID: item.ID,
SessionID: item.SessionID,
Path: item.Path,
Content: item.Content,
Version: item.Version,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
-441
View File
@@ -1,441 +0,0 @@
package history
import (
"context"
"database/sql"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
)
const (
InitialVersion = "initial"
)
type File struct {
ID string
SessionID string
Path string
Content string
Version string
CreatedAt time.Time
UpdatedAt time.Time
}
const (
EventFileCreated pubsub.EventType = "history_file_created"
EventFileVersionCreated pubsub.EventType = "history_file_version_created"
EventFileUpdated pubsub.EventType = "history_file_updated"
EventFileDeleted pubsub.EventType = "history_file_deleted"
EventSessionFilesDeleted pubsub.EventType = "history_session_files_deleted"
)
type Service interface {
pubsub.Subscriber[File]
Create(ctx context.Context, sessionID, path, content string) (File, error)
CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
Get(ctx context.Context, id string) (File, error)
GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error)
GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
ListBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
ListVersionsByPath(ctx context.Context, path string) ([]File, error)
Update(ctx context.Context, file File) (File, error)
Delete(ctx context.Context, id string) error
DeleteSessionFiles(ctx context.Context, sessionID string) error
}
type service struct {
db *db.Queries
sqlDB *sql.DB
broker *pubsub.Broker[File]
mu sync.RWMutex
}
var globalHistoryService *service
func InitService(sqlDatabase *sql.DB) error {
if globalHistoryService != nil {
return fmt.Errorf("history service already initialized")
}
queries := db.New(sqlDatabase)
broker := pubsub.NewBroker[File]()
globalHistoryService = &service{
db: queries,
sqlDB: sqlDatabase,
broker: broker,
}
return nil
}
func GetService() Service {
if globalHistoryService == nil {
panic("history service not initialized. Call history.InitService() first.")
}
return globalHistoryService
}
func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
return s.createWithVersion(ctx, sessionID, path, content, InitialVersion, EventFileCreated)
}
func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
s.mu.RLock()
files, err := s.db.ListFilesByPath(ctx, path)
s.mu.RUnlock()
if err != nil && err != sql.ErrNoRows {
return File{}, fmt.Errorf("db.ListFilesByPath for next version: %w", err)
}
latestVersionNumber := 0
if len(files) > 0 {
// Sort to be absolutely sure about the latest version globally for this path
slices.SortFunc(files, func(a, b db.File) int {
if strings.HasPrefix(a.Version, "v") && strings.HasPrefix(b.Version, "v") {
vA, _ := strconv.Atoi(a.Version[1:])
vB, _ := strconv.Atoi(b.Version[1:])
return vB - vA // Descending to get latest first
}
if a.Version == InitialVersion && b.Version != InitialVersion {
return 1 // initial comes after vX
}
if b.Version == InitialVersion && a.Version != InitialVersion {
return -1
}
// Compare timestamps as strings (ISO format sorts correctly)
if b.CreatedAt > a.CreatedAt {
return 1
} else if a.CreatedAt > b.CreatedAt {
return -1
}
return 0 // Equal timestamps
})
latestFile := files[0]
if strings.HasPrefix(latestFile.Version, "v") {
vNum, parseErr := strconv.Atoi(latestFile.Version[1:])
if parseErr == nil {
latestVersionNumber = vNum
}
}
}
nextVersionStr := fmt.Sprintf("v%d", latestVersionNumber+1)
return s.createWithVersion(ctx, sessionID, path, content, nextVersionStr, EventFileVersionCreated)
}
func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string, eventType pubsub.EventType) (File, error) {
s.mu.Lock()
defer s.mu.Unlock()
const maxRetries = 3
var file File
var err error
for attempt := range maxRetries {
tx, txErr := s.sqlDB.BeginTx(ctx, nil)
if txErr != nil {
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
}
qtx := s.db.WithTx(tx)
dbFile, createErr := qtx.CreateFile(ctx, db.CreateFileParams{
ID: uuid.New().String(),
SessionID: sessionID,
Path: path,
Content: content,
Version: version,
})
if createErr != nil {
if rbErr := tx.Rollback(); rbErr != nil {
slog.Error("Failed to rollback transaction on create error", "error", rbErr)
}
if strings.Contains(createErr.Error(), "UNIQUE constraint failed: files.path, files.session_id, files.version") {
if attempt < maxRetries-1 {
slog.Warn("Unique constraint violation for file version, retrying with incremented version", "path", path, "session", sessionID, "attempted_version", version, "attempt", attempt+1)
// Increment version string like v1, v2, v3...
if strings.HasPrefix(version, "v") {
numPart := version[1:]
num, parseErr := strconv.Atoi(numPart)
if parseErr == nil {
version = fmt.Sprintf("v%d", num+1)
continue // Retry with new version
}
}
// Fallback if version is not "vX" or parsing failed
version = fmt.Sprintf("%s-retry%d", version, attempt+1)
continue
}
}
return File{}, fmt.Errorf("db.CreateFile within transaction: %w", createErr)
}
if commitErr := tx.Commit(); commitErr != nil {
return File{}, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
file = s.fromDBItem(dbFile)
s.broker.Publish(eventType, file)
return file, nil // Success
}
return File{}, fmt.Errorf("failed to create file after %d retries due to version conflicts: %w", maxRetries, err)
}
func (s *service) Get(ctx context.Context, id string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFile, err := s.db.GetFile(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return File{}, fmt.Errorf("file with ID '%s' not found", id)
}
return File{}, fmt.Errorf("db.GetFile: %w", err)
}
return s.fromDBItem(dbFile), nil
}
func (s *service) GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// sqlc doesn't directly support GetyByPathAndVersionAndSession
// We list and filter. This could be optimized with a custom query if performance is an issue.
allFilesForPath, err := s.db.ListFilesByPath(ctx, path)
if err != nil {
return File{}, fmt.Errorf("db.ListFilesByPath for GetByPathAndVersion: %w", err)
}
for _, dbFile := range allFilesForPath {
if dbFile.SessionID == sessionID && dbFile.Version == version {
return s.fromDBItem(dbFile), nil
}
}
return File{}, fmt.Errorf("file not found for session '%s', path '%s', version '%s'", sessionID, path, version)
}
func (s *service) GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// GetFileByPathAndSession in sqlc already orders by created_at DESC and takes LIMIT 1
dbFile, err := s.db.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
Path: path,
SessionID: sessionID,
})
if err != nil {
if err == sql.ErrNoRows {
return File{}, fmt.Errorf("no file found for path '%s' in session '%s'", path, sessionID)
}
return File{}, fmt.Errorf("db.GetFileByPathAndSession: %w", err)
}
return s.fromDBItem(dbFile), nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListFilesBySession(ctx, sessionID) // Assumes this orders by created_at ASC
if err != nil {
return nil, fmt.Errorf("db.ListFilesBySession: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListLatestSessionFiles(ctx, sessionID) // Uses the specific sqlc query
if err != nil {
return nil, fmt.Errorf("db.ListLatestSessionFiles: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListFilesByPath(ctx, path) // sqlc query orders by created_at DESC
if err != nil {
return nil, fmt.Errorf("db.ListFilesByPath: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) Update(ctx context.Context, file File) (File, error) {
s.mu.Lock()
defer s.mu.Unlock()
if file.ID == "" {
return File{}, fmt.Errorf("cannot update file with empty ID")
}
// UpdatedAt is handled by DB trigger
dbFile, err := s.db.UpdateFile(ctx, db.UpdateFileParams{
ID: file.ID,
Content: file.Content,
Version: file.Version,
})
if err != nil {
return File{}, fmt.Errorf("db.UpdateFile: %w", err)
}
updatedFile := s.fromDBItem(dbFile)
s.broker.Publish(EventFileUpdated, updatedFile)
return updatedFile, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
fileToPublish, err := s.getServiceForPublish(ctx, id) // Use internal method with appropriate locking
s.mu.Unlock()
if err != nil {
if strings.Contains(err.Error(), "not found") {
slog.Warn("Attempted to delete non-existent file history", "id", id)
return nil // Or return specific error if needed
}
return err
}
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteFile(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteFile: %w", err)
}
if fileToPublish != nil {
s.broker.Publish(EventFileDeleted, *fileToPublish)
}
return nil
}
func (s *service) getServiceForPublish(ctx context.Context, id string) (*File, error) {
// Assumes outer lock is NOT held or caller manages it.
// For GetFile, it has its own RLock.
dbFile, err := s.db.GetFile(ctx, id)
if err != nil {
return nil, err
}
file := s.fromDBItem(dbFile)
return &file, nil
}
func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
s.mu.Lock() // Lock for the entire operation
defer s.mu.Unlock()
// Get files first for publishing events
filesToDelete, err := s.db.ListFilesBySession(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.ListFilesBySession for deletion: %w", err)
}
err = s.db.DeleteSessionFiles(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.DeleteSessionFiles: %w", err)
}
for _, dbFile := range filesToDelete {
file := s.fromDBItem(dbFile)
s.broker.Publish(EventFileDeleted, file) // Individual delete events
}
return nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.File) File {
// Parse timestamps from ISO strings
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
createdAt = time.Now() // Fallback
}
updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
updatedAt = time.Now() // Fallback
}
return File{
ID: item.ID,
SessionID: item.SessionID,
Path: item.Path,
Content: item.Content,
Version: item.Version,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
func Create(ctx context.Context, sessionID, path, content string) (File, error) {
return GetService().Create(ctx, sessionID, path, content)
}
func CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
return GetService().CreateVersion(ctx, sessionID, path, content)
}
func Get(ctx context.Context, id string) (File, error) {
return GetService().Get(ctx, id)
}
func GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
return GetService().GetByPathAndVersion(ctx, sessionID, path, version)
}
func GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
return GetService().GetLatestByPathAndSession(ctx, path, sessionID)
}
func ListBySession(ctx context.Context, sessionID string) ([]File, error) {
return GetService().ListBySession(ctx, sessionID)
}
func ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
return GetService().ListLatestSessionFiles(ctx, sessionID)
}
func ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
return GetService().ListVersionsByPath(ctx, path)
}
func Update(ctx context.Context, file File) (File, error) {
return GetService().Update(ctx, file)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func DeleteSessionFiles(ctx context.Context, sessionID string) error {
return GetService().DeleteSessionFiles(ctx, sessionID)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
return GetService().Subscribe(ctx)
}
+8 -6
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/session"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/message"
"github.com/kujtimiihoxha/opencode/internal/session"
)
type agentTool struct {
@@ -88,8 +88,10 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
}
parentSession.Cost += updatedSession.Cost
parentSession.PromptTokens += updatedSession.PromptTokens
parentSession.CompletionTokens += updatedSession.CompletionTokens
_, err = b.sessions.Update(ctx, parentSession)
_, err = b.sessions.Save(ctx, parentSession)
if err != nil {
return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
}
+87 -410
View File
@@ -6,20 +6,16 @@ import (
"fmt"
"strings"
"sync"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/prompt"
"github.com/sst/opencode/internal/llm/provider"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/llm/prompt"
"github.com/kujtimiihoxha/opencode/internal/llm/provider"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/message"
"github.com/kujtimiihoxha/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/session"
)
// Common errors
@@ -42,14 +38,10 @@ func (e *AgentEvent) Response() message.Message {
}
type Service interface {
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error)
Cancel(sessionID string)
IsSessionBusy(sessionID string) bool
IsBusy() bool
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
CompactSession(ctx context.Context, sessionID string, force bool) error
GetUsage(ctx context.Context, sessionID string) (*int64, error)
EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error)
}
type agent struct {
@@ -75,8 +67,8 @@ func NewAgent(
return nil, err
}
var titleProvider provider.Provider
// Only generate titles for the primary agent
if agentName == config.AgentPrimary {
// Only generate titles for the coder agent
if agentName == config.AgentCoder {
titleProvider, err = createAgentProvider(config.AgentTitle)
if err != nil {
return nil, err
@@ -98,7 +90,7 @@ func NewAgent(
func (a *agent) Cancel(sessionID string) {
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
status.Info(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
cancel()
}
}
@@ -124,9 +116,6 @@ func (a *agent) IsSessionBusy(sessionID string) bool {
}
func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
if content == "" {
return nil
}
if a.titleProvider == nil {
return nil
}
@@ -134,13 +123,16 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
if err != nil {
return err
}
parts := []message.ContentPart{message.TextContent{Text: content}}
response, err := a.titleProvider.SendMessages(
ctx,
[]message.Message{
{
Role: message.User,
Parts: parts,
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{
Text: content,
},
},
},
},
make([]tools.BaseTool, 0),
@@ -155,7 +147,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
}
session.Title = title
_, err = a.sessions.Update(ctx, session)
_, err = a.sessions.Save(ctx, session)
return err
}
@@ -165,10 +157,7 @@ func (a *agent) err(err error) AgentEvent {
}
}
func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
if !a.provider.Model().SupportsAttachments && attachments != nil {
attachments = nil
}
func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) {
events := make(chan AgentEvent)
if a.IsSessionBusy(sessionID) {
return nil, ErrSessionBusy
@@ -178,98 +167,49 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
a.activeRequests.Store(sessionID, cancel)
go func() {
slog.Debug("Request started", "sessionID", sessionID)
logging.Debug("Request started", "sessionID", sessionID)
defer logging.RecoverPanic("agent.Run", func() {
events <- a.err(fmt.Errorf("panic while running the agent"))
})
var attachmentParts []message.ContentPart
for _, attachment := range attachments {
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
}
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
result := a.processGeneration(genCtx, sessionID, content)
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
status.Error(result.Err().Error())
logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result))
}
slog.Debug("Request completed", "sessionID", sessionID)
logging.Debug("Request completed", "sessionID", sessionID)
a.activeRequests.Delete(sessionID)
cancel()
events <- result
close(events)
}()
return events, nil
}
func (a *agent) prepareMessageHistory(ctx context.Context, sessionID string) (session.Session, []message.Message, error) {
currentSession, err := a.sessions.Get(ctx, sessionID)
func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent {
// List existing messages; if none, start title generation asynchronously.
msgs, err := a.messages.List(ctx, sessionID)
if err != nil {
return currentSession, nil, fmt.Errorf("failed to get session: %w", err)
return a.err(fmt.Errorf("failed to list messages: %w", err))
}
if len(msgs) == 0 {
go func() {
defer logging.RecoverPanic("agent.Run", func() {
logging.ErrorPersist("panic while generating title")
})
titleErr := a.generateTitle(context.Background(), sessionID, content)
if titleErr != nil {
logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr))
}
}()
}
var sessionMessages []message.Message
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
// If summary exists, only fetch messages after the summarization timestamp
sessionMessages, err = a.messages.ListAfter(ctx, sessionID, currentSession.SummarizedAt)
if err != nil {
return currentSession, nil, fmt.Errorf("failed to list messages after summary: %w", err)
}
} else {
// If no summary, fetch all messages
sessionMessages, err = a.messages.List(ctx, sessionID)
if err != nil {
return currentSession, nil, fmt.Errorf("failed to list messages: %w", err)
}
}
var messages []message.Message
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
// If summary exists, create a temporary message for the summary
summaryMessage := message.Message{
Role: message.Assistant,
Parts: []message.ContentPart{
message.TextContent{Text: currentSession.Summary},
},
}
// Start with the summary, then add messages after the summary timestamp
messages = append([]message.Message{summaryMessage}, sessionMessages...)
} else {
// If no summary, just use all messages
messages = sessionMessages
}
return currentSession, messages, nil
}
func (a *agent) triggerTitleGeneration(sessionID string, content string) {
go func() {
defer logging.RecoverPanic("agent.Run", func() {
status.Error("panic while generating title")
})
titleErr := a.generateTitle(context.Background(), sessionID, content)
if titleErr != nil {
status.Error(fmt.Sprintf("failed to generate title: %v", titleErr))
}
}()
}
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
currentSession, sessionMessages, err := a.prepareMessageHistory(ctx, sessionID)
if err != nil {
return a.err(err)
}
// If this is a new session, start title generation asynchronously
if len(sessionMessages) == 0 && currentSession.Summary == "" {
a.triggerTitleGeneration(sessionID, content)
}
userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
userMsg, err := a.createUserMessage(ctx, sessionID, content)
if err != nil {
return a.err(fmt.Errorf("failed to create user message: %w", err))
}
messages := append(sessionMessages, userMsg)
// Append the new user message to the conversation history.
msgHistory := append(msgs, userMsg)
for {
// Check for cancellation before each iteration
select {
@@ -278,42 +218,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
default:
// Continue processing
}
// Check if auto-compaction is needed before calling the provider
usagePercentage, needsCompaction, errEstimate := a.EstimateContextWindowUsage(ctx, sessionID)
if errEstimate != nil {
slog.Warn("Failed to estimate context window usage for auto-compaction", "error", errEstimate, "sessionID", sessionID)
} else if needsCompaction {
status.Info(fmt.Sprintf("Context window usage is at %.2f%%. Auto-compacting conversation...", usagePercentage))
// Run compaction synchronously
compactCtx, cancelCompact := context.WithTimeout(ctx, 30*time.Second) // Use appropriate context
errCompact := a.CompactSession(compactCtx, sessionID, true)
cancelCompact()
if errCompact != nil {
status.Warn(fmt.Sprintf("Auto-compaction failed: %v. Context window usage may continue to grow.", errCompact))
} else {
status.Info("Auto-compaction completed successfully.")
// After compaction, message history needs to be re-prepared.
// The 'messages' slice needs to be updated with the new summary and subsequent messages,
// ensuring the latest user message is correctly appended.
_, sessionMessagesFromCompact, errPrepare := a.prepareMessageHistory(ctx, sessionID)
if errPrepare != nil {
return a.err(fmt.Errorf("failed to re-prepare message history after compaction: %w", errPrepare))
}
messages = sessionMessagesFromCompact
// Ensure the user message that triggered this cycle is the last one.
// 'userMsg' was created before this loop using a.createUserMessage.
// It should be appended to the 'messages' slice if it's not already the last element.
if len(messages) == 0 || (len(messages) > 0 && messages[len(messages)-1].ID != userMsg.ID) {
messages = append(messages, userMsg)
}
}
}
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, messages)
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
if err != nil {
if errors.Is(err, context.Canceled) {
agentMessage.AddFinish(message.FinishReasonCanceled)
@@ -322,10 +227,10 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
}
return a.err(fmt.Errorf("failed to process events: %w", err))
}
slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
// We are not done, we need to respond with the tool response
messages = append(messages, agentMessage, *toolResults)
msgHistory = append(msgHistory, agentMessage, *toolResults)
continue
}
return AgentEvent{
@@ -334,36 +239,15 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
}
}
func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
parts := []message.ContentPart{message.TextContent{Text: content}}
parts = append(parts, attachmentParts...)
func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) {
return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
Role: message.User,
Parts: parts,
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{Text: content},
},
})
}
func (a *agent) createToolResponseMessage(ctx context.Context, sessionID string, toolResults []message.ToolResult) (*message.Message, error) {
if len(toolResults) == 0 {
return nil, nil
}
parts := make([]message.ContentPart, 0, len(toolResults))
for _, tr := range toolResults {
parts = append(parts, tr)
}
msg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: parts,
})
if err != nil {
return nil, fmt.Errorf("failed to create tool response message: %w", err)
}
return &msg, nil
}
func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
@@ -392,37 +276,12 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
}
}
// If the assistant wants to use tools, execute them
if assistantMsg.FinishReason() == message.FinishReasonToolUse {
toolCalls := assistantMsg.ToolCalls()
if len(toolCalls) > 0 {
toolResults, err := a.executeToolCalls(ctx, toolCalls)
if err != nil {
if errors.Is(err, context.Canceled) {
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
}
return assistantMsg, nil, err
}
// Create a message with the tool results
toolResponseMsg, err := a.createToolResponseMessage(ctx, sessionID, toolResults)
if err != nil {
return assistantMsg, nil, err
}
return assistantMsg, toolResponseMsg, nil
}
}
return assistantMsg, nil, nil
}
func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCall) ([]message.ToolResult, error) {
toolResults := make([]message.ToolResult, len(toolCalls))
toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
toolCalls := assistantMsg.ToolCalls()
for i, toolCall := range toolCalls {
select {
case <-ctx.Done():
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
// Make all future tool calls cancelled
for j := i; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
@@ -431,7 +290,7 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
IsError: true,
}
}
return toolResults, ctx.Err()
goto out
default:
// Continue processing
var tool tools.BaseTool
@@ -456,7 +315,6 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
Name: toolCall.Name,
Input: toolCall.Input,
})
if toolErr != nil {
if errors.Is(toolErr, permission.ErrorPermissionDenied) {
toolResults[i] = message.ToolResult{
@@ -464,7 +322,6 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
Content: "Permission denied",
IsError: true,
}
// Cancel all remaining tool calls if permission is denied
for j := i + 1; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
ToolCallID: toolCalls[j].ID,
@@ -472,18 +329,10 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
IsError: true,
}
}
return toolResults, nil
a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
break
}
// Handle other errors
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: toolErr.Error(),
IsError: true,
}
continue
}
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: toolResult.Content,
@@ -492,13 +341,28 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
}
}
}
out:
if len(toolResults) == 0 {
return assistantMsg, nil, nil
}
parts := make([]message.ContentPart, 0)
for _, tr := range toolResults {
parts = append(parts, tr)
}
msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: parts,
})
if err != nil {
return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
}
return toolResults, nil
return assistantMsg, &msg, err
}
func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
msg.AddFinish(finishReson)
_, _ = a.messages.Update(ctx, *msg)
_ = a.messages.Update(ctx, *msg)
}
func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
@@ -506,22 +370,19 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
case <-ctx.Done():
return ctx.Err()
default:
// Continue processing
// Continue processing.
}
switch event.Type {
case provider.EventThinkingDelta:
assistantMsg.AppendReasoningContent(event.Content)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventContentDelta:
assistantMsg.AppendContent(event.Content)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventToolUseStart:
assistantMsg.AddToolCall(*event.ToolCall)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
// TODO: see how to handle this
// case provider.EventToolUseDelta:
// tm := time.Unix(assistantMsg.UpdatedAt, 0)
@@ -533,19 +394,18 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
// }
case provider.EventToolUseStop:
assistantMsg.FinishToolCall(event.ToolCall.ID)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventError:
if errors.Is(event.Error, context.Canceled) {
status.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
return context.Canceled
}
status.Error(event.Error.Error())
logging.ErrorPersist(event.Error.Error())
return event.Error
case provider.EventComplete:
assistantMsg.SetToolCalls(event.Response.ToolCalls)
assistantMsg.AddFinish(event.Response.FinishReason)
if _, err := a.messages.Update(ctx, *assistantMsg); err != nil {
if err := a.messages.Update(ctx, *assistantMsg); err != nil {
return fmt.Errorf("failed to update message: %w", err)
}
return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
@@ -554,49 +414,6 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
return nil
}
func (a *agent) GetUsage(ctx context.Context, sessionID string) (*int64, error) {
session, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
usage := session.PromptTokens + session.CompletionTokens
return &usage, nil
}
func (a *agent) EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error) {
session, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return 0, false, fmt.Errorf("failed to get session: %w", err)
}
// Get the model's context window size
model := a.provider.Model()
contextWindow := model.ContextWindow
if contextWindow <= 0 {
// Default to a reasonable size if not specified
contextWindow = 100000
}
// Calculate current token usage
currentTokens := session.PromptTokens + session.CompletionTokens
// Get the max tokens setting for the agent
maxTokens := a.provider.MaxTokens()
// Calculate percentage of context window used
usagePercentage := float64(currentTokens) / float64(contextWindow)
// Check if we need to auto-compact
// Auto-compact when:
// 1. Usage exceeds 90% of context window, OR
// 2. Current usage + maxTokens would exceed 100% of context window
needsCompaction := usagePercentage >= 0.9 ||
float64(currentTokens+maxTokens) > float64(contextWindow)
return usagePercentage * 100, needsCompaction, nil
}
func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
sess, err := a.sessions.Get(ctx, sessionID)
if err != nil {
@@ -609,156 +426,16 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
sess.Cost += cost
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
sess.CompletionTokens += usage.OutputTokens
sess.PromptTokens += usage.InputTokens
_, err = a.sessions.Update(ctx, sess)
_, err = a.sessions.Save(ctx, sess)
if err != nil {
return fmt.Errorf("failed to save session: %w", err)
}
return nil
}
func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) {
if a.IsBusy() {
return models.Model{}, fmt.Errorf("cannot change model while processing requests")
}
if err := config.UpdateAgentModel(agentName, modelID); err != nil {
return models.Model{}, fmt.Errorf("failed to update config: %w", err)
}
provider, err := createAgentProvider(agentName)
if err != nil {
return models.Model{}, fmt.Errorf("failed to create provider for model %s: %w", modelID, err)
}
a.provider = provider
return a.provider.Model(), nil
}
func (a *agent) CompactSession(ctx context.Context, sessionID string, force bool) error {
// Check if the session is busy
if a.IsSessionBusy(sessionID) && !force {
return ErrSessionBusy
}
// Create a cancellable context
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Mark the session as busy during compaction
compactionCancelFunc := func() {}
a.activeRequests.Store(sessionID+"-compact", compactionCancelFunc)
defer a.activeRequests.Delete(sessionID + "-compact")
// Fetch the session
session, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
// Fetch all messages for the session
sessionMessages, err := a.messages.List(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to list messages: %w", err)
}
var existingSummary string
if session.Summary != "" && !session.SummarizedAt.IsZero() {
// Filter messages that were created after the last summarization
var newMessages []message.Message
for _, msg := range sessionMessages {
if msg.CreatedAt.After(session.SummarizedAt) {
newMessages = append(newMessages, msg)
}
}
sessionMessages = newMessages
existingSummary = session.Summary
}
// If there are no messages to summarize and no existing summary, return early
if len(sessionMessages) == 0 && existingSummary == "" {
return nil
}
messages := []message.Message{
message.Message{
Role: message.System,
Parts: []message.ContentPart{
message.TextContent{
Text: `You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`,
},
},
},
}
// If there's an existing summary, include it
if existingSummary != "" {
messages = append(messages, message.Message{
Role: message.Assistant,
Parts: []message.ContentPart{
message.TextContent{
Text: existingSummary,
},
},
})
}
// Add all messages since the last summarized message
messages = append(messages, sessionMessages...)
// Add a final user message requesting the summary
messages = append(messages, message.Message{
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{
Text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
},
},
})
// Call provider to get the summary
response, err := a.provider.SendMessages(ctx, messages, a.tools)
if err != nil {
return fmt.Errorf("failed to get summary from the assistant: %w", err)
}
// Extract the summary text
summaryText := strings.TrimSpace(response.Content)
if summaryText == "" {
return fmt.Errorf("received empty summary from the assistant")
}
// Update the session with the new summary
session.Summary = summaryText
session.SummarizedAt = time.Now()
// Save the updated session
_, err = a.sessions.Update(ctx, session)
if err != nil {
return fmt.Errorf("failed to save session with summary: %w", err)
}
// Track token usage
err = a.TrackUsage(ctx, sessionID, a.provider.Model(), response.Usage)
if err != nil {
return fmt.Errorf("failed to track usage: %w", err)
}
return nil
}
func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
cfg := config.Get()
agentConfig, ok := cfg.Agents[agentName]
@@ -794,7 +471,7 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
provider.WithReasoningEffort(agentConfig.ReasoningEffort),
),
)
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentPrimary {
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
opts = append(
opts,
provider.WithAnthropicOptions(
+10 -11
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/version"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/version"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
@@ -58,7 +58,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
toolRequest := mcp.CallToolRequest{}
toolRequest.Params.Name = toolName
var args map[string]any
if err = json.Unmarshal([]byte(input), &args); err != nil {
if err = json.Unmarshal([]byte(input), &input); err != nil {
return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
toolRequest.Params.Arguments = args
@@ -86,7 +86,6 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
}
permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
p := b.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
@@ -147,13 +146,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
_, err := c.Initialize(ctx, initRequest)
if err != nil {
slog.Error("error initializing mcp client", "error", err)
logging.Error("error initializing mcp client", "error", err)
return stdioTools
}
toolsRequest := mcp.ListToolsRequest{}
tools, err := c.ListTools(ctx, toolsRequest)
if err != nil {
slog.Error("error listing tools", "error", err)
logging.Error("error listing tools", "error", err)
return stdioTools
}
for _, t := range tools.Tools {
@@ -176,7 +175,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
m.Args...,
)
if err != nil {
slog.Error("error creating mcp client", "error", err)
logging.Error("error creating mcp client", "error", err)
continue
}
@@ -187,7 +186,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
client.WithHeaders(m.Headers),
)
if err != nil {
slog.Error("error creating mcp client", "error", err)
logging.Error("error creating mcp client", "error", err)
continue
}
mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
+14 -19
View File
@@ -3,15 +3,15 @@ package agent
import (
"context"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/kujtimiihoxha/opencode/internal/history"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/message"
"github.com/kujtimiihoxha/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/session"
)
func PrimaryAgentTools(
func CoderAgentTools(
permissions permission.Service,
sessions session.Service,
messages message.Service,
@@ -19,8 +19,10 @@ func PrimaryAgentTools(
lspClients map[string]*lsp.Client,
) []tools.BaseTool {
ctx := context.Background()
mcpTools := GetMcpTools(ctx, permissions)
otherTools := GetMcpTools(ctx, permissions)
if len(lspClients) > 0 {
otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
}
return append(
[]tools.BaseTool{
tools.NewBashTool(permissions),
@@ -29,16 +31,12 @@ func PrimaryAgentTools(
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients),
tools.NewPatchTool(lspClients, permissions, history),
tools.NewWriteTool(lspClients, permissions, history),
tools.NewDiagnosticsTool(lspClients),
tools.NewDefinitionTool(lspClients),
tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients),
NewAgentTool(sessions, messages, lspClients),
}, mcpTools...,
}, otherTools...,
)
}
@@ -47,10 +45,7 @@ func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients),
tools.NewDefinitionTool(lspClients),
tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients),
}
}
+52 -57
View File
@@ -11,72 +11,67 @@ const (
Claude3Opus ModelID = "claude-3-opus"
)
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
var AnthropicModels = map[ModelID]Model{
// Anthropic
Claude35Sonnet: {
ID: Claude35Sonnet,
Name: "Claude 3.5 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 5000,
SupportsAttachments: true,
ID: Claude35Sonnet,
Name: "Claude 3.5 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 5000,
},
Claude3Haiku: {
ID: Claude3Haiku,
Name: "Claude 3 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
CostPer1MIn: 0.25,
CostPer1MInCached: 0.30,
CostPer1MOutCached: 0.03,
CostPer1MOut: 1.25,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
ID: Claude3Haiku,
Name: "Claude 3 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-haiku-latest",
CostPer1MIn: 0.25,
CostPer1MInCached: 0.30,
CostPer1MOutCached: 0.03,
CostPer1MOut: 1.25,
ContextWindow: 200000,
DefaultMaxTokens: 5000,
},
Claude37Sonnet: {
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-latest",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200000,
DefaultMaxTokens: 50000,
CanReason: true,
},
Claude35Haiku: {
ID: Claude35Haiku,
Name: "Claude 3.5 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-haiku-latest",
CostPer1MIn: 0.80,
CostPer1MInCached: 1.0,
CostPer1MOutCached: 0.08,
CostPer1MOut: 4.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
ID: Claude35Haiku,
Name: "Claude 3.5 Haiku",
Provider: ProviderAnthropic,
APIModel: "claude-3-5-haiku-latest",
CostPer1MIn: 0.80,
CostPer1MInCached: 1.0,
CostPer1MOutCached: 0.08,
CostPer1MOut: 4.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
},
Claude3Opus: {
ID: Claude3Opus,
Name: "Claude 3 Opus",
Provider: ProviderAnthropic,
APIModel: "claude-3-opus-latest",
CostPer1MIn: 15.0,
CostPer1MInCached: 18.75,
CostPer1MOutCached: 1.50,
CostPer1MOut: 75.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
ID: Claude3Opus,
Name: "Claude 3 Opus",
Provider: ProviderAnthropic,
APIModel: "claude-3-opus-latest",
CostPer1MIn: 15.0,
CostPer1MInCached: 18.75,
CostPer1MOutCached: 1.50,
CostPer1MOut: 75.0,
ContextWindow: 200000,
DefaultMaxTokens: 4096,
},
}
-168
View File
@@ -1,168 +0,0 @@
package models
const ProviderAzure ModelProvider = "azure"
const (
AzureGPT41 ModelID = "azure.gpt-4.1"
AzureGPT41Mini ModelID = "azure.gpt-4.1-mini"
AzureGPT41Nano ModelID = "azure.gpt-4.1-nano"
AzureGPT45Preview ModelID = "azure.gpt-4.5-preview"
AzureGPT4o ModelID = "azure.gpt-4o"
AzureGPT4oMini ModelID = "azure.gpt-4o-mini"
AzureO1 ModelID = "azure.o1"
AzureO1Mini ModelID = "azure.o1-mini"
AzureO3 ModelID = "azure.o3"
AzureO3Mini ModelID = "azure.o3-mini"
AzureO4Mini ModelID = "azure.o4-mini"
)
var AzureModels = map[ModelID]Model{
AzureGPT41: {
ID: AzureGPT41,
Name: "Azure OpenAI GPT 4.1",
Provider: ProviderAzure,
APIModel: "gpt-4.1",
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT41Mini: {
ID: AzureGPT41Mini,
Name: "Azure OpenAI GPT 4.1 mini",
Provider: ProviderAzure,
APIModel: "gpt-4.1-mini",
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT41Nano: {
ID: AzureGPT41Nano,
Name: "Azure OpenAI GPT 4.1 nano",
Provider: ProviderAzure,
APIModel: "gpt-4.1-nano",
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT45Preview: {
ID: AzureGPT45Preview,
Name: "Azure OpenAI GPT 4.5 preview",
Provider: ProviderAzure,
APIModel: "gpt-4.5-preview",
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT4o: {
ID: AzureGPT4o,
Name: "Azure OpenAI GPT-4o",
Provider: ProviderAzure,
APIModel: "gpt-4o",
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureGPT4oMini: {
ID: AzureGPT4oMini,
Name: "Azure OpenAI GPT-4o mini",
Provider: ProviderAzure,
APIModel: "gpt-4o-mini",
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
SupportsAttachments: true,
},
AzureO1: {
ID: AzureO1,
Name: "Azure OpenAI O1",
Provider: ProviderAzure,
APIModel: "o1",
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
CanReason: OpenAIModels[O1].CanReason,
SupportsAttachments: true,
},
AzureO1Mini: {
ID: AzureO1Mini,
Name: "Azure OpenAI O1 mini",
Provider: ProviderAzure,
APIModel: "o1-mini",
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O1Mini].CanReason,
SupportsAttachments: true,
},
AzureO3: {
ID: AzureO3,
Name: "Azure OpenAI O3",
Provider: ProviderAzure,
APIModel: "o3",
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
CanReason: OpenAIModels[O3].CanReason,
SupportsAttachments: true,
},
AzureO3Mini: {
ID: AzureO3Mini,
Name: "Azure OpenAI O3 mini",
Provider: ProviderAzure,
APIModel: "o3-mini",
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O3Mini].CanReason,
SupportsAttachments: false,
},
AzureO4Mini: {
ID: AzureO4Mini,
Name: "Azure OpenAI O4 mini",
Provider: ProviderAzure,
APIModel: "o4-mini",
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O4Mini].CanReason,
SupportsAttachments: true,
},
}
+40 -44
View File
@@ -12,56 +12,52 @@ const (
var GeminiModels = map[ModelID]Model{
Gemini25Flash: {
ID: Gemini25Flash,
Name: "Gemini 2.5 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.5-flash-preview-04-17",
CostPer1MIn: 0.15,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.60,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
SupportsAttachments: true,
ID: Gemini25Flash,
Name: "Gemini 2.5 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.5-flash-preview-04-17",
CostPer1MIn: 0.15,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.60,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
},
Gemini25: {
ID: Gemini25,
Name: "Gemini 2.5 Pro",
Provider: ProviderGemini,
APIModel: "gemini-2.5-pro-preview-03-25",
CostPer1MIn: 1.25,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 10,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
SupportsAttachments: true,
ID: Gemini25,
Name: "Gemini 2.5 Pro",
Provider: ProviderGemini,
APIModel: "gemini-2.5-pro-preview-03-25",
CostPer1MIn: 1.25,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 10,
ContextWindow: 1000000,
DefaultMaxTokens: 50000,
},
Gemini20Flash: {
ID: Gemini20Flash,
Name: "Gemini 2.0 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash",
CostPer1MIn: 0.10,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.40,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
SupportsAttachments: true,
ID: Gemini20Flash,
Name: "Gemini 2.0 Flash",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash",
CostPer1MIn: 0.10,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.40,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
},
Gemini20FlashLite: {
ID: Gemini20FlashLite,
Name: "Gemini 2.0 Flash Lite",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash-lite",
CostPer1MIn: 0.05,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.30,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
SupportsAttachments: true,
ID: Gemini20FlashLite,
Name: "Gemini 2.0 Flash Lite",
Provider: ProviderGemini,
APIModel: "gemini-2.0-flash-lite",
CostPer1MIn: 0.05,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.30,
ContextWindow: 1000000,
DefaultMaxTokens: 6000,
},
}
-87
View File
@@ -1,87 +0,0 @@
package models
const (
ProviderGROQ ModelProvider = "groq"
// GROQ
QWENQwq ModelID = "qwen-qwq"
// GROQ preview models
Llama4Scout ModelID = "meta-llama/llama-4-scout-17b-16e-instruct"
Llama4Maverick ModelID = "meta-llama/llama-4-maverick-17b-128e-instruct"
Llama3_3_70BVersatile ModelID = "llama-3.3-70b-versatile"
DeepseekR1DistillLlama70b ModelID = "deepseek-r1-distill-llama-70b"
)
var GroqModels = map[ModelID]Model{
//
// GROQ
QWENQwq: {
ID: QWENQwq,
Name: "Qwen Qwq",
Provider: ProviderGROQ,
APIModel: "qwen-qwq-32b",
CostPer1MIn: 0.29,
CostPer1MInCached: 0.275,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.39,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
// for some reason, the groq api doesn't like the reasoningEffort parameter
CanReason: false,
SupportsAttachments: false,
},
Llama4Scout: {
ID: Llama4Scout,
Name: "Llama4Scout",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
CostPer1MIn: 0.11,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.34,
ContextWindow: 128_000, // 10M when?
SupportsAttachments: true,
},
Llama4Maverick: {
ID: Llama4Maverick,
Name: "Llama4Maverick",
Provider: ProviderGROQ,
APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
CostPer1MIn: 0.20,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.20,
ContextWindow: 128_000,
SupportsAttachments: true,
},
Llama3_3_70BVersatile: {
ID: Llama3_3_70BVersatile,
Name: "Llama3_3_70BVersatile",
Provider: ProviderGROQ,
APIModel: "llama-3.3-70b-versatile",
CostPer1MIn: 0.59,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.79,
ContextWindow: 128_000,
SupportsAttachments: false,
},
DeepseekR1DistillLlama70b: {
ID: DeepseekR1DistillLlama70b,
Name: "DeepseekR1DistillLlama70b",
Provider: ProviderGROQ,
APIModel: "deepseek-r1-distill-llama-70b",
CostPer1MIn: 0.75,
CostPer1MInCached: 0,
CostPer1MOutCached: 0,
CostPer1MOut: 0.99,
ContextWindow: 128_000,
CanReason: true,
SupportsAttachments: false,
},
}
+36 -39
View File
@@ -8,43 +8,36 @@ type (
)
type Model struct {
ID ModelID `json:"id"`
Name string `json:"name"`
Provider ModelProvider `json:"provider"`
APIModel string `json:"api_model"`
CostPer1MIn float64 `json:"cost_per_1m_in"`
CostPer1MOut float64 `json:"cost_per_1m_out"`
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
ContextWindow int64 `json:"context_window"`
DefaultMaxTokens int64 `json:"default_max_tokens"`
CanReason bool `json:"can_reason"`
SupportsAttachments bool `json:"supports_attachments"`
ID ModelID `json:"id"`
Name string `json:"name"`
Provider ModelProvider `json:"provider"`
APIModel string `json:"api_model"`
CostPer1MIn float64 `json:"cost_per_1m_in"`
CostPer1MOut float64 `json:"cost_per_1m_out"`
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
ContextWindow int64 `json:"context_window"`
DefaultMaxTokens int64 `json:"default_max_tokens"`
CanReason bool `json:"can_reason"`
}
// Model IDs
const ( // GEMINI
// GROQ
QWENQwq ModelID = "qwen-qwq"
// Bedrock
BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
)
const (
ProviderBedrock ModelProvider = "bedrock"
ProviderGROQ ModelProvider = "groq"
// ForTests
ProviderMock ModelProvider = "__mock"
)
// Providers in order of popularity
var ProviderPopularity = map[ModelProvider]int{
ProviderAnthropic: 1,
ProviderOpenAI: 2,
ProviderGemini: 3,
ProviderGROQ: 4,
ProviderOpenRouter: 5,
ProviderBedrock: 6,
ProviderAzure: 7,
}
var SupportedModels = map[ModelID]Model{
//
// // GEMINI
@@ -70,20 +63,28 @@ var SupportedModels = map[ModelID]Model{
// CostPer1MOut: 0.4,
// },
//
// // GROQ
// QWENQwq: {
// ID: QWENQwq,
// Name: "Qwen Qwq",
// Provider: ProviderGROQ,
// APIModel: "qwen-qwq-32b",
// CostPer1MIn: 0,
// CostPer1MInCached: 0,
// CostPer1MOutCached: 0,
// CostPer1MOut: 0,
// },
//
// // Bedrock
BedrockClaude37Sonnet: {
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200_000,
DefaultMaxTokens: 50_000,
CanReason: true,
SupportsAttachments: true,
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
},
}
@@ -91,8 +92,4 @@ func init() {
maps.Copy(SupportedModels, AnthropicModels)
maps.Copy(SupportedModels, OpenAIModels)
maps.Copy(SupportedModels, GeminiModels)
maps.Copy(SupportedModels, GroqModels)
maps.Copy(SupportedModels, AzureModels)
maps.Copy(SupportedModels, OpenRouterModels)
maps.Copy(SupportedModels, XAIModels)
}
+124 -136
View File
@@ -19,163 +19,151 @@ const (
var OpenAIModels = map[ModelID]Model{
GPT41: {
ID: GPT41,
Name: "GPT 4.1",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 2.00,
CostPer1MInCached: 0.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 8.00,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
ID: GPT41,
Name: "GPT 4.1",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 2.00,
CostPer1MInCached: 0.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 8.00,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
},
GPT41Mini: {
ID: GPT41Mini,
Name: "GPT 4.1 mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 0.40,
CostPer1MInCached: 0.10,
CostPer1MOutCached: 0.0,
CostPer1MOut: 1.60,
ContextWindow: 200_000,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
ID: GPT41Mini,
Name: "GPT 4.1 mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1",
CostPer1MIn: 0.40,
CostPer1MInCached: 0.10,
CostPer1MOutCached: 0.0,
CostPer1MOut: 1.60,
ContextWindow: 200_000,
DefaultMaxTokens: 20000,
},
GPT41Nano: {
ID: GPT41Nano,
Name: "GPT 4.1 nano",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1-nano",
CostPer1MIn: 0.10,
CostPer1MInCached: 0.025,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.40,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
SupportsAttachments: true,
ID: GPT41Nano,
Name: "GPT 4.1 nano",
Provider: ProviderOpenAI,
APIModel: "gpt-4.1-nano",
CostPer1MIn: 0.10,
CostPer1MInCached: 0.025,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.40,
ContextWindow: 1_047_576,
DefaultMaxTokens: 20000,
},
GPT45Preview: {
ID: GPT45Preview,
Name: "GPT 4.5 preview",
Provider: ProviderOpenAI,
APIModel: "gpt-4.5-preview",
CostPer1MIn: 75.00,
CostPer1MInCached: 37.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 150.00,
ContextWindow: 128_000,
DefaultMaxTokens: 15000,
SupportsAttachments: true,
ID: GPT45Preview,
Name: "GPT 4.5 preview",
Provider: ProviderOpenAI,
APIModel: "gpt-4.5-preview",
CostPer1MIn: 75.00,
CostPer1MInCached: 37.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 150.00,
ContextWindow: 128_000,
DefaultMaxTokens: 15000,
},
GPT4o: {
ID: GPT4o,
Name: "GPT 4o",
Provider: ProviderOpenAI,
APIModel: "gpt-4o",
CostPer1MIn: 2.50,
CostPer1MInCached: 1.25,
CostPer1MOutCached: 0.0,
CostPer1MOut: 10.00,
ContextWindow: 128_000,
DefaultMaxTokens: 4096,
SupportsAttachments: true,
ID: GPT4o,
Name: "GPT 4o",
Provider: ProviderOpenAI,
APIModel: "gpt-4o",
CostPer1MIn: 2.50,
CostPer1MInCached: 1.25,
CostPer1MOutCached: 0.0,
CostPer1MOut: 10.00,
ContextWindow: 128_000,
DefaultMaxTokens: 4096,
},
GPT4oMini: {
ID: GPT4oMini,
Name: "GPT 4o mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4o-mini",
CostPer1MIn: 0.15,
CostPer1MInCached: 0.075,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.60,
ContextWindow: 128_000,
SupportsAttachments: true,
ID: GPT4oMini,
Name: "GPT 4o mini",
Provider: ProviderOpenAI,
APIModel: "gpt-4o-mini",
CostPer1MIn: 0.15,
CostPer1MInCached: 0.075,
CostPer1MOutCached: 0.0,
CostPer1MOut: 0.60,
ContextWindow: 128_000,
},
O1: {
ID: O1,
Name: "O1",
Provider: ProviderOpenAI,
APIModel: "o1",
CostPer1MIn: 15.00,
CostPer1MInCached: 7.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 60.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
ID: O1,
Name: "O1",
Provider: ProviderOpenAI,
APIModel: "o1",
CostPer1MIn: 15.00,
CostPer1MInCached: 7.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 60.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
},
O1Pro: {
ID: O1Pro,
Name: "o1 pro",
Provider: ProviderOpenAI,
APIModel: "o1-pro",
CostPer1MIn: 150.00,
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 600.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
ID: O1Pro,
Name: "o1 pro",
Provider: ProviderOpenAI,
APIModel: "o1-pro",
CostPer1MIn: 150.00,
CostPer1MInCached: 0.0,
CostPer1MOutCached: 0.0,
CostPer1MOut: 600.00,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
},
O1Mini: {
ID: O1Mini,
Name: "o1 mini",
Provider: ProviderOpenAI,
APIModel: "o1-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
ID: O1Mini,
Name: "o1 mini",
Provider: ProviderOpenAI,
APIModel: "o1-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
},
O3: {
ID: O3,
Name: "o3",
Provider: ProviderOpenAI,
APIModel: "o3",
CostPer1MIn: 10.00,
CostPer1MInCached: 2.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 40.00,
ContextWindow: 200_000,
CanReason: true,
SupportsAttachments: true,
ID: O3,
Name: "o3",
Provider: ProviderOpenAI,
APIModel: "o3",
CostPer1MIn: 10.00,
CostPer1MInCached: 2.50,
CostPer1MOutCached: 0.0,
CostPer1MOut: 40.00,
ContextWindow: 200_000,
CanReason: true,
},
O3Mini: {
ID: O3Mini,
Name: "o3 mini",
Provider: ProviderOpenAI,
APIModel: "o3-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: false,
ID: O3Mini,
Name: "o3 mini",
Provider: ProviderOpenAI,
APIModel: "o3-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.55,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 200_000,
DefaultMaxTokens: 50000,
CanReason: true,
},
O4Mini: {
ID: O4Mini,
Name: "o4 mini",
Provider: ProviderOpenAI,
APIModel: "o4-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.275,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
SupportsAttachments: true,
ID: O4Mini,
Name: "o4 mini",
Provider: ProviderOpenAI,
APIModel: "o4-mini",
CostPer1MIn: 1.10,
CostPer1MInCached: 0.275,
CostPer1MOutCached: 0.0,
CostPer1MOut: 4.40,
ContextWindow: 128_000,
DefaultMaxTokens: 50000,
CanReason: true,
},
}
-327
View File
@@ -1,327 +0,0 @@
package models
const (
ProviderOpenRouter ModelProvider = "openrouter"
OpenRouterGPT41 ModelID = "openrouter.gpt-4.1"
OpenRouterGPT41Mini ModelID = "openrouter.gpt-4.1-mini"
OpenRouterGPT41Nano ModelID = "openrouter.gpt-4.1-nano"
OpenRouterGPT45Preview ModelID = "openrouter.gpt-4.5-preview"
OpenRouterGPT4o ModelID = "openrouter.gpt-4o"
OpenRouterGPT4oMini ModelID = "openrouter.gpt-4o-mini"
OpenRouterO1 ModelID = "openrouter.o1"
OpenRouterO1Pro ModelID = "openrouter.o1-pro"
OpenRouterO1Mini ModelID = "openrouter.o1-mini"
OpenRouterO3 ModelID = "openrouter.o3"
OpenRouterO3Mini ModelID = "openrouter.o3-mini"
OpenRouterO4Mini ModelID = "openrouter.o4-mini"
OpenRouterGemini25Flash ModelID = "openrouter.gemini-2.5-flash"
OpenRouterGemini25 ModelID = "openrouter.gemini-2.5"
OpenRouterClaude35Sonnet ModelID = "openrouter.claude-3.5-sonnet"
OpenRouterClaude3Haiku ModelID = "openrouter.claude-3-haiku"
OpenRouterClaude37Sonnet ModelID = "openrouter.claude-3.7-sonnet"
OpenRouterClaude35Haiku ModelID = "openrouter.claude-3.5-haiku"
OpenRouterClaude3Opus ModelID = "openrouter.claude-3-opus"
OpenRouterQwen235B ModelID = "openrouter.qwen-3-235b"
OpenRouterQwen32B ModelID = "openrouter.qwen-3-32b"
OpenRouterQwen30B ModelID = "openrouter.qwen-3-30b"
OpenRouterQwen14B ModelID = "openrouter.qwen-3-14b"
OpenRouterQwen8B ModelID = "openrouter.qwen-3-8b"
)
var OpenRouterModels = map[ModelID]Model{
OpenRouterGPT41: {
ID: OpenRouterGPT41,
Name: "OpenRouter: GPT 4.1",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1",
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
},
OpenRouterGPT41Mini: {
ID: OpenRouterGPT41Mini,
Name: "OpenRouter: GPT 4.1 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1-mini",
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
},
OpenRouterGPT41Nano: {
ID: OpenRouterGPT41Nano,
Name: "OpenRouter: GPT 4.1 nano",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1-nano",
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
},
OpenRouterGPT45Preview: {
ID: OpenRouterGPT45Preview,
Name: "OpenRouter: GPT 4.5 preview",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.5-preview",
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
},
OpenRouterGPT4o: {
ID: OpenRouterGPT4o,
Name: "OpenRouter: GPT 4o",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4o",
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
},
OpenRouterGPT4oMini: {
ID: OpenRouterGPT4oMini,
Name: "OpenRouter: GPT 4o mini",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4o-mini",
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
},
OpenRouterO1: {
ID: OpenRouterO1,
Name: "OpenRouter: O1",
Provider: ProviderOpenRouter,
APIModel: "openai/o1",
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
CanReason: OpenAIModels[O1].CanReason,
},
OpenRouterO1Pro: {
ID: OpenRouterO1Pro,
Name: "OpenRouter: o1 pro",
Provider: ProviderOpenRouter,
APIModel: "openai/o1-pro",
CostPer1MIn: OpenAIModels[O1Pro].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1Pro].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1Pro].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1Pro].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1Pro].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1Pro].DefaultMaxTokens,
CanReason: OpenAIModels[O1Pro].CanReason,
},
OpenRouterO1Mini: {
ID: OpenRouterO1Mini,
Name: "OpenRouter: o1 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o1-mini",
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O1Mini].CanReason,
},
OpenRouterO3: {
ID: OpenRouterO3,
Name: "OpenRouter: o3",
Provider: ProviderOpenRouter,
APIModel: "openai/o3",
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
CanReason: OpenAIModels[O3].CanReason,
},
OpenRouterO3Mini: {
ID: OpenRouterO3Mini,
Name: "OpenRouter: o3 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o3-mini-high",
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O3Mini].CanReason,
},
OpenRouterO4Mini: {
ID: OpenRouterO4Mini,
Name: "OpenRouter: o4 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o4-mini-high",
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
CanReason: OpenAIModels[O4Mini].CanReason,
},
OpenRouterGemini25Flash: {
ID: OpenRouterGemini25Flash,
Name: "OpenRouter: Gemini 2.5 Flash",
Provider: ProviderOpenRouter,
APIModel: "google/gemini-2.5-flash-preview:thinking",
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
},
OpenRouterGemini25: {
ID: OpenRouterGemini25,
Name: "OpenRouter: Gemini 2.5 Pro",
Provider: ProviderOpenRouter,
APIModel: "google/gemini-2.5-pro-preview-03-25",
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
ContextWindow: GeminiModels[Gemini25].ContextWindow,
DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
},
OpenRouterClaude35Sonnet: {
ID: OpenRouterClaude35Sonnet,
Name: "OpenRouter: Claude 3.5 Sonnet",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.5-sonnet",
CostPer1MIn: AnthropicModels[Claude35Sonnet].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude35Sonnet].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude35Sonnet].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude35Sonnet].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude35Sonnet].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude35Sonnet].DefaultMaxTokens,
},
OpenRouterClaude3Haiku: {
ID: OpenRouterClaude3Haiku,
Name: "OpenRouter: Claude 3 Haiku",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3-haiku",
CostPer1MIn: AnthropicModels[Claude3Haiku].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude3Haiku].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude3Haiku].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude3Haiku].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude3Haiku].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude3Haiku].DefaultMaxTokens,
},
OpenRouterClaude37Sonnet: {
ID: OpenRouterClaude37Sonnet,
Name: "OpenRouter: Claude 3.7 Sonnet",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.7-sonnet",
CostPer1MIn: AnthropicModels[Claude37Sonnet].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude37Sonnet].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude37Sonnet].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude37Sonnet].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude37Sonnet].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude37Sonnet].DefaultMaxTokens,
CanReason: AnthropicModels[Claude37Sonnet].CanReason,
},
OpenRouterClaude35Haiku: {
ID: OpenRouterClaude35Haiku,
Name: "OpenRouter: Claude 3.5 Haiku",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.5-haiku",
CostPer1MIn: AnthropicModels[Claude35Haiku].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude35Haiku].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude35Haiku].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude35Haiku].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude35Haiku].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude35Haiku].DefaultMaxTokens,
},
OpenRouterClaude3Opus: {
ID: OpenRouterClaude3Opus,
Name: "OpenRouter: Claude 3 Opus",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3-opus",
CostPer1MIn: AnthropicModels[Claude3Opus].CostPer1MIn,
CostPer1MInCached: AnthropicModels[Claude3Opus].CostPer1MInCached,
CostPer1MOut: AnthropicModels[Claude3Opus].CostPer1MOut,
CostPer1MOutCached: AnthropicModels[Claude3Opus].CostPer1MOutCached,
ContextWindow: AnthropicModels[Claude3Opus].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude3Opus].DefaultMaxTokens,
},
OpenRouterQwen235B: {
ID: OpenRouterQwen235B,
Name: "OpenRouter: Qwen3 235B A22B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-235b-a22b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.1,
CostPer1MOutCached: 0.1,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen32B: {
ID: OpenRouterQwen32B,
Name: "OpenRouter: Qwen3 32B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-32b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.3,
CostPer1MOutCached: 0.3,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen30B: {
ID: OpenRouterQwen30B,
Name: "OpenRouter: Qwen3 30B A3B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-30b-a3b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.3,
CostPer1MOutCached: 0.3,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen14B: {
ID: OpenRouterQwen14B,
Name: "OpenRouter: Qwen3 14B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-14b",
CostPer1MIn: 0.7,
CostPer1MInCached: 0.7,
CostPer1MOut: 0.24,
CostPer1MOutCached: 0.24,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen8B: {
ID: OpenRouterQwen8B,
Name: "OpenRouter: Qwen3 8B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-8b",
CostPer1MIn: 0.35,
CostPer1MInCached: 0.35,
CostPer1MOut: 0.138,
CostPer1MOutCached: 0.138,
ContextWindow: 128000,
DefaultMaxTokens: 4096,
},
}
-61
View File
@@ -1,61 +0,0 @@
package models
const (
ProviderXAI ModelProvider = "xai"
XAIGrok3Beta ModelID = "grok-3-beta"
XAIGrok3MiniBeta ModelID = "grok-3-mini-beta"
XAIGrok3FastBeta ModelID = "grok-3-fast-beta"
XAiGrok3MiniFastBeta ModelID = "grok-3-mini-fast-beta"
)
var XAIModels = map[ModelID]Model{
XAIGrok3Beta: {
ID: XAIGrok3Beta,
Name: "Grok3 Beta",
Provider: ProviderXAI,
APIModel: "grok-3-beta",
CostPer1MIn: 3.0,
CostPer1MInCached: 0,
CostPer1MOut: 15,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAIGrok3MiniBeta: {
ID: XAIGrok3MiniBeta,
Name: "Grok3 Mini Beta",
Provider: ProviderXAI,
APIModel: "grok-3-mini-beta",
CostPer1MIn: 0.3,
CostPer1MInCached: 0,
CostPer1MOut: 0.5,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAIGrok3FastBeta: {
ID: XAIGrok3FastBeta,
Name: "Grok3 Fast Beta",
Provider: ProviderXAI,
APIModel: "grok-3-fast-beta",
CostPer1MIn: 5,
CostPer1MInCached: 0,
CostPer1MOut: 25,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
XAiGrok3MiniFastBeta: {
ID: XAiGrok3MiniFastBeta,
Name: "Grok3 Mini Fast Beta",
Provider: ProviderXAI,
APIModel: "grok-3-mini-fast-beta",
CostPer1MIn: 0.6,
CostPer1MInCached: 0,
CostPer1MOut: 4.0,
CostPer1MOutCached: 0,
ContextWindow: 131_072,
DefaultMaxTokens: 20_000,
},
}
@@ -8,23 +8,23 @@ import (
"runtime"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
)
func PrimaryPrompt(provider models.ModelProvider) string {
basePrompt := baseAnthropicPrimaryPrompt
func CoderPrompt(provider models.ModelProvider) string {
basePrompt := baseAnthropicCoderPrompt
switch provider {
case models.ProviderOpenAI:
basePrompt = baseOpenAIPrimaryPrompt
basePrompt = baseOpenAICoderPrompt
}
envInfo := getEnvironmentInfo()
return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
}
const baseOpenAIPrimaryPrompt = `
const baseOpenAICoderPrompt = `
You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
You can:
@@ -71,7 +71,7 @@ You MUST adhere to the following criteria when executing the task:
- Remember the user does not see the full output of tools
`
const baseAnthropicPrimaryPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
@@ -81,7 +81,7 @@ If the current working directory contains a file called OpenCode.md, it will be
2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
3. Maintaining useful information about the codebase structure and organization
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CONTEXT.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CONTEXT.md so you can remember it for next time.
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
@@ -103,7 +103,7 @@ assistant: 4
<example>
user: is 11 a prime number?
assistant: yes
assistant: true
</example>
<example>
+32 -104
View File
@@ -4,19 +4,30 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
)
// contextFiles is a list of potential context files to check for
var contextFiles = []string{
".github/copilot-instructions.md",
".cursorrules",
"CLAUDE.md",
"CLAUDE.local.md",
"opencode.md",
"opencode.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
}
func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
basePrompt := ""
switch agentName {
case config.AgentPrimary:
basePrompt = PrimaryPrompt(provider)
case config.AgentCoder:
basePrompt = CoderPrompt(provider)
case config.AgentTitle:
basePrompt = TitlePrompt(provider)
case config.AgentTask:
@@ -25,111 +36,28 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
basePrompt = "You are a helpful assistant"
}
if agentName == config.AgentPrimary || agentName == config.AgentTask {
if agentName == config.AgentCoder || agentName == config.AgentTask {
// Add context from project-specific instruction files if they exist
contextContent := getContextFromPaths()
slog.Debug("Context content", "Context", contextContent)
contextContent := getContextFromFiles()
if contextContent != "" {
return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent)
}
}
return basePrompt
}
var (
onceContext sync.Once
contextContent string
)
// getContextFromFiles checks for the existence of context files and returns their content
func getContextFromFiles() string {
workDir := config.WorkingDirectory()
var contextContent string
func getContextFromPaths() string {
onceContext.Do(func() {
var (
cfg = config.Get()
workDir = cfg.WorkingDir
contextPaths = cfg.ContextPaths
)
contextContent = processContextPaths(workDir, contextPaths)
})
for _, file := range contextFiles {
filePath := filepath.Join(workDir, file)
content, err := os.ReadFile(filePath)
if err == nil {
contextContent += fmt.Sprintf("\n%s\n", string(content))
}
}
return contextContent
}
func processContextPaths(workDir string, paths []string) string {
var (
wg sync.WaitGroup
resultCh = make(chan string)
)
// Track processed files to avoid duplicates
processedFiles := make(map[string]bool)
var processedMutex sync.Mutex
for _, path := range paths {
wg.Add(1)
go func(p string) {
defer wg.Done()
if strings.HasSuffix(p, "/") {
filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
// Check if we've already processed this file (case-insensitive)
processedMutex.Lock()
lowerPath := strings.ToLower(path)
if !processedFiles[lowerPath] {
processedFiles[lowerPath] = true
processedMutex.Unlock()
if result := processFile(path); result != "" {
resultCh <- result
}
} else {
processedMutex.Unlock()
}
}
return nil
})
} else {
fullPath := filepath.Join(workDir, p)
// Check if we've already processed this file (case-insensitive)
processedMutex.Lock()
lowerPath := strings.ToLower(fullPath)
if !processedFiles[lowerPath] {
processedFiles[lowerPath] = true
processedMutex.Unlock()
result := processFile(fullPath)
if result != "" {
resultCh <- result
}
} else {
processedMutex.Unlock()
}
}
}(path)
}
go func() {
wg.Wait()
close(resultCh)
}()
results := make([]string, 0)
for result := range resultCh {
results = append(results, result)
}
return strings.Join(results, "\n")
}
func processFile(filePath string) string {
content, err := os.ReadFile(filePath)
if err != nil {
return ""
}
return "# From:" + filePath + "\n" + string(content)
}
-61
View File
@@ -1,61 +0,0 @@
package prompt
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/sst/opencode/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetContextFromPaths(t *testing.T) {
t.Parallel()
lvl := new(slog.LevelVar)
lvl.Set(slog.LevelDebug)
tmpDir := t.TempDir()
_, err := config.Load(tmpDir, false, lvl)
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
cfg := config.Get()
cfg.WorkingDir = tmpDir
cfg.ContextPaths = []string{
"file.txt",
"directory/",
}
testFiles := []string{
"file.txt",
"directory/file_a.txt",
"directory/file_b.txt",
"directory/file_c.txt",
}
createTestFiles(t, tmpDir, testFiles)
context := getContextFromPaths()
expectedContext := fmt.Sprintf("# From:%s/file.txt\nfile.txt: test content\n# From:%s/directory/file_a.txt\ndirectory/file_a.txt: test content\n# From:%s/directory/file_b.txt\ndirectory/file_b.txt: test content\n# From:%s/directory/file_c.txt\ndirectory/file_c.txt: test content", tmpDir, tmpDir, tmpDir, tmpDir)
assert.Equal(t, expectedContext, context)
}
func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
t.Helper()
for _, path := range testFiles {
fullPath := filepath.Join(tmpDir, path)
if path[len(path)-1] == '/' {
err := os.MkdirAll(fullPath, 0755)
require.NoError(t, err)
} else {
dir := filepath.Dir(fullPath)
err := os.MkdirAll(dir, 0755)
require.NoError(t, err)
err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
require.NoError(t, err)
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package prompt
import (
"fmt"
"github.com/sst/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
)
func TaskPrompt(_ models.ModelProvider) string {
+2 -3
View File
@@ -1,6 +1,6 @@
package prompt
import "github.com/sst/opencode/internal/llm/models"
import "github.com/kujtimiihoxha/opencode/internal/llm/models"
func TitlePrompt(_ models.ModelProvider) string {
return `you will generate a short title based on the first message a user begins a conversation with
@@ -8,6 +8,5 @@ func TitlePrompt(_ models.ModelProvider) string {
- the title should be a summary of the user's message
- it should be one line long
- do not use quotes or colons
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long`
- the entire text you return will be used as the title`
}
+19 -34
View File
@@ -12,12 +12,10 @@ import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/bedrock"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/message"
)
type anthropicOptions struct {
@@ -72,29 +70,18 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
Type: "ephemeral",
}
}
var contentBlocks []anthropic.ContentBlockParamUnion
contentBlocks = append(contentBlocks, content)
for _, binaryContent := range msg.BinaryContent() {
base64Image := binaryContent.String(models.ProviderAnthropic)
imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
contentBlocks = append(contentBlocks, imageBlock)
}
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content))
case message.Assistant:
blocks := []anthropic.ContentBlockParamUnion{}
if msg.Content() != nil {
content := msg.Content().String()
if strings.TrimSpace(content) != "" {
block := anthropic.NewTextBlock(content)
if cache && !a.options.disableCache {
block.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
}
if msg.Content().String() != "" {
content := anthropic.NewTextBlock(msg.Content().String())
if cache && !a.options.disableCache {
content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
}
blocks = append(blocks, block)
}
blocks = append(blocks, content)
}
for _, toolCall := range msg.ToolCalls() {
@@ -107,7 +94,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
}
if len(blocks) == 0 {
slog.Warn("There is a message without content, investigate, this should not happen")
logging.Warn("There is a message without content, investigate, this should not happen")
continue
}
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
@@ -209,10 +196,9 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(preparedMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
// jsonData, _ := json.Marshal(preparedMessages)
// logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
attempts++
@@ -222,13 +208,12 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
slog.Error("Error in Anthropic API call", "error", err)
retry, after, retryErr := a.shouldRetry(attempts, err)
if retryErr != nil {
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -258,8 +243,8 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(preparedMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
// jsonData, _ := json.Marshal(preparedMessages)
// logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
eventChan := make(chan ProviderEvent)
@@ -277,7 +262,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
event := anthropicStream.Current()
err := accumulatedMessage.Accumulate(event)
if err != nil {
slog.Warn("Error accumulating message", "error", err)
eventChan <- ProviderEvent{Type: EventError, Error: err}
continue
}
@@ -366,7 +351,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
// context cancelled
-47
View File
@@ -1,47 +0,0 @@
package provider
import (
"os"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/openai/openai-go"
"github.com/openai/openai-go/azure"
"github.com/openai/openai-go/option"
)
type azureClient struct {
*openaiClient
}
type AzureClient ProviderClient
func newAzureClient(opts providerClientOptions) AzureClient {
endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com
apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
if endpoint == "" || apiVersion == "" {
return &azureClient{openaiClient: newOpenAIClient(opts).(*openaiClient)}
}
reqOpts := []option.RequestOption{
azure.WithEndpoint(endpoint, apiVersion),
}
if opts.apiKey != "" || os.Getenv("AZURE_OPENAI_API_KEY") != "" {
key := opts.apiKey
if key == "" {
key = os.Getenv("AZURE_OPENAI_API_KEY")
}
reqOpts = append(reqOpts, azure.WithAPIKey(key))
} else if cred, err := azidentity.NewDefaultAzureCredential(nil); err == nil {
reqOpts = append(reqOpts, azure.WithTokenCredential(cred))
}
base := &openaiClient{
providerOptions: opts,
client: openai.NewClient(reqOpts...),
}
return &azureClient{openaiClient: base}
}
+6 -6
View File
@@ -7,8 +7,8 @@ import (
"os"
"strings"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/message"
)
type bedrockOptions struct {
@@ -55,7 +55,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
if strings.Contains(string(opts.model.APIModel), "anthropic") {
// Create Anthropic client with Bedrock configuration
anthropicOpts := opts
anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
WithAnthropicBedrock(true),
WithAnthropicDisableCache(),
)
@@ -84,7 +84,7 @@ func (b *bedrockClient) send(ctx context.Context, messages []message.Message, to
func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
eventChan := make(chan ProviderEvent)
if b.childProvider == nil {
go func() {
eventChan <- ProviderEvent{
@@ -95,6 +95,6 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
}()
return eventChan
}
return b.childProvider.stream(ctx, messages, tools)
}
}
+136 -114
View File
@@ -9,13 +9,14 @@ import (
"strings"
"time"
"github.com/google/generative-ai-go/genai"
"github.com/google/uuid"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"google.golang.org/genai"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/message"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
type geminiOptions struct {
@@ -38,9 +39,9 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
o(&geminiOpts)
}
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey))
if err != nil {
slog.Error("Failed to create Gemini client", "error", err)
logging.Error("Failed to create Gemini client", "error", err)
return nil
}
@@ -53,40 +54,43 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
var history []*genai.Content
// Add system message first
history = append(history, &genai.Content{
Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)},
Role: "user",
})
// Add a system response to acknowledge the system message
history = append(history, &genai.Content{
Parts: []genai.Part{genai.Text("I'll help you with that.")},
Role: "model",
})
for _, msg := range messages {
switch msg.Role {
case message.User:
var parts []*genai.Part
parts = append(parts, &genai.Part{Text: msg.Content().String()})
for _, binaryContent := range msg.BinaryContent() {
imageFormat := strings.Split(binaryContent.MIMEType, "/")
parts = append(parts, &genai.Part{InlineData: &genai.Blob{
MIMEType: imageFormat[1],
Data: binaryContent.Data,
}})
}
history = append(history, &genai.Content{
Parts: parts,
Parts: []genai.Part{genai.Text(msg.Content().String())},
Role: "user",
})
case message.Assistant:
content := &genai.Content{
Role: "model",
Parts: []*genai.Part{},
Parts: []genai.Part{},
}
if msg.Content().String() != "" {
content.Parts = append(content.Parts, &genai.Part{Text: msg.Content().String()})
content.Parts = append(content.Parts, genai.Text(msg.Content().String()))
}
if len(msg.ToolCalls()) > 0 {
for _, call := range msg.ToolCalls() {
args, _ := parseJsonToMap(call.Input)
content.Parts = append(content.Parts, &genai.Part{
FunctionCall: &genai.FunctionCall{
Name: call.Name,
Args: args,
},
content.Parts = append(content.Parts, genai.FunctionCall{
Name: call.Name,
Args: args,
})
}
}
@@ -114,14 +118,10 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
}
history = append(history, &genai.Content{
Parts: []*genai.Part{
{
FunctionResponse: &genai.FunctionResponse{
Name: toolCall.Name,
Response: response,
},
},
},
Parts: []genai.Part{genai.FunctionResponse{
Name: toolCall.Name,
Response: response,
}},
Role: "function",
})
}
@@ -132,8 +132,7 @@ func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Cont
}
func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
geminiTool := &genai.Tool{}
geminiTool.FunctionDeclarations = make([]*genai.FunctionDeclaration, 0, len(tools))
geminiTools := make([]*genai.Tool, 0, len(tools))
for _, tool := range tools {
info := tool.Info()
@@ -147,53 +146,62 @@ func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
},
}
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, declaration)
geminiTools = append(geminiTools, &genai.Tool{
FunctionDeclarations: []*genai.FunctionDeclaration{declaration},
})
}
return []*genai.Tool{geminiTool}
return geminiTools
}
func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
reasonStr := reason.String()
switch {
case reason == genai.FinishReasonStop:
case reasonStr == "STOP":
return message.FinishReasonEndTurn
case reason == genai.FinishReasonMaxTokens:
case reasonStr == "MAX_TOKENS":
return message.FinishReasonMaxTokens
case strings.Contains(reasonStr, "FUNCTION") || strings.Contains(reasonStr, "TOOL"):
return message.FinishReasonToolUse
default:
return message.FinishReasonUnknown
}
}
func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
// Convert tools
if len(tools) > 0 {
model.Tools = g.convertTools(tools)
}
// Convert messages
geminiMessages := g.convertMessages(messages)
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(geminiMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
},
Tools: g.convertTools(tools),
}, history)
attempts := 0
for {
attempts++
var toolCalls []message.ToolCall
chat := model.StartChat()
chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
var lastMsgParts []genai.Part
lastMsg := geminiMessages[len(geminiMessages)-1]
var lastText string
for _, part := range lastMsg.Parts {
lastMsgParts = append(lastMsgParts, *part)
if text, ok := part.(genai.Text); ok {
lastText = string(text)
break
}
}
resp, err := chat.SendMessage(ctx, lastMsgParts...)
resp, err := chat.SendMessage(ctx, genai.Text(lastText))
// If there is an error we are going to see if we can retry the call
if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err)
@@ -201,7 +209,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -213,62 +221,53 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
}
content := ""
var toolCalls []message.ToolCall
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
switch {
case part.Text != "":
content = string(part.Text)
case part.FunctionCall != nil:
switch p := part.(type) {
case genai.Text:
content = string(p)
case genai.FunctionCall:
id := "call_" + uuid.New().String()
args, _ := json.Marshal(part.FunctionCall.Args)
args, _ := json.Marshal(p.Args)
toolCalls = append(toolCalls, message.ToolCall{
ID: id,
Name: part.FunctionCall.Name,
Input: string(args),
Type: "function",
Finished: true,
ID: id,
Name: p.Name,
Input: string(args),
Type: "function",
})
}
}
}
finishReason := message.FinishReasonEndTurn
if len(resp.Candidates) > 0 {
finishReason = g.finishReason(resp.Candidates[0].FinishReason)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: g.usage(resp),
FinishReason: finishReason,
FinishReason: g.finishReason(resp.Candidates[0].FinishReason),
}, nil
}
}
func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
model := g.client.GenerativeModel(g.providerOptions.model.APIModel)
model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens))
// Convert tools
if len(tools) > 0 {
model.Tools = g.convertTools(tools)
}
// Convert messages
geminiMessages := g.convertMessages(messages)
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(geminiMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
MaxOutputTokens: int32(g.providerOptions.maxTokens),
SystemInstruction: &genai.Content{
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
},
Tools: g.convertTools(tools),
}, history)
attempts := 0
eventChan := make(chan ProviderEvent)
@@ -277,6 +276,19 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
for {
attempts++
chat := model.StartChat()
chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message
lastMsg := geminiMessages[len(geminiMessages)-1]
var lastText string
for _, part := range lastMsg.Parts {
if text, ok := part.(genai.Text); ok {
lastText = string(text)
break
}
}
iter := chat.SendMessageStream(ctx, genai.Text(lastText))
currentContent := ""
toolCalls := []message.ToolCall{}
@@ -284,12 +296,11 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
eventChan <- ProviderEvent{Type: EventContentStart}
var lastMsgParts []genai.Part
for _, part := range lastMsg.Parts {
lastMsgParts = append(lastMsgParts, *part)
}
for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
for {
resp, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
retry, after, retryErr := g.shouldRetry(attempts, err)
if retryErr != nil {
@@ -297,7 +308,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
if ctx.Err() != nil {
@@ -318,25 +329,25 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
switch {
case part.Text != "":
delta := string(part.Text)
switch p := part.(type) {
case genai.Text:
newText := string(p)
delta := newText[len(currentContent):]
if delta != "" {
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: delta,
}
currentContent += delta
currentContent = newText
}
case part.FunctionCall != nil:
case genai.FunctionCall:
id := "call_" + uuid.New().String()
args, _ := json.Marshal(part.FunctionCall.Args)
args, _ := json.Marshal(p.Args)
newCall := message.ToolCall{
ID: id,
Name: part.FunctionCall.Name,
Input: string(args),
Type: "function",
Finished: true,
ID: id,
Name: p.Name,
Input: string(args),
Type: "function",
}
isNew := true
@@ -358,26 +369,37 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
eventChan <- ProviderEvent{Type: EventContentStop}
if finalResp != nil {
finishReason := message.FinishReasonEndTurn
if len(finalResp.Candidates) > 0 {
finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: g.usage(finalResp),
FinishReason: finishReason,
FinishReason: g.finishReason(finalResp.Candidates[0].FinishReason),
},
}
return
}
// If we get here, we need to retry
if attempts > maxRetries {
eventChan <- ProviderEvent{
Type: EventError,
Error: fmt.Errorf("maximum retry attempts reached: %d retries", maxRetries),
}
return
}
// Wait before retrying
select {
case <-ctx.Done():
if ctx.Err() != nil {
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
}
return
case <-time.After(time.Duration(2000*(1<<(attempts-1))) * time.Millisecond):
continue
}
}
}()
@@ -421,12 +443,12 @@ func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
if part.FunctionCall != nil {
if funcCall, ok := part.(genai.FunctionCall); ok {
id := "call_" + uuid.New().String()
args, _ := json.Marshal(part.FunctionCall.Args)
args, _ := json.Marshal(funcCall.Args)
toolCalls = append(toolCalls, message.ToolCall{
ID: id,
Name: part.FunctionCall.Name,
Name: funcCall.Name,
Input: string(args),
Type: "function",
})
+22 -61
View File
@@ -8,22 +8,19 @@ import (
"io"
"time"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/message"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
)
type openaiOptions struct {
baseURL string
disableCache bool
reasoningEffort string
extraHeaders map[string]string
}
type OpenAIOption func(*openaiOptions)
@@ -52,12 +49,6 @@ func newOpenAIClient(opts providerClientOptions) OpenAIClient {
openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
}
if openaiOpts.extraHeaders != nil {
for key, value := range openaiOpts.extraHeaders {
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
}
}
client := openai.NewClient(openaiClientOptions...)
return &openaiClient{
providerOptions: opts,
@@ -73,17 +64,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
for _, msg := range messages {
switch msg.Role {
case message.User:
var content []openai.ChatCompletionContentPartUnionParam
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
for _, binaryContent := range msg.BinaryContent() {
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
}
openaiMessages = append(openaiMessages, openai.UserMessage(content))
openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -183,14 +164,6 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
}
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
params.WithExtraFields(map[string]any{
"provider": map[string]any{
"require_parameters": true,
},
})
}
return params
}
@@ -199,7 +172,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
@@ -215,7 +188,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -231,18 +204,11 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
content = openaiResponse.Choices[0].Message.Content
}
toolCalls := o.toolCalls(*openaiResponse)
finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
return &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
ToolCalls: o.toolCalls(*openaiResponse),
Usage: o.usage(*openaiResponse),
FinishReason: finishReason,
FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)),
}, nil
}
}
@@ -256,7 +222,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
@@ -278,6 +244,15 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
chunk := openaiStream.Current()
acc.AddChunk(chunk)
if tool, ok := acc.JustFinishedToolCall(); ok {
toolCalls = append(toolCalls, message.ToolCall{
ID: tool.Id,
Name: tool.Name,
Input: tool.Arguments,
Type: "function",
})
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
eventChan <- ProviderEvent{
@@ -292,21 +267,13 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
err := openaiStream.Err()
if err == nil || errors.Is(err, io.EOF) {
// Stream completed successfully
finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
}
if len(toolCalls) > 0 {
finishReason = message.FinishReasonToolUse
}
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: o.usage(acc.ChatCompletion),
FinishReason: finishReason,
FinishReason: o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)),
},
}
close(eventChan)
@@ -321,7 +288,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
// context cancelled
@@ -408,12 +375,6 @@ func WithOpenAIBaseURL(baseURL string) OpenAIOption {
}
}
func WithOpenAIExtraHeaders(headers map[string]string) OpenAIOption {
return func(options *openaiOptions) {
options.extraHeaders = headers
}
}
func WithOpenAIDisableCache() OpenAIOption {
return func(options *openaiOptions) {
options.disableCache = true
@@ -427,7 +388,7 @@ func WithReasoningEffort(effort string) OpenAIOption {
case "low", "medium", "high":
defaultReasoningEffort = effort
default:
slog.Warn("Invalid reasoning effort, using default: medium")
logging.Warn("Invalid reasoning effort, using default: medium")
}
options.reasoningEffort = defaultReasoningEffort
}
+5 -81
View File
@@ -4,10 +4,9 @@ import (
"context"
"fmt"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/llm/tools"
"github.com/kujtimiihoxha/opencode/internal/message"
)
type EventType string
@@ -56,8 +55,6 @@ type Provider interface {
StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
Model() models.Model
MaxTokens() int64
}
type providerClientOptions struct {
@@ -110,40 +107,6 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
options: clientOptions,
client: newBedrockClient(clientOptions),
}, nil
case models.ProviderGROQ:
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
)
return &baseProvider[OpenAIClient]{
options: clientOptions,
client: newOpenAIClient(clientOptions),
}, nil
case models.ProviderAzure:
return &baseProvider[AzureClient]{
options: clientOptions,
client: newAzureClient(clientOptions),
}, nil
case models.ProviderOpenRouter:
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
WithOpenAIExtraHeaders(map[string]string{
"HTTP-Referer": "opencode.ai",
"X-Title": "OpenCode",
}),
)
return &baseProvider[OpenAIClient]{
options: clientOptions,
client: newOpenAIClient(clientOptions),
}, nil
case models.ProviderXAI:
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
WithOpenAIBaseURL("https://api.x.ai/v1"),
)
return &baseProvider[OpenAIClient]{
options: clientOptions,
client: newOpenAIClient(clientOptions),
}, nil
case models.ProviderMock:
// TODO: implement mock client for test
panic("not implemented")
@@ -164,55 +127,16 @@ func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []m
func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
messages = p.cleanMessages(messages)
response, err := p.client.send(ctx, messages, tools)
if err == nil && response != nil {
slog.Debug("API request token usage",
"model", p.options.model.Name,
"input_tokens", response.Usage.InputTokens,
"output_tokens", response.Usage.OutputTokens,
"cache_creation_tokens", response.Usage.CacheCreationTokens,
"cache_read_tokens", response.Usage.CacheReadTokens,
"total_tokens", response.Usage.InputTokens+response.Usage.OutputTokens)
}
return response, err
return p.client.send(ctx, messages, tools)
}
func (p *baseProvider[C]) Model() models.Model {
return p.options.model
}
func (p *baseProvider[C]) MaxTokens() int64 {
return p.options.maxTokens
}
func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
messages = p.cleanMessages(messages)
eventChan := p.client.stream(ctx, messages, tools)
// Create a new channel to intercept events
wrappedChan := make(chan ProviderEvent)
go func() {
defer close(wrappedChan)
for event := range eventChan {
// Pass the event through
wrappedChan <- event
// Log token usage when we get the complete event
if event.Type == EventComplete && event.Response != nil {
slog.Debug("API streaming request token usage",
"model", p.options.model.Name,
"input_tokens", event.Response.Usage.InputTokens,
"output_tokens", event.Response.Usage.OutputTokens,
"cache_creation_tokens", event.Response.Usage.CacheCreationTokens,
"cache_read_tokens", event.Response.Usage.CacheReadTokens,
"total_tokens", event.Response.Usage.InputTokens+event.Response.Usage.OutputTokens)
}
}
}()
return wrappedChan
return p.client.stream(ctx, messages, tools)
}
func WithAPIKey(apiKey string) ProviderClientOption {
+3 -4
View File
@@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools/shell"
"github.com/sst/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/llm/tools/shell"
"github.com/kujtimiihoxha/opencode/internal/permission"
)
type BashParams struct {
@@ -268,7 +268,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
if !isSafeReadOnly {
p := b.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
@@ -9,8 +9,8 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
type DiagnosticsParams struct {
@@ -74,8 +74,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
lsps := b.lspClients
if len(lsps) == 0 {
// Return a more helpful message when LSP clients aren't ready yet
return NewTextResponse("\n<diagnostic_summary>\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n</diagnostic_summary>\n"), nil
return NewTextErrorResponse("no LSP clients available"), nil
}
if params.FilePath != "" {
+23 -26
View File
@@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/diff"
"github.com/kujtimiihoxha/opencode/internal/history"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/permission"
)
type EditParams struct {
@@ -37,7 +37,7 @@ type EditResponseMetadata struct {
type editTool struct {
lspClients map[string]*lsp.Client
permissions permission.Service
history history.Service
files history.Service
}
const (
@@ -95,7 +95,7 @@ func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Servi
return &editTool{
lspClients: lspClients,
permissions: permissions,
history: files,
files: files,
}
}
@@ -202,7 +202,6 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -225,17 +224,17 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
}
// File can't be in the history so we create a new file history
_, err = e.history.Create(ctx, sessionID, filePath, "")
_, err = e.files.Create(ctx, sessionID, filePath, "")
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
}
// Add the new content to the file history
_, err = e.history.CreateVersion(ctx, sessionID, filePath, content)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
if err != nil {
// Log error but don't fail the operation
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
@@ -314,7 +313,6 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -337,9 +335,9 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
}
// Check if file exists in history
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
@@ -347,15 +345,15 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
}
if file.Content != oldContent {
// User Manually changed the content store an intermediate version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, "")
_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
@@ -435,7 +433,6 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -458,9 +455,9 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
}
// Check if file exists in history
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
@@ -468,15 +465,15 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
}
if file.Content != oldContent {
// User Manually changed the content store an intermediate version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, newContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
+2 -3
View File
@@ -11,8 +11,8 @@ import (
md "github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/permission"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/permission"
)
type FetchParams struct {
@@ -122,7 +122,6 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
}
p := t.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
+7 -72
View File
@@ -1,20 +1,18 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/config"
)
const (
@@ -134,73 +132,14 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
matches, err := globWithRipgrep(pattern, searchPath, limit)
if err == nil {
return matches, len(matches) >= limit, nil
}
return globWithDoublestar(pattern, searchPath, limit)
}
func globWithRipgrep(
pattern, searchRoot string,
limit int,
) ([]string, error) {
if searchRoot == "" {
searchRoot = "."
}
rgBin, err := exec.LookPath("rg")
if err != nil {
return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
}
if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
args := []string{
"--files",
"--null",
"--glob", pattern,
"-L",
}
cmd := exec.Command(rgBin, args...)
cmd.Dir = searchRoot
out, err := cmd.CombinedOutput()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
return nil, nil
if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
if !strings.HasSuffix(searchPath, "/") {
searchPath += "/"
}
return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
pattern = searchPath + pattern
}
var matches []string
for _, p := range bytes.Split(out, []byte{0}) {
if len(p) == 0 {
continue
}
abs := filepath.Join(searchRoot, string(p))
if skipHidden(abs) {
continue
}
matches = append(matches, abs)
}
sort.SliceStable(matches, func(i, j int) bool {
return len(matches[i]) < len(matches[j])
})
if len(matches) > limit {
matches = matches[:limit]
}
return matches, nil
}
func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
fsys := os.DirFS(searchPath)
fsys := os.DirFS("/")
relPattern := strings.TrimPrefix(pattern, "/")
@@ -219,11 +158,7 @@ func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool,
return nil // Skip files we can't access
}
absPath := path // Restore absolute path
if !strings.HasPrefix(absPath, searchPath) {
absPath = filepath.Join(searchPath, absPath)
}
absPath := "/" + path // Restore absolute path
matches = append(matches, fileInfo{
path: absPath,
modTime: info.ModTime(),
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/config"
)
type GrepParams struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/config"
)
type LSParams struct {
+23 -23
View File
@@ -83,19 +83,19 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// Check that visible directories and files are included
assert.Contains(t, response.Content, "dir1")
assert.Contains(t, response.Content, "dir2")
assert.Contains(t, response.Content, "dir3")
assert.Contains(t, response.Content, "file1.txt")
assert.Contains(t, response.Content, "file2.txt")
// Check that hidden files and directories are not included
assert.NotContains(t, response.Content, ".hidden_dir")
assert.NotContains(t, response.Content, ".hidden_file.txt")
assert.NotContains(t, response.Content, ".hidden_root_file.txt")
// Check that __pycache__ is not included
assert.NotContains(t, response.Content, "__pycache__")
})
@@ -122,7 +122,7 @@ func TestLsTool_Run(t *testing.T) {
t.Run("handles empty path parameter", func(t *testing.T) {
// For this test, we need to mock the config.WorkingDirectory function
// Since we can't easily do that, we'll just check that the response doesn't contain an error message
tool := NewLsTool()
params := LSParams{
Path: "",
@@ -138,7 +138,7 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// The response should either contain a valid directory listing or an error
// We'll just check that it's not empty
assert.NotEmpty(t, response.Content)
@@ -173,11 +173,11 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// The output format is a tree, so we need to check for specific patterns
// Check that file1.txt is not directly mentioned
assert.NotContains(t, response.Content, "- file1.txt")
// Check that dir1/ is not directly mentioned
assert.NotContains(t, response.Content, "- dir1/")
})
@@ -189,12 +189,12 @@ func TestLsTool_Run(t *testing.T) {
defer func() {
os.Chdir(origWd)
}()
// Change to a directory above the temp directory
parentDir := filepath.Dir(tempDir)
err = os.Chdir(parentDir)
require.NoError(t, err)
tool := NewLsTool()
params := LSParams{
Path: filepath.Base(tempDir),
@@ -210,7 +210,7 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// Should list the temp directory contents
assert.Contains(t, response.Content, "dir1")
assert.Contains(t, response.Content, "file1.txt")
@@ -291,22 +291,22 @@ func TestCreateFileTree(t *testing.T) {
}
tree := createFileTree(paths)
// Check the structure of the tree
assert.Len(t, tree, 1) // Should have one root node
// Check the root node
rootNode := tree[0]
assert.Equal(t, "path", rootNode.Name)
assert.Equal(t, "directory", rootNode.Type)
assert.Len(t, rootNode.Children, 1)
// Check the "to" node
toNode := rootNode.Children[0]
assert.Equal(t, "to", toNode.Name)
assert.Equal(t, "directory", toNode.Type)
assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
// Find the dir1 node
var dir1Node *TreeNode
for _, child := range toNode.Children {
@@ -315,7 +315,7 @@ func TestCreateFileTree(t *testing.T) {
break
}
}
require.NotNil(t, dir1Node)
assert.Equal(t, "directory", dir1Node.Type)
assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
@@ -354,9 +354,9 @@ func TestPrintTree(t *testing.T) {
Type: "file",
},
}
result := printTree(tree, "/root")
// Check the output format
assert.Contains(t, result, "- /root/")
assert.Contains(t, result, " - dir1/")
@@ -405,7 +405,7 @@ func TestListDirectory(t *testing.T) {
files, truncated, err := listDirectory(tempDir, []string{}, 1000)
require.NoError(t, err)
assert.False(t, truncated)
// Check that visible files and directories are included
containsPath := func(paths []string, target string) bool {
targetPath := filepath.Join(tempDir, target)
@@ -416,12 +416,12 @@ func TestListDirectory(t *testing.T) {
}
return false
}
assert.True(t, containsPath(files, "dir1"))
assert.True(t, containsPath(files, "file1.txt"))
assert.True(t, containsPath(files, "file2.txt"))
assert.True(t, containsPath(files, "dir1/file3.txt"))
// Check that hidden files and directories are not included
assert.False(t, containsPath(files, ".hidden_dir"))
assert.False(t, containsPath(files, ".hidden_file.txt"))
@@ -438,12 +438,12 @@ func TestListDirectory(t *testing.T) {
files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
require.NoError(t, err)
assert.False(t, truncated)
// Check that no .txt files are included
for _, file := range files {
assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
}
// But directories should still be included
containsDir := false
for _, file := range files {
@@ -454,4 +454,4 @@ func TestListDirectory(t *testing.T) {
}
assert.True(t, containsDir)
})
}
}
-198
View File
@@ -1,198 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type DefinitionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
}
type definitionTool struct {
lspClients map[string]*lsp.Client
}
const (
DefinitionToolName = "definition"
definitionDescription = `Find the definition of a symbol at a specific position in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find where a symbol is defined
- Helpful for understanding code structure and relationships
- Great for navigating between implementation and interface
HOW TO USE:
- Provide the path to the file containing the symbol
- Specify the line number (1-based) where the symbol appears
- Specify the column number (1-based) where the symbol appears
- Results show the location of the symbol's definition
FEATURES:
- Finds definitions across files in the project
- Works with variables, functions, classes, interfaces, etc.
- Returns file path, line, and column of the definition
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all symbols depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with References tool to understand usage
- Combine with View tool to examine the definition
`
)
func NewDefinitionTool(lspClients map[string]*lsp.Client) BaseTool {
return &definitionTool{
lspClients,
}
}
func (b *definitionTool) Info() ToolInfo {
return ToolInfo{
Name: DefinitionToolName,
Description: definitionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the symbol",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the symbol appears",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the symbol appears",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *definitionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params DefinitionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Definition lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
output := getDefinition(ctx, params.FilePath, line, column, lsps)
return NewTextResponse(output), nil
}
func getDefinition(ctx context.Context, filePath string, line, column int, lsps map[string]*lsp.Client) string {
var results []string
slog.Debug(fmt.Sprintf("Looking for definition in %s at line %d, column %d", filePath, line+1, column+1))
slog.Debug(fmt.Sprintf("Available LSP clients: %v", getClientNames(lsps)))
for lspName, client := range lsps {
slog.Debug(fmt.Sprintf("Trying LSP client: %s", lspName))
// Create definition params
uri := fmt.Sprintf("file://%s", filePath)
definitionParams := protocol.DefinitionParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Position: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
},
}
slog.Debug(fmt.Sprintf("Sending definition request with params: %+v", definitionParams))
// Get definition
definition, err := client.Definition(ctx, definitionParams)
if err != nil {
slog.Debug(fmt.Sprintf("Error from %s: %s", lspName, err))
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
slog.Debug(fmt.Sprintf("Got definition result type: %T", definition.Value))
// Process the definition result
locations := processDefinitionResult(definition)
slog.Debug(fmt.Sprintf("Processed locations count: %d", len(locations)))
if len(locations) == 0 {
results = append(results, fmt.Sprintf("No definition found by %s", lspName))
continue
}
// Format the locations
for _, loc := range locations {
path := strings.TrimPrefix(string(loc.URI), "file://")
// Convert 0-based line/column to 1-based for display
defLine := loc.Range.Start.Line + 1
defColumn := loc.Range.Start.Character + 1
slog.Debug(fmt.Sprintf("Found definition at %s:%d:%d", path, defLine, defColumn))
results = append(results, fmt.Sprintf("Definition found by %s: %s:%d:%d", lspName, path, defLine, defColumn))
}
}
if len(results) == 0 {
return "No definition found for the symbol at the specified position."
}
return strings.Join(results, "\n")
}
func processDefinitionResult(result protocol.Or_Result_textDocument_definition) []protocol.Location {
var locations []protocol.Location
switch v := result.Value.(type) {
case protocol.Location:
locations = append(locations, v)
case []protocol.Location:
locations = append(locations, v...)
case []protocol.DefinitionLink:
for _, link := range v {
locations = append(locations, protocol.Location{
URI: link.TargetURI,
Range: link.TargetRange,
})
}
case protocol.Or_Definition:
switch d := v.Value.(type) {
case protocol.Location:
locations = append(locations, d)
case []protocol.Location:
locations = append(locations, d...)
}
}
return locations
}
// Helper function to get LSP client names for debugging
func getClientNames(lsps map[string]*lsp.Client) []string {
names := make([]string, 0, len(lsps))
for name := range lsps {
names = append(names, name)
}
return names
}
-204
View File
@@ -1,204 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type DocSymbolsParams struct {
FilePath string `json:"file_path"`
}
type docSymbolsTool struct {
lspClients map[string]*lsp.Client
}
const (
DocSymbolsToolName = "docSymbols"
docSymbolsDescription = `Get document symbols for a file.
WHEN TO USE THIS TOOL:
- Use when you need to understand the structure of a file
- Helpful for finding classes, functions, methods, and variables in a file
- Great for getting an overview of a file's organization
HOW TO USE:
- Provide the path to the file to get symbols for
- Results show all symbols defined in the file with their kind and location
FEATURES:
- Lists all symbols in a hierarchical structure
- Shows symbol types (function, class, variable, etc.)
- Provides location information for each symbol
- Organizes symbols by their scope and relationship
LIMITATIONS:
- Requires a functioning LSP server for the file type
- Results depend on the accuracy of the LSP server
- May not work for all file types
TIPS:
- Use to quickly understand the structure of a large file
- Combine with Definition and References tools for deeper code exploration
`
)
func NewDocSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
return &docSymbolsTool{
lspClients,
}
}
func (b *docSymbolsTool) Info() ToolInfo {
return ToolInfo{
Name: DocSymbolsToolName,
Description: docSymbolsDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file to get symbols for",
},
},
Required: []string{"file_path"},
}
}
func (b *docSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params DocSymbolsParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Document symbols lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
output := getDocumentSymbols(ctx, params.FilePath, lsps)
return NewTextResponse(output), nil
}
func getDocumentSymbols(ctx context.Context, filePath string, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create document symbol params
uri := fmt.Sprintf("file://%s", filePath)
symbolParams := protocol.DocumentSymbolParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
}
// Get document symbols
symbolResult, err := client.DocumentSymbol(ctx, symbolParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
// Process the symbol result
symbols := processDocumentSymbolResult(symbolResult)
if len(symbols) == 0 {
results = append(results, fmt.Sprintf("No symbols found by %s", lspName))
continue
}
// Format the symbols
results = append(results, fmt.Sprintf("Symbols found by %s:", lspName))
for _, symbol := range symbols {
results = append(results, formatSymbol(symbol, 1))
}
}
if len(results) == 0 {
return "No symbols found in the specified file."
}
return strings.Join(results, "\n")
}
func processDocumentSymbolResult(result protocol.Or_Result_textDocument_documentSymbol) []SymbolInfo {
var symbols []SymbolInfo
switch v := result.Value.(type) {
case []protocol.SymbolInformation:
for _, si := range v {
symbols = append(symbols, SymbolInfo{
Name: si.Name,
Kind: symbolKindToString(si.Kind),
Location: locationToString(si.Location),
Children: nil,
})
}
case []protocol.DocumentSymbol:
for _, ds := range v {
symbols = append(symbols, documentSymbolToSymbolInfo(ds))
}
}
return symbols
}
// SymbolInfo represents a symbol in a document
type SymbolInfo struct {
Name string
Kind string
Location string
Children []SymbolInfo
}
func documentSymbolToSymbolInfo(symbol protocol.DocumentSymbol) SymbolInfo {
info := SymbolInfo{
Name: symbol.Name,
Kind: symbolKindToString(symbol.Kind),
Location: fmt.Sprintf("Line %d-%d",
symbol.Range.Start.Line+1,
symbol.Range.End.Line+1),
Children: []SymbolInfo{},
}
for _, child := range symbol.Children {
info.Children = append(info.Children, documentSymbolToSymbolInfo(child))
}
return info
}
func locationToString(location protocol.Location) string {
return fmt.Sprintf("Line %d-%d",
location.Range.Start.Line+1,
location.Range.End.Line+1)
}
func symbolKindToString(kind protocol.SymbolKind) string {
if kindStr, ok := protocol.TableKindMap[kind]; ok {
return kindStr
}
return "Unknown"
}
func formatSymbol(symbol SymbolInfo, level int) string {
indent := strings.Repeat(" ", level)
result := fmt.Sprintf("%s- %s (%s) %s", indent, symbol.Name, symbol.Kind, symbol.Location)
var childResults []string
for _, child := range symbol.Children {
childResults = append(childResults, formatSymbol(child, level+1))
}
if len(childResults) > 0 {
return result + "\n" + strings.Join(childResults, "\n")
}
return result
}
-161
View File
@@ -1,161 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type ReferencesParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
IncludeDeclaration bool `json:"include_declaration"`
}
type referencesTool struct {
lspClients map[string]*lsp.Client
}
const (
ReferencesToolName = "references"
referencesDescription = `Find all references to a symbol at a specific position in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find all places where a symbol is used
- Helpful for understanding code usage and dependencies
- Great for refactoring and impact analysis
HOW TO USE:
- Provide the path to the file containing the symbol
- Specify the line number (1-based) where the symbol appears
- Specify the column number (1-based) where the symbol appears
- Optionally set include_declaration to include the declaration in results
- Results show all locations where the symbol is referenced
FEATURES:
- Finds references across files in the project
- Works with variables, functions, classes, interfaces, etc.
- Returns file paths, lines, and columns of all references
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not find all references depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Definition tool to understand symbol origins
- Combine with View tool to examine the references
`
)
func NewReferencesTool(lspClients map[string]*lsp.Client) BaseTool {
return &referencesTool{
lspClients,
}
}
func (b *referencesTool) Info() ToolInfo {
return ToolInfo{
Name: ReferencesToolName,
Description: referencesDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the symbol",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the symbol appears",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the symbol appears",
},
"include_declaration": map[string]any{
"type": "boolean",
"description": "Whether to include the declaration in the results",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params ReferencesParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. References lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
output := getReferences(ctx, params.FilePath, line, column, params.IncludeDeclaration, lsps)
return NewTextResponse(output), nil
}
func getReferences(ctx context.Context, filePath string, line, column int, includeDeclaration bool, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create references params
uri := fmt.Sprintf("file://%s", filePath)
referencesParams := protocol.ReferenceParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Position: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
},
Context: protocol.ReferenceContext{
IncludeDeclaration: includeDeclaration,
},
}
// Get references
references, err := client.References(ctx, referencesParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(references) == 0 {
results = append(results, fmt.Sprintf("No references found by %s", lspName))
continue
}
// Format the locations
results = append(results, fmt.Sprintf("References found by %s:", lspName))
for _, loc := range references {
path := strings.TrimPrefix(string(loc.URI), "file://")
// Convert 0-based line/column to 1-based for display
refLine := loc.Range.Start.Line + 1
refColumn := loc.Range.Start.Character + 1
results = append(results, fmt.Sprintf(" %s:%d:%d", path, refLine, refColumn))
}
}
if len(results) == 0 {
return "No references found for the symbol at the specified position."
}
return strings.Join(results, "\n")
}
-162
View File
@@ -1,162 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type WorkspaceSymbolsParams struct {
Query string `json:"query"`
}
type workspaceSymbolsTool struct {
lspClients map[string]*lsp.Client
}
const (
WorkspaceSymbolsToolName = "workspaceSymbols"
workspaceSymbolsDescription = `Find symbols across the workspace matching a query.
WHEN TO USE THIS TOOL:
- Use when you need to find symbols across multiple files
- Helpful for locating classes, functions, or variables in a project
- Great for exploring large codebases
HOW TO USE:
- Provide a query string to search for symbols
- Results show matching symbols from across the workspace
FEATURES:
- Searches across all files in the workspace
- Shows symbol types (function, class, variable, etc.)
- Provides location information for each symbol
- Works with partial matches and fuzzy search (depending on LSP server)
LIMITATIONS:
- Requires a functioning LSP server for the file types
- Results depend on the accuracy of the LSP server
- Query capabilities vary by language server
- May not work for all file types
TIPS:
- Use specific queries to narrow down results
- Combine with DocSymbols tool for detailed file exploration
- Use with Definition tool to jump to symbol definitions
`
)
func NewWorkspaceSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
return &workspaceSymbolsTool{
lspClients,
}
}
func (b *workspaceSymbolsTool) Info() ToolInfo {
return ToolInfo{
Name: WorkspaceSymbolsToolName,
Description: workspaceSymbolsDescription,
Parameters: map[string]any{
"query": map[string]any{
"type": "string",
"description": "The query string to search for symbols",
},
},
Required: []string{"query"},
}
}
func (b *workspaceSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params WorkspaceSymbolsParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Workspace symbols lookup will be available once they're ready.\n"), nil
}
output := getWorkspaceSymbols(ctx, params.Query, lsps)
return NewTextResponse(output), nil
}
func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create workspace symbol params
symbolParams := protocol.WorkspaceSymbolParams{
Query: query,
}
// Get workspace symbols
symbolResult, err := client.Symbol(ctx, symbolParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
// Process the symbol result
symbols := processWorkspaceSymbolResult(symbolResult)
if len(symbols) == 0 {
results = append(results, fmt.Sprintf("No symbols found by %s for query '%s'", lspName, query))
continue
}
// Format the symbols
results = append(results, fmt.Sprintf("Symbols found by %s for query '%s':", lspName, query))
for _, symbol := range symbols {
results = append(results, fmt.Sprintf(" %s (%s) - %s", symbol.Name, symbol.Kind, symbol.Location))
}
}
if len(results) == 0 {
return fmt.Sprintf("No symbols found matching query '%s'.", query)
}
return strings.Join(results, "\n")
}
func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) []SymbolInfo {
var symbols []SymbolInfo
switch v := result.Value.(type) {
case []protocol.SymbolInformation:
for _, si := range v {
symbols = append(symbols, SymbolInfo{
Name: si.Name,
Kind: symbolKindToString(si.Kind),
Location: formatWorkspaceLocation(si.Location),
Children: nil,
})
}
case []protocol.WorkspaceSymbol:
for _, ws := range v {
location := "Unknown location"
if ws.Location.Value != nil {
if loc, ok := ws.Location.Value.(protocol.Location); ok {
location = formatWorkspaceLocation(loc)
}
}
symbols = append(symbols, SymbolInfo{
Name: ws.Name,
Kind: symbolKindToString(ws.Kind),
Location: location,
Children: nil,
})
}
}
return symbols
}
func formatWorkspaceLocation(location protocol.Location) string {
path := strings.TrimPrefix(string(location.URI), "file://")
return fmt.Sprintf("%s:%d:%d", path, location.Range.Start.Line+1, location.Range.Start.Character+1)
}
+10 -13
View File
@@ -8,12 +8,12 @@ import (
"path/filepath"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/diff"
"github.com/kujtimiihoxha/opencode/internal/history"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/permission"
)
type PatchParams struct {
@@ -193,7 +193,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
dir := filepath.Dir(path)
patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -221,7 +220,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
dir := filepath.Dir(path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -241,7 +239,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
dir := filepath.Dir(path)
patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -316,12 +313,12 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
totalRemovals += removals
// Update history
file, err := p.files.GetLatestByPathAndSession(ctx, absPath, sessionID)
file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
if err != nil && change.Type != diff.ActionAdd {
// If not adding a file, create history entry for existing file
_, err = p.files.Create(ctx, sessionID, absPath, oldContent)
if err != nil {
slog.Debug("Error creating file history", "error", err)
logging.Debug("Error creating file history", "error", err)
}
}
@@ -329,7 +326,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
// User manually changed content, store intermediate version
_, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
@@ -340,7 +337,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
_, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
}
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
// Record file operations
+2 -6
View File
@@ -11,8 +11,6 @@ import (
"sync"
"syscall"
"time"
"github.com/sst/opencode/internal/status"
)
type PersistentShell struct {
@@ -49,9 +47,7 @@ func GetPersistentShell(workingDir string) *PersistentShell {
shellInstance = newPersistentShell(workingDir)
})
if shellInstance == nil {
shellInstance = newPersistentShell(workingDir)
} else if !shellInstance.isAlive {
if !shellInstance.isAlive {
shellInstance = newPersistentShell(shellInstance.cwd)
}
@@ -101,7 +97,7 @@ func newPersistentShell(cwd string) *PersistentShell {
go func() {
err := cmd.Wait()
if err != nil {
status.Error(fmt.Sprintf("Shell process exited with error: %v", err))
// Log the error if needed
}
shell.isAlive = false
close(shell.commandQueue)
+383
View File
@@ -0,0 +1,383 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type SourcegraphParams struct {
Query string `json:"query"`
Count int `json:"count,omitempty"`
ContextWindow int `json:"context_window,omitempty"`
Timeout int `json:"timeout,omitempty"`
}
type SourcegraphResponseMetadata struct {
NumberOfMatches int `json:"number_of_matches"`
Truncated bool `json:"truncated"`
}
type sourcegraphTool struct {
client *http.Client
}
const (
SourcegraphToolName = "sourcegraph"
sourcegraphToolDescription = `Search code across public repositories using Sourcegraph's GraphQL API.
WHEN TO USE THIS TOOL:
- Use when you need to find code examples or implementations across public repositories
- Helpful for researching how others have solved similar problems
- Useful for discovering patterns and best practices in open source code
HOW TO USE:
- Provide a search query using Sourcegraph's query syntax
- Optionally specify the number of results to return (default: 10)
- Optionally set a timeout for the request
QUERY SYNTAX:
- Basic search: "fmt.Println" searches for exact matches
- File filters: "file:.go fmt.Println" limits to Go files
- Repository filters: "repo:^github\.com/golang/go$ fmt.Println" limits to specific repos
- Language filters: "lang:go fmt.Println" limits to Go code
- Boolean operators: "fmt.Println AND log.Fatal" for combined terms
- Regular expressions: "fmt\.(Print|Printf|Println)" for pattern matching
- Quoted strings: "\"exact phrase\"" for exact phrase matching
- Exclude filters: "-file:test" or "-repo:forks" to exclude matches
ADVANCED FILTERS:
- Repository filters:
* "repo:name" - Match repositories with name containing "name"
* "repo:^github\.com/org/repo$" - Exact repository match
* "repo:org/repo@branch" - Search specific branch
* "repo:org/repo rev:branch" - Alternative branch syntax
* "-repo:name" - Exclude repositories
* "fork:yes" or "fork:only" - Include or only show forks
* "archived:yes" or "archived:only" - Include or only show archived repos
* "visibility:public" or "visibility:private" - Filter by visibility
- File filters:
* "file:\.js$" - Files with .js extension
* "file:internal/" - Files in internal directory
* "-file:test" - Exclude test files
* "file:has.content(Copyright)" - Files containing "Copyright"
* "file:has.contributor([email protected])" - Files with specific contributor
- Content filters:
* "content:\"exact string\"" - Search for exact string
* "-content:\"unwanted\"" - Exclude files with unwanted content
* "case:yes" - Case-sensitive search
- Type filters:
* "type:symbol" - Search for symbols (functions, classes, etc.)
* "type:file" - Search file content only
* "type:path" - Search filenames only
* "type:diff" - Search code changes
* "type:commit" - Search commit messages
- Commit/diff search:
* "after:\"1 month ago\"" - Commits after date
* "before:\"2023-01-01\"" - Commits before date
* "author:name" - Commits by author
* "message:\"fix bug\"" - Commits with message
- Result selection:
* "select:repo" - Show only repository names
* "select:file" - Show only file paths
* "select:content" - Show only matching content
* "select:symbol" - Show only matching symbols
- Result control:
* "count:100" - Return up to 100 results
* "count:all" - Return all results
* "timeout:30s" - Set search timeout
EXAMPLES:
- "file:.go context.WithTimeout" - Find Go code using context.WithTimeout
- "lang:typescript useState type:symbol" - Find TypeScript React useState hooks
- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Find Kubernetes files related to pod listing
- "repo:sourcegraph/sourcegraph$ after:\"3 months ago\" type:diff database" - Recent changes to database code
- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with specific base images
- "repo:has.path(\.py) file:requirements.txt tensorflow" - Python projects using TensorFlow
BOOLEAN OPERATORS:
- "term1 AND term2" - Results containing both terms
- "term1 OR term2" - Results containing either term
- "term1 NOT term2" - Results with term1 but not term2
- "term1 and (term2 or term3)" - Grouping with parentheses
LIMITATIONS:
- Only searches public repositories
- Rate limits may apply
- Complex queries may take longer to execute
- Maximum of 20 results per query
TIPS:
- Use specific file extensions to narrow results
- Add repo: filters for more targeted searches
- Use type:symbol to find function/method definitions
- Use type:file to find relevant files`
)
func NewSourcegraphTool() BaseTool {
return &sourcegraphTool{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (t *sourcegraphTool) Info() ToolInfo {
return ToolInfo{
Name: SourcegraphToolName,
Description: sourcegraphToolDescription,
Parameters: map[string]any{
"query": map[string]any{
"type": "string",
"description": "The Sourcegraph search query",
},
"count": map[string]any{
"type": "number",
"description": "Optional number of results to return (default: 10, max: 20)",
},
"context_window": map[string]any{
"type": "number",
"description": "The context around the match to return (default: 10 lines)",
},
"timeout": map[string]any{
"type": "number",
"description": "Optional timeout in seconds (max 120)",
},
},
Required: []string{"query"},
}
}
func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params SourcegraphParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse("Failed to parse sourcegraph parameters: " + err.Error()), nil
}
if params.Query == "" {
return NewTextErrorResponse("Query parameter is required"), nil
}
if params.Count <= 0 {
params.Count = 10
} else if params.Count > 20 {
params.Count = 20 // Limit to 20 results
}
if params.ContextWindow <= 0 {
params.ContextWindow = 10 // Default context window
}
client := t.client
if params.Timeout > 0 {
maxTimeout := 120 // 2 minutes
if params.Timeout > maxTimeout {
params.Timeout = maxTimeout
}
client = &http.Client{
Timeout: time.Duration(params.Timeout) * time.Second,
}
}
type graphqlRequest struct {
Query string `json:"query"`
Variables struct {
Query string `json:"query"`
} `json:"variables"`
}
request := graphqlRequest{
Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
}
request.Variables.Query = params.Query
graphqlQueryBytes, err := json.Marshal(request)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
}
graphqlQuery := string(graphqlQueryBytes)
req, err := http.NewRequestWithContext(
ctx,
"POST",
"https://sourcegraph.com/.api/graphql",
bytes.NewBuffer([]byte(graphqlQuery)),
)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "opencode/1.0")
resp, err := client.Do(req)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if len(body) > 0 {
return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
}
return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
}
var result map[string]any
if err = json.Unmarshal(body, &result); err != nil {
return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
}
formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
if err != nil {
return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
}
return NewTextResponse(formattedResults), nil
}
func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
var buffer strings.Builder
if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
buffer.WriteString("## Sourcegraph API Error\n\n")
for _, err := range errors {
if errMap, ok := err.(map[string]any); ok {
if message, ok := errMap["message"].(string); ok {
buffer.WriteString(fmt.Sprintf("- %s\n", message))
}
}
}
return buffer.String(), nil
}
data, ok := result["data"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing data field")
}
search, ok := data["search"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing search field")
}
searchResults, ok := search["results"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing results field")
}
matchCount, _ := searchResults["matchCount"].(float64)
resultCount, _ := searchResults["resultCount"].(float64)
limitHit, _ := searchResults["limitHit"].(bool)
buffer.WriteString("# Sourcegraph Search Results\n\n")
buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
if limitHit {
buffer.WriteString("(Result limit reached, try a more specific query)\n")
}
buffer.WriteString("\n")
results, ok := searchResults["results"].([]any)
if !ok || len(results) == 0 {
buffer.WriteString("No results found. Try a different query.\n")
return buffer.String(), nil
}
maxResults := 10
if len(results) > maxResults {
results = results[:maxResults]
}
for i, res := range results {
fileMatch, ok := res.(map[string]any)
if !ok {
continue
}
typeName, _ := fileMatch["__typename"].(string)
if typeName != "FileMatch" {
continue
}
repo, _ := fileMatch["repository"].(map[string]any)
file, _ := fileMatch["file"].(map[string]any)
lineMatches, _ := fileMatch["lineMatches"].([]any)
if repo == nil || file == nil {
continue
}
repoName, _ := repo["name"].(string)
filePath, _ := file["path"].(string)
fileURL, _ := file["url"].(string)
fileContent, _ := file["content"].(string)
buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
if fileURL != "" {
buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
}
if len(lineMatches) > 0 {
for _, lm := range lineMatches {
lineMatch, ok := lm.(map[string]any)
if !ok {
continue
}
lineNumber, _ := lineMatch["lineNumber"].(float64)
preview, _ := lineMatch["preview"].(string)
if fileContent != "" {
lines := strings.Split(fileContent, "\n")
buffer.WriteString("```\n")
startLine := max(1, int(lineNumber)-contextWindow)
for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
if j >= 0 {
buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
}
}
buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
endLine := int(lineNumber) + contextWindow
for j := int(lineNumber); j < endLine && j < len(lines); j++ {
if j < len(lines) {
buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
}
}
buffer.WriteString("```\n\n")
} else {
buffer.WriteString("```\n")
buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
buffer.WriteString("```\n\n")
}
}
}
}
return buffer.String(), nil
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/lsp"
)
type ViewParams struct {
+9 -10
View File
@@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/diff"
"github.com/kujtimiihoxha/opencode/internal/history"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/permission"
)
type WriteParams struct {
@@ -167,7 +167,6 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
permissionPath = rootDir
}
p := w.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -190,7 +189,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
}
// Check if file exists in history
file, err := w.files.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
@@ -202,13 +201,13 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
// User Manually changed the content store an intermediate version
_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
+78
View File
@@ -0,0 +1,78 @@
package logging
import (
"fmt"
"log/slog"
"os"
"runtime/debug"
"time"
)
func Info(msg string, args ...any) {
slog.Info(msg, args...)
}
func Debug(msg string, args ...any) {
slog.Debug(msg, args...)
}
func Warn(msg string, args ...any) {
slog.Warn(msg, args...)
}
func Error(msg string, args ...any) {
slog.Error(msg, args...)
}
func InfoPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Info(msg, args...)
}
func DebugPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Debug(msg, args...)
}
func WarnPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Warn(msg, args...)
}
func ErrorPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Error(msg, args...)
}
// RecoverPanic is a common function to handle panics gracefully.
// It logs the error, creates a panic log file with stack trace,
// and executes an optional cleanup function before returning.
func RecoverPanic(name string, cleanup func()) {
if r := recover(); r != nil {
// Log the panic
ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r))
// Create a timestamped panic log file
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
file, err := os.Create(filename)
if err != nil {
ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
} else {
defer file.Close()
// Write panic information and stack trace
fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack())
InfoPersist(fmt.Sprintf("Panic details written to %s", filename))
}
// Execute cleanup function if provided
if cleanup != nil {
cleanup()
}
}
}
-292
View File
@@ -1,292 +0,0 @@
package logging
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"runtime/debug"
"strings"
"time"
"github.com/go-logfmt/logfmt"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
)
type Log struct {
ID string
SessionID string
Timestamp time.Time
Level string
Message string
Attributes map[string]string
CreatedAt time.Time
}
const (
EventLogCreated pubsub.EventType = "log_created"
)
type Service interface {
pubsub.Subscriber[Log]
Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error
ListBySession(ctx context.Context, sessionID string) ([]Log, error)
ListAll(ctx context.Context, limit int) ([]Log, error)
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Log]
}
var globalLoggingService *service
func InitService(dbConn *sql.DB) error {
if globalLoggingService != nil {
return fmt.Errorf("logging service already initialized")
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Log]()
globalLoggingService = &service{
db: queries,
broker: broker,
}
return nil
}
func GetService() Service {
if globalLoggingService == nil {
panic("logging service not initialized. Call logging.InitService() first.")
}
return globalLoggingService
}
func (s *service) Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
if level == "" {
level = "info"
}
var attributesJSON sql.NullString
if len(attributes) > 0 {
attributesBytes, err := json.Marshal(attributes)
if err != nil {
return fmt.Errorf("failed to marshal log attributes: %w", err)
}
attributesJSON = sql.NullString{String: string(attributesBytes), Valid: true}
}
dbLog, err := s.db.CreateLog(ctx, db.CreateLogParams{
ID: uuid.New().String(),
SessionID: sql.NullString{String: sessionID, Valid: sessionID != ""},
Timestamp: timestamp.UTC().Format(time.RFC3339Nano),
Level: level,
Message: message,
Attributes: attributesJSON,
})
if err != nil {
return fmt.Errorf("db.CreateLog: %w", err)
}
log := s.fromDBItem(dbLog)
s.broker.Publish(EventLogCreated, log)
return nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
dbLogs, err := s.db.ListLogsBySession(ctx, sql.NullString{String: sessionID, Valid: true})
if err != nil {
return nil, fmt.Errorf("db.ListLogsBySession: %w", err)
}
logs := make([]Log, len(dbLogs))
for i, dbSess := range dbLogs {
logs[i] = s.fromDBItem(dbSess)
}
return logs, nil
}
func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) {
dbLogs, err := s.db.ListAllLogs(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("db.ListAllLogs: %w", err)
}
logs := make([]Log, len(dbLogs))
for i, dbSess := range dbLogs {
logs[i] = s.fromDBItem(dbSess)
}
return logs, nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.Log) Log {
log := Log{
ID: item.ID,
SessionID: item.SessionID.String,
Level: item.Level,
Message: item.Message,
}
// Parse timestamp from ISO string
timestamp, err := time.Parse(time.RFC3339Nano, item.Timestamp)
if err == nil {
log.Timestamp = timestamp
} else {
log.Timestamp = time.Now() // Fallback
}
// Parse created_at from ISO string
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err == nil {
log.CreatedAt = createdAt
} else {
log.CreatedAt = time.Now() // Fallback
}
if item.Attributes.Valid && item.Attributes.String != "" {
if err := json.Unmarshal([]byte(item.Attributes.String), &log.Attributes); err != nil {
slog.Error("Failed to unmarshal log attributes", "log_id", item.ID, "error", err)
log.Attributes = make(map[string]string)
}
} else {
log.Attributes = make(map[string]string)
}
return log
}
func Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
return GetService().Create(ctx, timestamp, level, message, attributes, sessionID)
}
func ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
return GetService().ListBySession(ctx, sessionID)
}
func ListAll(ctx context.Context, limit int) ([]Log, error) {
return GetService().ListAll(ctx, limit)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
return GetService().Subscribe(ctx)
}
type slogWriter struct{}
func (sw *slogWriter) Write(p []byte) (n int, err error) {
// Example: time=2024-05-09T12:34:56.789-05:00 level=INFO msg="User request" session=xyz foo=bar
d := logfmt.NewDecoder(bytes.NewReader(p))
for d.ScanRecord() {
var timestamp time.Time
var level string
var message string
var sessionID string
var attributes map[string]string
attributes = make(map[string]string)
hasTimestamp := false
for d.ScanKeyval() {
key := string(d.Key())
value := string(d.Value())
switch key {
case "time":
parsedTime, timeErr := time.Parse(time.RFC3339Nano, value)
if timeErr != nil {
parsedTime, timeErr = time.Parse(time.RFC3339, value)
if timeErr != nil {
slog.Error("Failed to parse time in slog writer", "value", value, "error", timeErr)
timestamp = time.Now().UTC()
hasTimestamp = true
continue
}
}
timestamp = parsedTime
hasTimestamp = true
case "level":
level = strings.ToLower(value)
case "msg", "message":
message = value
case "session_id":
sessionID = value
default:
attributes[key] = value
}
}
if d.Err() != nil {
return len(p), fmt.Errorf("logfmt.ScanRecord: %w", d.Err())
}
if !hasTimestamp {
timestamp = time.Now()
}
// Create log entry via the service (non-blocking or handle error appropriately)
// Using context.Background() as this is a low-level logging write.
go func(timestamp time.Time, level, message string, attributes map[string]string, sessionID string) { // Run in a goroutine to avoid blocking slog
if globalLoggingService == nil {
// If the logging service is not initialized, log the message to stderr
// fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: logging service not initialized\n")
return
}
if err := Create(context.Background(), timestamp, level, message, attributes, sessionID); err != nil {
// Log internal error using a more primitive logger to avoid loops
fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: failed to persist log: %v\n", err)
}
}(timestamp, level, message, attributes, sessionID)
}
if d.Err() != nil {
return len(p), fmt.Errorf("logfmt.ScanRecord final: %w", d.Err())
}
return len(p), nil
}
func NewSlogWriter() io.Writer {
return &slogWriter{}
}
// RecoverPanic is a common function to handle panics gracefully.
// It logs the error, creates a panic log file with stack trace,
// and executes an optional cleanup function.
func RecoverPanic(name string, cleanup func()) {
if r := recover(); r != nil {
errorMsg := fmt.Sprintf("Panic in %s: %v", name, r)
// Use slog directly here, as our service might be the one panicking.
slog.Error(errorMsg)
// status.Error(errorMsg)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
file, err := os.Create(filename)
if err != nil {
errMsg := fmt.Sprintf("Failed to create panic log file '%s': %v", filename, err)
slog.Error(errMsg)
// status.Error(errMsg)
} else {
defer file.Close()
fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(file, "Stack Trace:\n%s\n", string(debug.Stack())) // Capture stack trace
infoMsg := fmt.Sprintf("Panic details written to %s", filename)
slog.Info(infoMsg)
// status.Info(infoMsg)
}
if cleanup != nil {
cleanup()
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package logging
import (
"time"
)
// LogMessage is the event payload for a log message
type LogMessage struct {
ID string
Time time.Time
Level string
Persist bool // used when we want to show the mesage in the status bar
PersistTime time.Duration // used when we want to show the mesage in the status bar
Message string `json:"msg"`
Attributes []Attr
}
type Attr struct {
Key string
Value string
}
+101
View File
@@ -0,0 +1,101 @@
package logging
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/go-logfmt/logfmt"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
)
const (
persistKeyArg = "$_persist"
PersistTimeArg = "$_persist_time"
)
type LogData struct {
messages []LogMessage
*pubsub.Broker[LogMessage]
lock sync.Mutex
}
func (l *LogData) Add(msg LogMessage) {
l.lock.Lock()
defer l.lock.Unlock()
l.messages = append(l.messages, msg)
l.Publish(pubsub.CreatedEvent, msg)
}
func (l *LogData) List() []LogMessage {
l.lock.Lock()
defer l.lock.Unlock()
return l.messages
}
var defaultLogData = &LogData{
messages: make([]LogMessage, 0),
Broker: pubsub.NewBroker[LogMessage](),
}
type writer struct{}
func (w *writer) Write(p []byte) (int, error) {
d := logfmt.NewDecoder(bytes.NewReader(p))
for d.ScanRecord() {
msg := LogMessage{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
Time: time.Now(),
}
for d.ScanKeyval() {
switch string(d.Key()) {
case "time":
parsed, err := time.Parse(time.RFC3339, string(d.Value()))
if err != nil {
return 0, fmt.Errorf("parsing time: %w", err)
}
msg.Time = parsed
case "level":
msg.Level = strings.ToLower(string(d.Value()))
case "msg":
msg.Message = string(d.Value())
default:
if string(d.Key()) == persistKeyArg {
msg.Persist = true
} else if string(d.Key()) == PersistTimeArg {
parsed, err := time.ParseDuration(string(d.Value()))
if err != nil {
continue
}
msg.PersistTime = parsed
} else {
msg.Attributes = append(msg.Attributes, Attr{
Key: string(d.Key()),
Value: string(d.Value()),
})
}
}
}
defaultLogData.Add(msg)
}
if d.Err() != nil {
return 0, d.Err()
}
return len(p), nil
}
func NewWriter() *writer {
w := &writer{}
return w
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
return defaultLogData.Subscribe(ctx)
}
func List() []LogMessage {
return defaultLogData.List()
}
+24 -43
View File
@@ -14,12 +14,9 @@ import (
"sync/atomic"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/status"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
type Client struct {
@@ -99,17 +96,17 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
slog.Info("LSP Server", "message", scanner.Text())
fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
}
if err := scanner.Err(); err != nil {
slog.Error("Error reading LSP stderr", "error", err)
fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
}
}()
// Start message handling loop
go func() {
defer logging.RecoverPanic("LSP-message-handler", func() {
status.Error("LSP message handler crashed, LSP functionality may be impaired")
logging.ErrorPersist("LSP message handler crashed, LSP functionality may be impaired")
})
client.handleMessages()
}()
@@ -303,7 +300,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
defer ticker.Stop()
if cnf.DebugLSP {
slog.Debug("Waiting for LSP server to be ready...")
logging.Debug("Waiting for LSP server to be ready...")
}
// Determine server type for specialized initialization
@@ -312,7 +309,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
// For TypeScript-like servers, we need to open some key files first
if serverType == ServerTypeTypeScript {
if cnf.DebugLSP {
slog.Debug("TypeScript-like server detected, opening key configuration files")
logging.Debug("TypeScript-like server detected, opening key configuration files")
}
c.openKeyConfigFiles(ctx)
}
@@ -329,15 +326,15 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
// Server responded successfully
c.SetServerState(StateReady)
if cnf.DebugLSP {
slog.Debug("LSP server is ready")
logging.Debug("LSP server is ready")
}
return nil
} else {
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
}
if cnf.DebugLSP {
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
}
}
}
@@ -392,7 +389,7 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
filepath.Join(workDir, "package.json"),
filepath.Join(workDir, "jsconfig.json"),
}
// Also find and open a few TypeScript files to help the server initialize
c.openTypeScriptFiles(ctx, workDir)
case ServerTypeGo:
@@ -412,9 +409,9 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
if _, err := os.Stat(file); err == nil {
// File exists, try to open it
if err := c.OpenFile(ctx, file); err != nil {
slog.Debug("Failed to open key config file", "file", file, "error", err)
logging.Debug("Failed to open key config file", "file", file, "error", err)
} else {
slog.Debug("Opened key config file for initialization", "file", file)
logging.Debug("Opened key config file for initialization", "file", file)
}
}
}
@@ -490,7 +487,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
return nil
})
if err != nil {
slog.Debug("Error walking directory for TypeScript files", "error", err)
logging.Debug("Error walking directory for TypeScript files", "error", err)
}
// Final fallback - just try a generic capability
@@ -530,7 +527,7 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
if err := c.OpenFile(ctx, path); err == nil {
filesOpened++
if cnf.DebugLSP {
slog.Debug("Opened TypeScript file for initialization", "file", path)
logging.Debug("Opened TypeScript file for initialization", "file", path)
}
}
}
@@ -539,23 +536,23 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
})
if err != nil && cnf.DebugLSP {
slog.Debug("Error walking directory for TypeScript files", "error", err)
logging.Debug("Error walking directory for TypeScript files", "error", err)
}
if cnf.DebugLSP {
slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
}
}
// shouldSkipDir returns true if the directory should be skipped during file search
func shouldSkipDir(path string) bool {
dirName := filepath.Base(path)
// Skip hidden directories
if strings.HasPrefix(dirName, ".") {
return true
}
// Skip common directories that won't contain relevant source files
skipDirs := map[string]bool{
"node_modules": true,
@@ -565,7 +562,7 @@ func shouldSkipDir(path string) bool {
"vendor": true,
"target": true,
}
return skipDirs[dirName]
}
@@ -630,15 +627,6 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)
// Verify file exists before attempting to read it
if _, err := os.Stat(filepath); err != nil {
if os.IsNotExist(err) {
// File was deleted - close it in the LSP client instead of notifying change
return c.CloseFile(ctx, filepath)
}
return fmt.Errorf("error checking file: %w", err)
}
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file: %w", err)
@@ -693,7 +681,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
}
if cnf.DebugLSP {
slog.Debug("Closing file", "file", filepath)
logging.Debug("Closing file", "file", filepath)
}
if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
return err
@@ -732,12 +720,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
for _, filePath := range filesToClose {
err := c.CloseFile(ctx, filePath)
if err != nil && cnf.DebugLSP {
slog.Warn("Error closing file", "file", filePath, "error", err)
logging.Warn("Error closing file", "file", filePath, "error", err)
}
}
if cnf.DebugLSP {
slog.Debug("Closed all files", "files", filesToClose)
logging.Debug("Closed all files", "files", filesToClose)
}
}
@@ -788,10 +776,3 @@ func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]
return diagnostics, nil
}
// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache
func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentUri) {
c.diagnosticsMu.Lock()
defer c.diagnosticsMu.Unlock()
delete(c.diagnostics, uri)
}
-65
View File
@@ -1,65 +0,0 @@
package discovery
import (
"fmt"
"github.com/sst/opencode/internal/config"
"log/slog"
)
// IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration
func IntegrateLSPServers(workingDir string) error {
// Get the current configuration
cfg := config.Get()
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Check if this is the first run
shouldInit, err := config.ShouldShowInitDialog()
if err != nil {
return fmt.Errorf("failed to check initialization status: %w", err)
}
// Always run language detection, but log differently for first run vs. subsequent runs
if shouldInit || len(cfg.LSP) == 0 {
slog.Info("Running initial LSP auto-discovery...")
} else {
slog.Debug("Running LSP auto-discovery to detect new languages...")
}
// Configure LSP servers
servers, err := ConfigureLSPServers(workingDir)
if err != nil {
return fmt.Errorf("failed to configure LSP servers: %w", err)
}
// Update the configuration with discovered servers
for langID, serverInfo := range servers {
// Skip languages that already have a configured server
if _, exists := cfg.LSP[langID]; exists {
slog.Debug("LSP server already configured for language", "language", langID)
continue
}
if serverInfo.Available {
// Only add servers that were found
cfg.LSP[langID] = config.LSPConfig{
Disabled: false,
Command: serverInfo.Path,
Args: serverInfo.Args,
}
slog.Info("Added LSP server to configuration",
"language", langID,
"command", serverInfo.Command,
"path", serverInfo.Path)
} else {
slog.Warn("LSP server not available",
"language", langID,
"command", serverInfo.Command,
"installCmd", serverInfo.InstallCmd)
}
}
return nil
}
-298
View File
@@ -1,298 +0,0 @@
package discovery
import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/sst/opencode/internal/lsp"
"log/slog"
)
// LanguageInfo stores information about a detected language
type LanguageInfo struct {
// Language identifier (e.g., "go", "typescript", "python")
ID string
// Number of files detected for this language
FileCount int
// Project files associated with this language (e.g., go.mod, package.json)
ProjectFiles []string
// Whether this is likely a primary language in the project
IsPrimary bool
}
// ProjectFile represents a project configuration file
type ProjectFile struct {
// File name or pattern to match
Name string
// Associated language ID
LanguageID string
// Whether this file strongly indicates the language is primary
IsPrimary bool
}
// Common project files that indicate specific languages
var projectFilePatterns = []ProjectFile{
{Name: "go.mod", LanguageID: "go", IsPrimary: true},
{Name: "go.sum", LanguageID: "go", IsPrimary: false},
{Name: "package.json", LanguageID: "javascript", IsPrimary: true}, // Could be TypeScript too
{Name: "tsconfig.json", LanguageID: "typescript", IsPrimary: true},
{Name: "jsconfig.json", LanguageID: "javascript", IsPrimary: true},
{Name: "pyproject.toml", LanguageID: "python", IsPrimary: true},
{Name: "setup.py", LanguageID: "python", IsPrimary: true},
{Name: "requirements.txt", LanguageID: "python", IsPrimary: true},
{Name: "Cargo.toml", LanguageID: "rust", IsPrimary: true},
{Name: "Cargo.lock", LanguageID: "rust", IsPrimary: false},
{Name: "CMakeLists.txt", LanguageID: "cmake", IsPrimary: true},
{Name: "pom.xml", LanguageID: "java", IsPrimary: true},
{Name: "build.gradle", LanguageID: "java", IsPrimary: true},
{Name: "build.gradle.kts", LanguageID: "kotlin", IsPrimary: true},
{Name: "composer.json", LanguageID: "php", IsPrimary: true},
{Name: "Gemfile", LanguageID: "ruby", IsPrimary: true},
{Name: "Rakefile", LanguageID: "ruby", IsPrimary: true},
{Name: "mix.exs", LanguageID: "elixir", IsPrimary: true},
{Name: "rebar.config", LanguageID: "erlang", IsPrimary: true},
{Name: "dune-project", LanguageID: "ocaml", IsPrimary: true},
{Name: "stack.yaml", LanguageID: "haskell", IsPrimary: true},
{Name: "cabal.project", LanguageID: "haskell", IsPrimary: true},
{Name: "Makefile", LanguageID: "make", IsPrimary: false},
{Name: "Dockerfile", LanguageID: "dockerfile", IsPrimary: false},
}
// Map of file extensions to language IDs
var extensionToLanguage = map[string]string{
".go": "go",
".js": "javascript",
".jsx": "javascript",
".ts": "typescript",
".tsx": "typescript",
".py": "python",
".rs": "rust",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".cs": "csharp",
".fs": "fsharp",
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".hs": "haskell",
".ml": "ocaml",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".lua": "lua",
".r": "r",
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".html": "html",
".css": "css",
".scss": "scss",
".sass": "sass",
".less": "less",
".json": "json",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".md": "markdown",
".dart": "dart",
}
// Directories to exclude from scanning
var excludedDirs = map[string]bool{
".git": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".idea": true,
".vscode": true,
".github": true,
".gitlab": true,
"__pycache__": true,
".next": true,
".nuxt": true,
"venv": true,
"env": true,
".env": true,
}
// DetectLanguages scans a directory to identify programming languages used in the project
func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) {
languages := make(map[string]LanguageInfo)
var mutex sync.Mutex
// Walk the directory tree
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files that can't be accessed
}
// Skip excluded directories
if info.IsDir() {
if excludedDirs[info.Name()] || strings.HasPrefix(info.Name(), ".") {
return filepath.SkipDir
}
return nil
}
// Skip hidden files
if strings.HasPrefix(info.Name(), ".") {
return nil
}
// Check for project files
for _, pattern := range projectFilePatterns {
if info.Name() == pattern.Name {
mutex.Lock()
lang, exists := languages[pattern.LanguageID]
if !exists {
lang = LanguageInfo{
ID: pattern.LanguageID,
FileCount: 0,
ProjectFiles: []string{},
IsPrimary: pattern.IsPrimary,
}
}
lang.ProjectFiles = append(lang.ProjectFiles, path)
if pattern.IsPrimary {
lang.IsPrimary = true
}
languages[pattern.LanguageID] = lang
mutex.Unlock()
break
}
}
// Check file extension
ext := strings.ToLower(filepath.Ext(path))
if langID, ok := extensionToLanguage[ext]; ok {
mutex.Lock()
lang, exists := languages[langID]
if !exists {
lang = LanguageInfo{
ID: langID,
FileCount: 0,
ProjectFiles: []string{},
}
}
lang.FileCount++
languages[langID] = lang
mutex.Unlock()
}
return nil
})
if err != nil {
return nil, err
}
// Determine primary languages based on file count if not already marked
determinePrimaryLanguages(languages)
// Log detected languages
for id, info := range languages {
if info.IsPrimary {
slog.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles))
} else {
slog.Debug("Detected secondary language", "language", id, "files", info.FileCount)
}
}
return languages, nil
}
// determinePrimaryLanguages marks languages as primary based on file count
func determinePrimaryLanguages(languages map[string]LanguageInfo) {
// Find the language with the most files
var maxFiles int
for _, info := range languages {
if info.FileCount > maxFiles {
maxFiles = info.FileCount
}
}
// Mark languages with at least 20% of the max files as primary
threshold := max(maxFiles/5, 5) // At least 5 files to be considered primary
for id, info := range languages {
if !info.IsPrimary && info.FileCount >= threshold {
info.IsPrimary = true
languages[id] = info
}
}
}
// GetLanguageIDFromExtension returns the language ID for a given file extension
func GetLanguageIDFromExtension(ext string) string {
ext = strings.ToLower(ext)
if langID, ok := extensionToLanguage[ext]; ok {
return langID
}
return ""
}
// GetLanguageIDFromProtocol converts a protocol.LanguageKind to our language ID string
func GetLanguageIDFromProtocol(langKind string) string {
// Convert protocol language kind to our language ID
switch langKind {
case "go":
return "go"
case "typescript":
return "typescript"
case "typescriptreact":
return "typescript"
case "javascript":
return "javascript"
case "javascriptreact":
return "javascript"
case "python":
return "python"
case "rust":
return "rust"
case "java":
return "java"
case "c":
return "c"
case "cpp":
return "cpp"
default:
// Try to normalize the language kind
return strings.ToLower(langKind)
}
}
// GetLanguageIDFromPath determines the language ID from a file path
func GetLanguageIDFromPath(path string) string {
// Check file extension first
ext := filepath.Ext(path)
if langID := GetLanguageIDFromExtension(ext); langID != "" {
return langID
}
// Check if it's a known project file
filename := filepath.Base(path)
for _, pattern := range projectFilePatterns {
if filename == pattern.Name {
return pattern.LanguageID
}
}
// Use LSP's detection as a fallback
uri := "file://" + path
langKind := lsp.DetectLanguageID(uri)
return GetLanguageIDFromProtocol(string(langKind))
}
-306
View File
@@ -1,306 +0,0 @@
package discovery
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"log/slog"
)
// ServerInfo contains information about an LSP server
type ServerInfo struct {
// Command to run the server
Command string
// Arguments to pass to the command
Args []string
// Command to install the server (for user guidance)
InstallCmd string
// Whether this server is available
Available bool
// Full path to the executable (if found)
Path string
}
// LanguageServerMap maps language IDs to their corresponding LSP servers
var LanguageServerMap = map[string]ServerInfo{
"go": {
Command: "gopls",
InstallCmd: "go install golang.org/x/tools/gopls@latest",
},
"typescript": {
Command: "typescript-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g typescript-language-server typescript",
},
"javascript": {
Command: "typescript-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g typescript-language-server typescript",
},
"python": {
Command: "pylsp",
InstallCmd: "pip install python-lsp-server",
},
"rust": {
Command: "rust-analyzer",
InstallCmd: "rustup component add rust-analyzer",
},
"java": {
Command: "jdtls",
InstallCmd: "Install Eclipse JDT Language Server",
},
"c": {
Command: "clangd",
InstallCmd: "Install clangd from your package manager",
},
"cpp": {
Command: "clangd",
InstallCmd: "Install clangd from your package manager",
},
"php": {
Command: "intelephense",
Args: []string{"--stdio"},
InstallCmd: "npm install -g intelephense",
},
"ruby": {
Command: "solargraph",
Args: []string{"stdio"},
InstallCmd: "gem install solargraph",
},
"lua": {
Command: "lua-language-server",
InstallCmd: "Install lua-language-server from your package manager",
},
"html": {
Command: "vscode-html-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"css": {
Command: "vscode-css-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"json": {
Command: "vscode-json-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"yaml": {
Command: "yaml-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g yaml-language-server",
},
}
// FindLSPServer searches for an LSP server for the given language
func FindLSPServer(languageID string) (ServerInfo, error) {
// Get server info for the language
serverInfo, exists := LanguageServerMap[languageID]
if !exists {
return ServerInfo{}, fmt.Errorf("no LSP server defined for language: %s", languageID)
}
// Check if the command is in PATH
path, err := exec.LookPath(serverInfo.Command)
if err == nil {
serverInfo.Available = true
serverInfo.Path = path
slog.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path)
return serverInfo, nil
}
// If not in PATH, search in common installation locations
paths := getCommonLSPPaths(languageID, serverInfo.Command)
for _, searchPath := range paths {
if _, err := os.Stat(searchPath); err == nil {
// Found the server
serverInfo.Available = true
serverInfo.Path = searchPath
slog.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath)
return serverInfo, nil
}
}
// Server not found
slog.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command)
return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd)
}
// getCommonLSPPaths returns common installation paths for LSP servers based on language and OS
func getCommonLSPPaths(languageID, command string) []string {
var paths []string
homeDir, err := os.UserHomeDir()
if err != nil {
slog.Error("Failed to get user home directory", "error", err)
return paths
}
// Add platform-specific paths
switch runtime.GOOS {
case "darwin":
// macOS paths
paths = append(paths,
fmt.Sprintf("/usr/local/bin/%s", command),
fmt.Sprintf("/opt/homebrew/bin/%s", command),
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
)
case "linux":
// Linux paths
paths = append(paths,
fmt.Sprintf("/usr/bin/%s", command),
fmt.Sprintf("/usr/local/bin/%s", command),
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
)
case "windows":
// Windows paths
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command),
fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command),
)
}
// Add language-specific paths
switch languageID {
case "go":
gopath := os.Getenv("GOPATH")
if gopath == "" {
gopath = filepath.Join(homeDir, "go")
}
paths = append(paths, filepath.Join(gopath, "bin", command))
if runtime.GOOS == "windows" {
paths = append(paths, filepath.Join(gopath, "bin", command+".exe"))
}
case "typescript", "javascript", "html", "css", "json", "yaml", "php":
// Node.js global packages
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command),
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command),
fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command),
fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command),
)
}
case "python":
// Python paths
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command),
fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command),
fmt.Sprintf("/usr/local/bin/%s", command),
)
}
case "rust":
// Rust paths
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command),
fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command),
fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command),
)
}
}
// Add VSCode extensions path
vscodePath := getVSCodeExtensionsPath(homeDir)
if vscodePath != "" {
paths = append(paths, vscodePath)
}
// Expand any glob patterns in paths
var expandedPaths []string
for _, path := range paths {
if strings.Contains(path, "*") {
// This is a glob pattern, expand it
matches, err := filepath.Glob(path)
if err == nil {
expandedPaths = append(expandedPaths, matches...)
}
} else {
expandedPaths = append(expandedPaths, path)
}
}
return expandedPaths
}
// getVSCodeExtensionsPath returns the path to VSCode extensions directory
func getVSCodeExtensionsPath(homeDir string) string {
var basePath string
switch runtime.GOOS {
case "darwin":
basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage")
case "linux":
basePath = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage")
case "windows":
basePath = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage")
default:
return ""
}
// Check if the directory exists
if _, err := os.Stat(basePath); err != nil {
return ""
}
return basePath
}
// ConfigureLSPServers detects languages and configures LSP servers
func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) {
// Detect languages in the project
languages, err := DetectLanguages(rootDir)
if err != nil {
return nil, fmt.Errorf("failed to detect languages: %w", err)
}
// Find LSP servers for detected languages
servers := make(map[string]ServerInfo)
for langID, langInfo := range languages {
// Prioritize primary languages but include all languages that have server definitions
if !langInfo.IsPrimary && langInfo.FileCount < 3 {
// Skip non-primary languages with very few files
slog.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount)
continue
}
// Check if we have a server for this language
serverInfo, err := FindLSPServer(langID)
if err != nil {
slog.Warn("LSP server not found", "language", langID, "error", err)
continue
}
// Add to the map of configured servers
servers[langID] = serverInfo
if langInfo.IsPrimary {
slog.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
} else {
slog.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
}
}
return servers, nil
}
+10 -10
View File
@@ -3,10 +3,10 @@ package lsp
import (
"encoding/json"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
"github.com/kujtimiihoxha/opencode/internal/lsp/util"
)
// Requests
@@ -18,7 +18,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
func HandleRegisterCapability(params json.RawMessage) (any, error) {
var registerParams protocol.RegistrationParams
if err := json.Unmarshal(params, &registerParams); err != nil {
slog.Error("Error unmarshaling registration params", "error", err)
logging.Error("Error unmarshaling registration params", "error", err)
return nil, err
}
@@ -28,13 +28,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) {
// Parse the registration options
optionsJSON, err := json.Marshal(reg.RegisterOptions)
if err != nil {
slog.Error("Error marshaling registration options", "error", err)
logging.Error("Error marshaling registration options", "error", err)
continue
}
var options protocol.DidChangeWatchedFilesRegistrationOptions
if err := json.Unmarshal(optionsJSON, &options); err != nil {
slog.Error("Error unmarshaling registration options", "error", err)
logging.Error("Error unmarshaling registration options", "error", err)
continue
}
@@ -54,7 +54,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) {
err := util.ApplyWorkspaceEdit(edit.Edit)
if err != nil {
slog.Error("Error applying workspace edit", "error", err)
logging.Error("Error applying workspace edit", "error", err)
return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
}
@@ -89,7 +89,7 @@ func HandleServerMessage(params json.RawMessage) {
}
if err := json.Unmarshal(params, &msg); err == nil {
if cnf.DebugLSP {
slog.Debug("Server message", "type", msg.Type, "message", msg.Message)
logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
}
}
}
@@ -97,7 +97,7 @@ func HandleServerMessage(params json.RawMessage) {
func HandleDiagnostics(client *Client, params json.RawMessage) {
var diagParams protocol.PublishDiagnosticsParams
if err := json.Unmarshal(params, &diagParams); err != nil {
slog.Error("Error unmarshaling diagnostics params", "error", err)
logging.Error("Error unmarshaling diagnostics params", "error", err)
return
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
func DetectLanguageID(uri string) protocol.LanguageKind {
+1 -1
View File
@@ -4,7 +4,7 @@ package lsp
import (
"context"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
// Implementation sends a textDocument/implementation request to the LSP server.
+17 -17
View File
@@ -8,8 +8,8 @@ import (
"io"
"strings"
"github.com/sst/opencode/internal/config"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
)
// Write writes an LSP message to the given writer
@@ -21,7 +21,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
}
_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
@@ -50,7 +50,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
line = strings.TrimSpace(line)
if cnf.DebugLSP {
slog.Debug("Received header", "line", line)
logging.Debug("Received header", "line", line)
}
if line == "" {
@@ -66,7 +66,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
}
if cnf.DebugLSP {
slog.Debug("Content-Length", "length", contentLength)
logging.Debug("Content-Length", "length", contentLength)
}
// Read content
@@ -77,7 +77,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
}
if cnf.DebugLSP {
slog.Debug("Received content", "content", string(content))
logging.Debug("Received content", "content", string(content))
}
// Parse message
@@ -96,7 +96,7 @@ func (c *Client) handleMessages() {
msg, err := ReadMessage(c.stdout)
if err != nil {
if cnf.DebugLSP {
slog.Error("Error reading message", "error", err)
logging.Error("Error reading message", "error", err)
}
return
}
@@ -104,7 +104,7 @@ func (c *Client) handleMessages() {
// Handle server->client request (has both Method and ID)
if msg.Method != "" && msg.ID != 0 {
if cnf.DebugLSP {
slog.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
}
response := &Message{
@@ -144,7 +144,7 @@ func (c *Client) handleMessages() {
// Send response back to server
if err := WriteMessage(c.stdin, response); err != nil {
slog.Error("Error sending response to server", "error", err)
logging.Error("Error sending response to server", "error", err)
}
continue
@@ -158,11 +158,11 @@ func (c *Client) handleMessages() {
if ok {
if cnf.DebugLSP {
slog.Debug("Handling notification", "method", msg.Method)
logging.Debug("Handling notification", "method", msg.Method)
}
go handler(msg.Params)
} else if cnf.DebugLSP {
slog.Debug("No handler for notification", "method", msg.Method)
logging.Debug("No handler for notification", "method", msg.Method)
}
continue
}
@@ -175,12 +175,12 @@ func (c *Client) handleMessages() {
if ok {
if cnf.DebugLSP {
slog.Debug("Received response for request", "id", msg.ID)
logging.Debug("Received response for request", "id", msg.ID)
}
ch <- msg
close(ch)
} else if cnf.DebugLSP {
slog.Debug("No handler for response", "id", msg.ID)
logging.Debug("No handler for response", "id", msg.ID)
}
}
}
@@ -192,7 +192,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
id := c.nextID.Add(1)
if cnf.DebugLSP {
slog.Debug("Making call", "method", method, "id", id)
logging.Debug("Making call", "method", method, "id", id)
}
msg, err := NewRequest(id, method, params)
@@ -218,14 +218,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
}
if cnf.DebugLSP {
slog.Debug("Request sent", "method", method, "id", id)
logging.Debug("Request sent", "method", method, "id", id)
}
// Wait for response
resp := <-ch
if cnf.DebugLSP {
slog.Debug("Received response", "id", id)
logging.Debug("Received response", "id", id)
}
if resp.Error != nil {
@@ -251,7 +251,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
func (c *Client) Notify(ctx context.Context, method string, params any) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Sending notification", "method", method)
logging.Debug("Sending notification", "method", method)
}
msg, err := NewNotification(method, params)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
+91 -159
View File
@@ -5,17 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/fsnotify/fsnotify"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"log/slog"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/logging"
"github.com/kujtimiihoxha/opencode/internal/lsp"
"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
)
// WorkspaceWatcher manages LSP file watching
@@ -46,7 +45,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
cnf := config.Get()
slog.Debug("Adding file watcher registrations")
logging.Debug("Adding file watcher registrations")
w.registrationMu.Lock()
defer w.registrationMu.Unlock()
@@ -55,33 +54,33 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
// Print detailed registration information for debugging
if cnf.DebugLSP {
slog.Debug("Adding file watcher registrations",
logging.Debug("Adding file watcher registrations",
"id", id,
"watchers", len(watchers),
"total", len(w.registrations),
)
for i, watcher := range watchers {
slog.Debug("Registration", "index", i+1)
logging.Debug("Registration", "index", i+1)
// Log the GlobPattern
switch v := watcher.GlobPattern.Value.(type) {
case string:
slog.Debug("GlobPattern", "pattern", v)
logging.Debug("GlobPattern", "pattern", v)
case protocol.RelativePattern:
slog.Debug("GlobPattern", "pattern", v.Pattern)
logging.Debug("GlobPattern", "pattern", v.Pattern)
// Log BaseURI details
switch u := v.BaseURI.Value.(type) {
case string:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
case protocol.DocumentUri:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
default:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
}
default:
slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
}
// Log WatchKind
@@ -90,26 +89,26 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
watchKind = *watcher.Kind
}
slog.Debug("WatchKind", "kind", watchKind)
logging.Debug("WatchKind", "kind", watchKind)
}
}
// Determine server type for specialized handling
serverName := getServerNameFromContext(ctx)
slog.Debug("Server type detected", "serverName", serverName)
logging.Debug("Server type detected", "serverName", serverName)
// Check if this server has sent file watchers
hasFileWatchers := len(watchers) > 0
// For servers that need file preloading, we'll use a smart approach
if shouldPreloadFiles(serverName) || !hasFileWatchers {
go func() {
startTime := time.Now()
filesOpened := 0
// Determine max files to open based on server type
maxFilesToOpen := 50 // Default conservative limit
switch serverName {
case "typescript", "typescript-language-server", "tsserver", "vtsls":
// TypeScript servers benefit from seeing more files
@@ -118,29 +117,29 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
// Java servers need to see many files for project model
maxFilesToOpen = 200
}
// First, open high-priority files
highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
filesOpened += highPriorityFilesOpened
if cnf.DebugLSP {
slog.Debug("Opened high-priority files",
logging.Debug("Opened high-priority files",
"count", highPriorityFilesOpened,
"serverName", serverName)
}
// If we've already opened enough high-priority files, we might not need more
if filesOpened >= maxFilesToOpen {
if cnf.DebugLSP {
slog.Debug("Reached file limit with high-priority files",
logging.Debug("Reached file limit with high-priority files",
"filesOpened", filesOpened,
"maxFiles", maxFilesToOpen)
}
return
}
// For the remaining slots, walk the directory and open matching files
err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
@@ -150,7 +149,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
if d.IsDir() {
if path != w.workspacePath && shouldExcludeDir(path) {
if cnf.DebugLSP {
slog.Debug("Skipping excluded directory", "path", path)
logging.Debug("Skipping excluded directory", "path", path)
}
return filepath.SkipDir
}
@@ -178,7 +177,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
elapsedTime := time.Since(startTime)
if cnf.DebugLSP {
slog.Debug("Limited workspace scan complete",
logging.Debug("Limited workspace scan complete",
"filesOpened", filesOpened,
"maxFiles", maxFilesToOpen,
"elapsedTime", elapsedTime.Seconds(),
@@ -187,11 +186,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
}
if err != nil && cnf.DebugLSP {
slog.Debug("Error scanning workspace for files to open", "error", err)
logging.Debug("Error scanning workspace for files to open", "error", err)
}
}()
} else if cnf.DebugLSP {
slog.Debug("Using on-demand file loading for server", "server", serverName)
logging.Debug("Using on-demand file loading for server", "server", serverName)
}
}
@@ -200,10 +199,10 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
cnf := config.Get()
filesOpened := 0
// Define patterns for high-priority files based on server type
var patterns []string
switch serverName {
case "typescript", "typescript-language-server", "tsserver", "vtsls":
patterns = []string{
@@ -257,50 +256,50 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
"**/.editorconfig",
}
}
// For each pattern, find and open matching files
for _, pattern := range patterns {
// Use doublestar.Glob to find files matching the pattern (supports ** patterns)
matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
if err != nil {
if cnf.DebugLSP {
slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
}
continue
}
for _, match := range matches {
// Convert relative path to absolute
fullPath := filepath.Join(w.workspacePath, match)
// Skip directories and excluded files
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
continue
}
// Open the file
if err := w.client.OpenFile(ctx, fullPath); err != nil {
if cnf.DebugLSP {
slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
}
} else {
filesOpened++
if cnf.DebugLSP {
slog.Debug("Opened high-priority file", "path", fullPath)
logging.Debug("Opened high-priority file", "path", fullPath)
}
}
// Add a small delay to prevent overwhelming the server
time.Sleep(20 * time.Millisecond)
// Limit the number of files opened per pattern
if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") {
break
}
}
}
return filesOpened
}
@@ -311,16 +310,16 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Store the watcher in the context for later use
ctx = context.WithValue(ctx, "workspaceWatcher", w)
// If the server name isn't already in the context, try to detect it
if _, ok := ctx.Value("serverName").(string); !ok {
serverName := getServerNameFromContext(ctx)
ctx = context.WithValue(ctx, "serverName", serverName)
}
serverName := getServerNameFromContext(ctx)
slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
// Register handler for file watcher registrations from the server
lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
w.AddRegistrations(ctx, id, watchers)
@@ -328,7 +327,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
watcher, err := fsnotify.NewWatcher()
if err != nil {
slog.Error("Error creating watcher", "error", err)
logging.Error("Error creating watcher", "error", err)
}
defer watcher.Close()
@@ -342,7 +341,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if d.IsDir() && path != workspacePath {
if shouldExcludeDir(path) {
if cnf.DebugLSP {
slog.Debug("Skipping excluded directory", "path", path)
logging.Debug("Skipping excluded directory", "path", path)
}
return filepath.SkipDir
}
@@ -352,14 +351,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if d.IsDir() {
err = watcher.Add(path)
if err != nil {
slog.Error("Error watching path", "path", path, "error", err)
logging.Error("Error watching path", "path", path, "error", err)
}
}
return nil
})
if err != nil {
slog.Error("Error walking workspace", "error", err)
logging.Error("Error walking workspace", "error", err)
}
// Event loop
@@ -376,29 +375,19 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Add new directories to the watcher
if event.Op&fsnotify.Create != 0 {
// Check if the file/directory still exists before processing
info, err := os.Stat(event.Name)
if err != nil {
if os.IsNotExist(err) {
// File was deleted between event and processing - ignore
slog.Debug("File deleted between create event and stat", "path", event.Name)
continue
}
slog.Error("Error getting file info", "path", event.Name, "error", err)
continue
}
if info.IsDir() {
// Skip excluded directories
if !shouldExcludeDir(event.Name) {
if err := watcher.Add(event.Name); err != nil {
slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
if info, err := os.Stat(event.Name); err == nil {
if info.IsDir() {
// Skip excluded directories
if !shouldExcludeDir(event.Name) {
if err := watcher.Add(event.Name); err != nil {
logging.Error("Error adding directory to watcher", "path", event.Name, "error", err)
}
}
} else {
// For newly created files
if !shouldExcludeFile(event.Name) {
w.openMatchingFile(ctx, event.Name)
}
}
} else {
// For newly created files
if !shouldExcludeFile(event.Name) {
w.openMatchingFile(ctx, event.Name)
}
}
}
@@ -406,7 +395,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Debug logging
if cnf.DebugLSP {
matched, kind := w.isPathWatched(event.Name)
slog.Debug("File event",
logging.Debug("File event",
"path", event.Name,
"operation", event.Op.String(),
"watched", matched,
@@ -425,11 +414,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
case event.Op&fsnotify.Create != 0:
// Already handled earlier in the event loop
// Just send the notification if needed
info, err := os.Stat(event.Name)
if err != nil {
slog.Error("Error getting file info", "path", event.Name, "error", err)
return
}
info, _ := os.Stat(event.Name)
if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
}
@@ -455,7 +440,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if !ok {
return
}
slog.Error("Error watching file", "error", err)
logging.Error("Error watching file", "error", err)
}
}
}
@@ -580,7 +565,7 @@ func matchesSimpleGlob(pattern, path string) bool {
// Fall back to simple matching for simpler patterns
matched, err := filepath.Match(pattern, path)
if err != nil {
slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
return false
}
@@ -591,7 +576,7 @@ func matchesSimpleGlob(pattern, path string) bool {
func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
patternInfo, err := pattern.AsPattern()
if err != nil {
slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
return false
}
@@ -616,7 +601,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
// Make path relative to basePath for matching
relPath, err := filepath.Rel(basePath, path)
if err != nil {
slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
return false
}
relPath = filepath.ToSlash(relPath)
@@ -654,55 +639,17 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri
func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
// If the file is open and it's a change event, use didChange notification
filePath := uri[7:] // Remove "file://" prefix
if changeType == protocol.FileChangeType(protocol.Deleted) {
// Always clear diagnostics for deleted files
w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
// If the file was open, close it in the LSP client
if w.client.IsFileOpen(filePath) {
if err := w.client.CloseFile(ctx, filePath); err != nil {
slog.Debug("Error closing deleted file in LSP client", "file", filePath, "error", err)
// Continue anyway - the file is gone
}
}
} else if changeType == protocol.FileChangeType(protocol.Changed) {
// For changed files, verify the file still exists before notifying
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
// File was deleted between the event and now - treat as delete
slog.Debug("File deleted between change event and processing", "file", filePath)
w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
return
}
slog.Error("Error getting file info", "path", filePath, "error", err)
return
}
// File exists and is open, notify change
if w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
slog.Error("Error notifying change", "error", err)
}
return
}
} else if changeType == protocol.FileChangeType(protocol.Created) {
// For created files, verify the file still exists before notifying
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
// File was deleted between the event and now - ignore
slog.Debug("File deleted between create event and processing", "file", filePath)
return
}
slog.Error("Error getting file info", "path", filePath, "error", err)
return
if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
logging.Error("Error notifying change", "error", err)
}
return
}
// Notify LSP server about the file event using didChangeWatchedFiles
if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
slog.Error("Error notifying LSP server about file event", "error", err)
logging.Error("Error notifying LSP server about file event", "error", err)
}
}
@@ -710,7 +657,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Notifying file event",
logging.Debug("Notifying file event",
"uri", uri,
"changeType", changeType,
)
@@ -735,7 +682,7 @@ func getServerNameFromContext(ctx context.Context) string {
if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
return strings.ToLower(serverName)
}
// Otherwise, try to extract server name from the client command path
if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
path := strings.ToLower(w.client.Cmd.Path)
@@ -875,11 +822,6 @@ func shouldExcludeFile(filePath string) bool {
return true
}
// Skip numeric temporary files (often created by editors)
if _, err := strconv.Atoi(fileName); err == nil {
return true
}
// Check file size
info, err := os.Stat(filePath)
if err != nil {
@@ -890,7 +832,7 @@ func shouldExcludeFile(filePath string) bool {
// Skip large files
if info.Size() > maxFileSize {
if cnf.DebugLSP {
slog.Debug("Skipping large file",
logging.Debug("Skipping large file",
"path", filePath,
"size", info.Size(),
"maxSize", maxFileSize,
@@ -908,19 +850,9 @@ func shouldExcludeFile(filePath string) bool {
// openMatchingFile opens a file if it matches any of the registered patterns
func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
cnf := config.Get()
// Skip directories and verify file exists
// Skip directories
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// File was deleted between event and processing - ignore
slog.Debug("File deleted between event and openMatchingFile", "path", path)
return
}
slog.Error("Error getting file info", "path", path, "error", err)
return
}
if info.IsDir() {
if err != nil || info.IsDir() {
return
}
@@ -933,15 +865,15 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
if watched, _ := w.isPathWatched(path); watched {
// Get server name for specialized handling
serverName := getServerNameFromContext(ctx)
// Check if the file is a high-priority file that should be opened immediately
// This helps with project initialization for certain language servers
if isHighPriorityFile(path, serverName) {
if cnf.DebugLSP {
slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
}
if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
slog.Error("Error opening high-priority file", "path", path, "error", err)
logging.Error("Error opening high-priority file", "path", path, "error", err)
}
return
}
@@ -949,21 +881,21 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
// For non-high-priority files, we'll use different strategies based on server type
if shouldPreloadFiles(serverName) {
// For servers that benefit from preloading, open files but with limits
// Check file size - for preloading we're more conservative
if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
if cnf.DebugLSP {
slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
}
return
}
// Check file extension for common source files
ext := strings.ToLower(filepath.Ext(path))
// Only preload source files for the specific language
shouldOpen := false
switch serverName {
case "typescript", "typescript-language-server", "tsserver", "vtsls":
shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
@@ -981,11 +913,11 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
// For unknown servers, be conservative
shouldOpen = false
}
if shouldOpen {
// Don't need to check if it's already open - the client.OpenFile handles that
if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
slog.Error("Error opening file", "path", path, "error", err)
logging.Error("Error opening file", "path", path, "error", err)
}
}
}
@@ -1011,13 +943,13 @@ func isHighPriorityFile(path string, serverName string) bool {
fileName == "main.js"
case "gopls":
// For Go, we want to open go.mod files immediately
return fileName == "go.mod" ||
return fileName == "go.mod" ||
fileName == "go.sum" ||
// Also open main.go files
fileName == "main.go"
case "rust-analyzer":
// For Rust, we want to open Cargo.toml files immediately
return fileName == "Cargo.toml" ||
return fileName == "Cargo.toml" ||
fileName == "Cargo.lock" ||
// Also open lib.rs and main.rs
fileName == "lib.rs" ||
-8
View File
@@ -1,8 +0,0 @@
package message
type Attachment struct {
FilePath string
FileName string
MimeType string
Content []byte
}
+19 -20
View File
@@ -5,7 +5,7 @@ import (
"slices"
"time"
"github.com/sst/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
)
type MessageRole string
@@ -48,10 +48,7 @@ type TextContent struct {
Text string `json:"text"`
}
func (tc *TextContent) String() string {
if tc == nil {
return ""
}
func (tc TextContent) String() string {
return tc.Text
}
@@ -69,17 +66,13 @@ func (iuc ImageURLContent) String() string {
func (ImageURLContent) isPart() {}
type BinaryContent struct {
Path string
MIMEType string
Data []byte
}
func (bc BinaryContent) String(provider models.ModelProvider) string {
func (bc BinaryContent) String() string {
base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
if provider == models.ProviderOpenAI {
return "data:" + bc.MIMEType + ";base64," + base64Encoded
}
return base64Encoded
return "data:" + bc.MIMEType + ";base64," + base64Encoded
}
func (BinaryContent) isPart() {}
@@ -105,24 +98,30 @@ type ToolResult struct {
func (ToolResult) isPart() {}
type Finish struct {
Reason FinishReason `json:"reason"`
Time time.Time `json:"time"`
}
type DBFinish struct {
Reason FinishReason `json:"reason"`
Time int64 `json:"time"`
}
func (Finish) isPart() {}
func (m *Message) Content() *TextContent {
type Message struct {
ID string
Role MessageRole
SessionID string
Parts []ContentPart
Model models.ModelID
CreatedAt int64
UpdatedAt int64
}
func (m *Message) Content() TextContent {
for _, part := range m.Parts {
if c, ok := part.(TextContent); ok {
return &c
return c
}
}
return nil
return TextContent{}
}
func (m *Message) ReasoningContent() ReasoningContent {
@@ -313,7 +312,7 @@ func (m *Message) AddFinish(reason FinishReason) {
break
}
}
m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now()})
m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
}
func (m *Message) AddImageURL(url, detail string) {
+127 -348
View File
@@ -5,31 +5,12 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/pubsub"
)
type Message struct {
ID string
Role MessageRole
SessionID string
Parts []ContentPart
Model models.ModelID
CreatedAt time.Time
UpdatedAt time.Time
}
const (
EventMessageCreated pubsub.EventType = "message_created"
EventMessageUpdated pubsub.EventType = "message_updated"
EventMessageDeleted pubsub.EventType = "message_deleted"
"github.com/kujtimiihoxha/opencode/internal/db"
"github.com/kujtimiihoxha/opencode/internal/llm/models"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
)
type CreateMessageParams struct {
@@ -39,317 +20,145 @@ type CreateMessageParams struct {
}
type Service interface {
pubsub.Subscriber[Message]
pubsub.Suscriber[Message]
Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
Update(ctx context.Context, message Message) (Message, error)
Update(ctx context.Context, message Message) error
Get(ctx context.Context, id string) (Message, error)
List(ctx context.Context, sessionID string) ([]Message, error)
ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error)
Delete(ctx context.Context, id string) error
DeleteSessionMessages(ctx context.Context, sessionID string) error
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Message]
mu sync.RWMutex
*pubsub.Broker[Message]
q db.Querier
}
var globalMessageService *service
func InitService(dbConn *sql.DB) error {
if globalMessageService != nil {
return fmt.Errorf("message service already initialized")
func NewService(q db.Querier) Service {
return &service{
Broker: pubsub.NewBroker[Message](),
q: q,
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Message]()
}
globalMessageService = &service{
db: queries,
broker: broker,
func (s *service) Delete(ctx context.Context, id string) error {
message, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteMessage(ctx, message.ID)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, message)
return nil
}
func GetService() Service {
if globalMessageService == nil {
panic("message service not initialized. Call message.InitService() first.")
}
return globalMessageService
}
func (s *service) Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
isFinished := false
for _, p := range params.Parts {
if _, ok := p.(Finish); ok {
isFinished = true
break
}
if params.Role != Assistant {
params.Parts = append(params.Parts, Finish{
Reason: "stop",
})
}
if params.Role == User && !isFinished {
params.Parts = append(params.Parts, Finish{Reason: FinishReasonEndTurn, Time: time.Now()})
}
partsJSON, err := marshallParts(params.Parts)
if err != nil {
return Message{}, fmt.Errorf("failed to marshal message parts: %w", err)
return Message{}, err
}
dbMsgParams := db.CreateMessageParams{
dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
ID: uuid.New().String(),
SessionID: sessionID,
Role: string(params.Role),
Parts: string(partsJSON),
Model: sql.NullString{String: string(params.Model), Valid: params.Model != ""},
}
dbMessage, err := s.db.CreateMessage(ctx, dbMsgParams)
Model: sql.NullString{String: string(params.Model), Valid: true},
})
if err != nil {
return Message{}, fmt.Errorf("db.CreateMessage: %w", err)
return Message{}, err
}
message, err := s.fromDBItem(dbMessage)
if err != nil {
return Message{}, fmt.Errorf("failed to convert DB message: %w", err)
return Message{}, err
}
s.broker.Publish(EventMessageCreated, message)
s.Publish(pubsub.CreatedEvent, message)
return message, nil
}
func (s *service) Update(ctx context.Context, message Message) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if message.ID == "" {
return Message{}, fmt.Errorf("cannot update message with empty ID")
}
partsJSON, err := marshallParts(message.Parts)
func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
messages, err := s.List(ctx, sessionID)
if err != nil {
return Message{}, fmt.Errorf("failed to marshal message parts for update: %w", err)
return err
}
var dbFinishedAt sql.NullString
finishPart := message.FinishPart()
if finishPart != nil && !finishPart.Time.IsZero() {
dbFinishedAt = sql.NullString{
String: finishPart.Time.UTC().Format(time.RFC3339Nano),
Valid: true,
for _, message := range messages {
if message.SessionID == sessionID {
err = s.Delete(ctx, message.ID)
if err != nil {
return err
}
}
}
return nil
}
// UpdatedAt is handled by the DB trigger (strftime('%s', 'now'))
err = s.db.UpdateMessage(ctx, db.UpdateMessageParams{
func (s *service) Update(ctx context.Context, message Message) error {
parts, err := marshallParts(message.Parts)
if err != nil {
return err
}
finishedAt := sql.NullInt64{}
if f := message.FinishPart(); f != nil {
finishedAt.Int64 = f.Time
finishedAt.Valid = true
}
err = s.q.UpdateMessage(ctx, db.UpdateMessageParams{
ID: message.ID,
Parts: string(partsJSON),
FinishedAt: dbFinishedAt,
Parts: string(parts),
FinishedAt: finishedAt,
})
if err != nil {
return Message{}, fmt.Errorf("db.UpdateMessage: %w", err)
return err
}
dbUpdatedMessage, err := s.db.GetMessage(ctx, message.ID)
if err != nil {
return Message{}, fmt.Errorf("failed to fetch message after update: %w", err)
}
updatedMessage, err := s.fromDBItem(dbUpdatedMessage)
if err != nil {
return Message{}, fmt.Errorf("failed to convert updated DB message: %w", err)
}
s.broker.Publish(EventMessageUpdated, updatedMessage)
return updatedMessage, nil
message.UpdatedAt = time.Now().Unix()
s.Publish(pubsub.UpdatedEvent, message)
return nil
}
func (s *service) Get(ctx context.Context, id string) (Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessage, err := s.db.GetMessage(ctx, id)
dbMessage, err := s.q.GetMessage(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return Message{}, fmt.Errorf("message with ID '%s' not found", id)
}
return Message{}, fmt.Errorf("db.GetMessage: %w", err)
return Message{}, err
}
return s.fromDBItem(dbMessage)
}
func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessages, err := s.db.ListMessagesBySession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("db.ListMessagesBySession: %w", err)
}
messages := make([]Message, len(dbMessages))
for i, dbMsg := range dbMessages {
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message at index %d: %w", i, convErr)
}
messages[i] = msg
}
return messages, nil
}
func (s *service) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessages, err := s.db.ListMessagesBySessionAfter(ctx, db.ListMessagesBySessionAfterParams{
SessionID: sessionID,
CreatedAt: timestamp.Format(time.RFC3339Nano),
})
if err != nil {
return nil, fmt.Errorf("db.ListMessagesBySessionAfter: %w", err)
}
messages := make([]Message, len(dbMessages))
for i, dbMsg := range dbMessages {
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message at index %d (ListAfter): %w", i, convErr)
}
messages[i] = msg
}
return messages, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
messageToPublish, err := s.getServiceForPublish(ctx, id)
s.mu.Unlock()
if err != nil {
// If error was due to not found, it's not a critical failure for deletion intent
if strings.Contains(err.Error(), "not found") {
return nil // Or return the error if strictness is required
}
return err
}
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteMessage(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteMessage: %w", err)
}
if messageToPublish != nil {
s.broker.Publish(EventMessageDeleted, *messageToPublish)
}
return nil
}
func (s *service) getServiceForPublish(ctx context.Context, id string) (*Message, error) {
dbMsg, err := s.db.GetMessage(ctx, id)
dbMessages, err := s.q.ListMessagesBySession(ctx, sessionID)
if err != nil {
return nil, err
}
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message for publishing: %w", convErr)
}
return &msg, nil
}
func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
messagesToDelete, err := s.db.ListMessagesBySession(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to list messages for deletion: %w", err)
}
err = s.db.DeleteSessionMessages(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.DeleteSessionMessages: %w", err)
}
for _, dbMsg := range messagesToDelete {
msg, convErr := s.fromDBItem(dbMsg)
if convErr == nil {
s.broker.Publish(EventMessageDeleted, msg)
} else {
slog.Error("Failed to convert DB message for delete event publishing", "id", dbMsg.ID, "error", convErr)
messages := make([]Message, len(dbMessages))
for i, dbMessage := range dbMessages {
messages[i], err = s.fromDBItem(dbMessage)
if err != nil {
return nil, err
}
}
return nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
return s.broker.Subscribe(ctx)
return messages, nil
}
func (s *service) fromDBItem(item db.Message) (Message, error) {
parts, err := unmarshallParts([]byte(item.Parts))
if err != nil {
return Message{}, fmt.Errorf("unmarshallParts for message ID %s: %w. Raw parts: %s", item.ID, err, item.Parts)
return Message{}, err
}
// Parse timestamps from ISO strings
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
createdAt = time.Now() // Fallback
}
updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
updatedAt = time.Now() // Fallback
}
msg := Message{
return Message{
ID: item.ID,
SessionID: item.SessionID,
Role: MessageRole(item.Role),
Parts: parts,
Model: models.ModelID(item.Model.String),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
return msg, nil
}
func Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
return GetService().Create(ctx, sessionID, params)
}
func Update(ctx context.Context, message Message) (Message, error) {
return GetService().Update(ctx, message)
}
func Get(ctx context.Context, id string) (Message, error) {
return GetService().Get(ctx, id)
}
func List(ctx context.Context, sessionID string) ([]Message, error) {
return GetService().List(ctx, sessionID)
}
func ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) {
return GetService().ListAfter(ctx, sessionID, timestamp)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func DeleteSessionMessages(ctx context.Context, sessionID string) error {
return GetService().DeleteSessionMessages(ctx, sessionID)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
return GetService().Subscribe(ctx)
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}, nil
}
type partType string
@@ -365,139 +174,109 @@ const (
)
type partWrapper struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
Type partType `json:"type"`
Data ContentPart `json:"data"`
}
func marshallParts(parts []ContentPart) ([]byte, error) {
wrappedParts := make([]json.RawMessage, len(parts))
wrappedParts := make([]partWrapper, len(parts))
for i, part := range parts {
var typ partType
var dataBytes []byte
var err error
switch p := part.(type) {
switch part.(type) {
case ReasoningContent:
typ = reasoningType
dataBytes, err = json.Marshal(p)
case TextContent:
typ = textType
dataBytes, err = json.Marshal(p)
case *TextContent:
typ = textType
dataBytes, err = json.Marshal(p)
case ImageURLContent:
typ = imageURLType
dataBytes, err = json.Marshal(p)
case BinaryContent:
typ = binaryType
dataBytes, err = json.Marshal(p)
case ToolCall:
typ = toolCallType
dataBytes, err = json.Marshal(p)
case ToolResult:
typ = toolResultType
dataBytes, err = json.Marshal(p)
case Finish:
typ = finishType
var dbFinish DBFinish
dbFinish.Reason = p.Reason
dbFinish.Time = p.Time.UnixMilli()
dataBytes, err = json.Marshal(dbFinish)
default:
return nil, fmt.Errorf("unknown part type for marshalling: %T", part)
return nil, fmt.Errorf("unknown part type: %T", part)
}
if err != nil {
return nil, fmt.Errorf("failed to marshal part data for type %s: %w", typ, err)
wrappedParts[i] = partWrapper{
Type: typ,
Data: part,
}
wrapper := struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
}{Type: typ, Data: dataBytes}
wrappedBytes, err := json.Marshal(wrapper)
if err != nil {
return nil, fmt.Errorf("failed to marshal part wrapper for type %s: %w", typ, err)
}
wrappedParts[i] = wrappedBytes
}
return json.Marshal(wrappedParts)
}
func unmarshallParts(data []byte) ([]ContentPart, error) {
var rawMessages []json.RawMessage
if err := json.Unmarshal(data, &rawMessages); err != nil {
return nil, fmt.Errorf("failed to unmarshal parts data as array: %w. Data: %s", err, string(data))
temp := []json.RawMessage{}
if err := json.Unmarshal(data, &temp); err != nil {
return nil, err
}
parts := make([]ContentPart, 0, len(rawMessages))
for _, rawPart := range rawMessages {
var wrapper partWrapper
parts := make([]ContentPart, 0)
for _, rawPart := range temp {
var wrapper struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawPart, &wrapper); err != nil {
// Fallback for old format where parts might be just TextContent string
var text string
if errText := json.Unmarshal(rawPart, &text); errText == nil {
parts = append(parts, TextContent{Text: text})
continue
}
return nil, fmt.Errorf("failed to unmarshal part wrapper: %w. Raw part: %s", err, string(rawPart))
return nil, err
}
switch wrapper.Type {
case reasoningType:
var p ReasoningContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ReasoningContent: %w. Data: %s", err, string(wrapper.Data))
part := ReasoningContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case textType:
var p TextContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal TextContent: %w. Data: %s", err, string(wrapper.Data))
part := TextContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case imageURLType:
var p ImageURLContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ImageURLContent: %w. Data: %s", err, string(wrapper.Data))
part := ImageURLContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
case binaryType:
var p BinaryContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal BinaryContent: %w. Data: %s", err, string(wrapper.Data))
part := BinaryContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case toolCallType:
var p ToolCall
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ToolCall: %w. Data: %s", err, string(wrapper.Data))
part := ToolCall{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case toolResultType:
var p ToolResult
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ToolResult: %w. Data: %s", err, string(wrapper.Data))
part := ToolResult{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case finishType:
var p DBFinish
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal Finish: %w. Data: %s", err, string(wrapper.Data))
part := Finish{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, Finish{Reason: FinishReason(p.Reason), Time: time.UnixMilli(p.Time)})
parts = append(parts, part)
default:
slog.Warn("Unknown part type during unmarshalling, attempting to parse as TextContent", "type", wrapper.Type, "data", string(wrapper.Data))
// Fallback: if type is unknown or empty, try to parse data as TextContent directly
var p TextContent
if err := json.Unmarshal(wrapper.Data, &p); err == nil {
parts = append(parts, p)
} else {
// If that also fails, log it but continue if possible, or return error
slog.Error("Failed to unmarshal unknown part type and fallback to TextContent failed", "type", wrapper.Type, "data", string(wrapper.Data), "error", err)
// Depending on strictness, you might return an error here:
// return nil, fmt.Errorf("unknown part type '%s' and failed fallback: %w", wrapper.Type, err)
}
return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
}
}
return parts, nil
}
+46 -168
View File
@@ -1,18 +1,15 @@
package permission
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"slices"
"sync"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/pubsub"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
)
var ErrorPermissionDenied = errors.New("permission denied")
@@ -36,141 +33,56 @@ type PermissionRequest struct {
Path string `json:"path"`
}
type PermissionResponse struct {
Request PermissionRequest
Granted bool
}
const (
EventPermissionRequested pubsub.EventType = "permission_requested"
EventPermissionGranted pubsub.EventType = "permission_granted"
EventPermissionDenied pubsub.EventType = "permission_denied"
EventPermissionPersisted pubsub.EventType = "permission_persisted"
)
type Service interface {
pubsub.Subscriber[PermissionRequest]
SubscribeToResponseEvents(ctx context.Context) <-chan pubsub.Event[PermissionResponse]
GrantPersistant(ctx context.Context, permission PermissionRequest)
Grant(ctx context.Context, permission PermissionRequest)
Deny(ctx context.Context, permission PermissionRequest)
Request(ctx context.Context, opts CreatePermissionRequest) bool
AutoApproveSession(ctx context.Context, sessionID string)
IsAutoApproved(ctx context.Context, sessionID string) bool
pubsub.Suscriber[PermissionRequest]
GrantPersistant(permission PermissionRequest)
Grant(permission PermissionRequest)
Deny(permission PermissionRequest)
Request(opts CreatePermissionRequest) bool
AutoApproveSession(sessionID string)
}
type permissionService struct {
broker *pubsub.Broker[PermissionRequest]
responseBroker *pubsub.Broker[PermissionResponse]
*pubsub.Broker[PermissionRequest]
sessionPermissions map[string][]PermissionRequest
sessionPermissions []PermissionRequest
pendingRequests sync.Map
autoApproveSessions map[string]bool
mu sync.RWMutex
autoApproveSessions []string
}
var globalPermissionService *permissionService
func InitService() error {
if globalPermissionService != nil {
return fmt.Errorf("permission service already initialized")
}
globalPermissionService = &permissionService{
broker: pubsub.NewBroker[PermissionRequest](),
responseBroker: pubsub.NewBroker[PermissionResponse](),
sessionPermissions: make(map[string][]PermissionRequest),
autoApproveSessions: make(map[string]bool),
}
return nil
}
func GetService() *permissionService {
if globalPermissionService == nil {
panic("permission service not initialized. Call permission.InitService() first.")
}
return globalPermissionService
}
func (s *permissionService) GrantPersistant(ctx context.Context, permission PermissionRequest) {
s.mu.Lock()
s.sessionPermissions[permission.SessionID] = append(s.sessionPermissions[permission.SessionID], permission)
s.mu.Unlock()
func (s *permissionService) GrantPersistant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- true:
case <-ctx.Done():
slog.Warn("Context cancelled while sending grant persistent response", "request_id", permission.ID)
}
respCh.(chan bool) <- true
}
s.responseBroker.Publish(EventPermissionPersisted, PermissionResponse{Request: permission, Granted: true})
s.sessionPermissions = append(s.sessionPermissions, permission)
}
func (s *permissionService) Grant(ctx context.Context, permission PermissionRequest) {
func (s *permissionService) Grant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- true:
case <-ctx.Done():
slog.Warn("Context cancelled while sending grant response", "request_id", permission.ID)
}
respCh.(chan bool) <- true
}
s.responseBroker.Publish(EventPermissionGranted, PermissionResponse{Request: permission, Granted: true})
}
func (s *permissionService) Deny(ctx context.Context, permission PermissionRequest) {
func (s *permissionService) Deny(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- false:
case <-ctx.Done():
slog.Warn("Context cancelled while sending deny response", "request_id", permission.ID)
}
respCh.(chan bool) <- false
}
s.responseBroker.Publish(EventPermissionDenied, PermissionResponse{Request: permission, Granted: false})
}
func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) bool {
s.mu.RLock()
if s.autoApproveSessions[opts.SessionID] {
s.mu.RUnlock()
func (s *permissionService) Request(opts CreatePermissionRequest) bool {
if slices.Contains(s.autoApproveSessions, opts.SessionID) {
return true
}
requestPath := opts.Path
if !filepath.IsAbs(requestPath) {
requestPath = filepath.Join(config.WorkingDirectory(), requestPath)
dir := filepath.Dir(opts.Path)
if dir == "." {
dir = config.WorkingDirectory()
}
requestPath = filepath.Clean(requestPath)
if permissions, ok := s.sessionPermissions[opts.SessionID]; ok {
for _, p := range permissions {
storedPath := p.Path
if !filepath.IsAbs(storedPath) {
storedPath = filepath.Join(config.WorkingDirectory(), storedPath)
}
storedPath = filepath.Clean(storedPath)
if p.ToolName == opts.ToolName && p.Action == opts.Action &&
(requestPath == storedPath || strings.HasPrefix(requestPath, storedPath+string(filepath.Separator))) {
s.mu.RUnlock()
return true
}
}
}
s.mu.RUnlock()
normalizedPath := opts.Path
if !filepath.IsAbs(normalizedPath) {
normalizedPath = filepath.Join(config.WorkingDirectory(), normalizedPath)
}
normalizedPath = filepath.Clean(normalizedPath)
permissionReq := PermissionRequest{
permission := PermissionRequest{
ID: uuid.New().String(),
Path: normalizedPath,
Path: dir,
SessionID: opts.SessionID,
ToolName: opts.ToolName,
Description: opts.Description,
@@ -178,69 +90,35 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
Params: opts.Params,
}
for _, p := range s.sessionPermissions {
if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
return true
}
}
respCh := make(chan bool, 1)
s.pendingRequests.Store(permissionReq.ID, respCh)
defer s.pendingRequests.Delete(permissionReq.ID)
s.broker.Publish(EventPermissionRequested, permissionReq)
s.pendingRequests.Store(permission.ID, respCh)
defer s.pendingRequests.Delete(permission.ID)
s.Publish(pubsub.CreatedEvent, permission)
// Wait for the response with a timeout
select {
case resp := <-respCh:
return resp
case <-ctx.Done():
slog.Warn("Permission request timed out or context cancelled", "request_id", permissionReq.ID, "tool", opts.ToolName)
case <-time.After(10 * time.Minute):
return false
}
}
func (s *permissionService) AutoApproveSession(ctx context.Context, sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoApproveSessions[sessionID] = true
func (s *permissionService) AutoApproveSession(sessionID string) {
s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
}
func (s *permissionService) IsAutoApproved(ctx context.Context, sessionID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.autoApproveSessions[sessionID]
}
func (s *permissionService) Subscribe(ctx context.Context) <-chan pubsub.Event[PermissionRequest] {
return s.broker.Subscribe(ctx)
}
func (s *permissionService) SubscribeToResponseEvents(ctx context.Context) <-chan pubsub.Event[PermissionResponse] {
return s.responseBroker.Subscribe(ctx)
}
func GrantPersistant(ctx context.Context, permission PermissionRequest) {
GetService().GrantPersistant(ctx, permission)
}
func Grant(ctx context.Context, permission PermissionRequest) {
GetService().Grant(ctx, permission)
}
func Deny(ctx context.Context, permission PermissionRequest) {
GetService().Deny(ctx, permission)
}
func Request(ctx context.Context, opts CreatePermissionRequest) bool {
return GetService().Request(ctx, opts)
}
func AutoApproveSession(ctx context.Context, sessionID string) {
GetService().AutoApproveSession(ctx, sessionID)
}
func IsAutoApproved(ctx context.Context, sessionID string) bool {
return GetService().IsAutoApproved(ctx, sessionID)
}
func SubscribeToRequests(ctx context.Context) <-chan pubsub.Event[PermissionRequest] {
return GetService().Subscribe(ctx)
}
func SubscribeToResponses(ctx context.Context) <-chan pubsub.Event[PermissionResponse] {
return GetService().SubscribeToResponseEvents(ctx)
func NewPermissionService() Service {
return &permissionService{
Broker: pubsub.NewBroker[PermissionRequest](),
sessionPermissions: make([]PermissionRequest, 0),
}
}
+74 -71
View File
@@ -2,112 +2,115 @@ package pubsub
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
const defaultChannelBufferSize = 100
const bufferSize = 64
type Broker[T any] struct {
subs map[chan Event[T]]context.CancelFunc
mu sync.RWMutex
isClosed bool
subs map[chan Event[T]]struct{}
mu sync.RWMutex
done chan struct{}
subCount int
maxEvents int
}
func NewBroker[T any]() *Broker[T] {
return &Broker[T]{
subs: make(map[chan Event[T]]context.CancelFunc),
return NewBrokerWithOptions[T](bufferSize, 1000)
}
func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
b := &Broker[T]{
subs: make(map[chan Event[T]]struct{}),
done: make(chan struct{}),
subCount: 0,
maxEvents: maxEvents,
}
return b
}
func (b *Broker[T]) Shutdown() {
b.mu.Lock()
if b.isClosed {
b.mu.Unlock()
select {
case <-b.done: // Already closed
return
default:
close(b.done)
}
b.isClosed = true
for ch, cancel := range b.subs {
cancel()
close(ch)
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
delete(b.subs, ch)
close(ch)
}
b.mu.Unlock()
slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T)))
b.subCount = 0
}
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
b.mu.Lock()
defer b.mu.Unlock()
if b.isClosed {
closedCh := make(chan Event[T])
close(closedCh)
return closedCh
select {
case <-b.done:
ch := make(chan Event[T])
close(ch)
return ch
default:
}
subCtx, subCancel := context.WithCancel(ctx)
subscriberChannel := make(chan Event[T], defaultChannelBufferSize)
b.subs[subscriberChannel] = subCancel
sub := make(chan Event[T], bufferSize)
b.subs[sub] = struct{}{}
b.subCount++
go func() {
<-subCtx.Done()
<-ctx.Done()
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.subs[subscriberChannel]; ok {
close(subscriberChannel)
delete(b.subs, subscriberChannel)
select {
case <-b.done:
return
default:
}
delete(b.subs, sub)
close(sub)
b.subCount--
}()
return subscriberChannel
}
func (b *Broker[T]) Publish(eventType EventType, payload T) {
b.mu.RLock()
defer b.mu.RUnlock()
if b.isClosed {
slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload))
return
}
event := Event[T]{Type: eventType, Payload: payload}
for ch := range b.subs {
// Non-blocking send with a fallback to a goroutine to prevent slow subscribers
// from blocking the publisher.
select {
case ch <- event:
// Successfully sent
default:
// Subscriber channel is full or receiver is slow.
// Send in a new goroutine to avoid blocking the publisher.
// This might lead to out-of-order delivery for this specific slow subscriber.
go func(sChan chan Event[T], ev Event[T]) {
// Re-check if broker is closed before attempting send in goroutine
b.mu.RLock()
isBrokerClosed := b.isClosed
b.mu.RUnlock()
if isBrokerClosed {
return
}
select {
case sChan <- ev:
case <-time.After(2 * time.Second): // Timeout for slow subscriber
slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type)
}
}(ch, event)
}
}
return sub
}
func (b *Broker[T]) GetSubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
return b.subCount
}
func (b *Broker[T]) Publish(t EventType, payload T) {
b.mu.RLock()
select {
case <-b.done:
b.mu.RUnlock()
return
default:
}
subscribers := make([]chan Event[T], 0, len(b.subs))
for sub := range b.subs {
subscribers = append(subscribers, sub)
}
b.mu.RUnlock()
event := Event[T]{Type: t, Payload: payload}
for _, sub := range subscribers {
select {
case sub <- event:
default:
}
}
}
-144
View File
@@ -1,144 +0,0 @@
package pubsub
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestBrokerSubscribe(t *testing.T) {
t.Parallel()
t.Run("with cancellable context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Cancel the context should remove the subscription
cancel()
time.Sleep(10 * time.Millisecond) // Give time for goroutine to process
assert.Equal(t, 0, broker.GetSubscriberCount())
})
t.Run("with background context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Using context.Background() should not leak goroutines
ch := broker.Subscribe(context.Background())
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Shutdown should clean up all subscriptions
broker.Shutdown()
assert.Equal(t, 0, broker.GetSubscriberCount())
})
}
func TestBrokerPublish(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx := t.Context()
ch := broker.Subscribe(ctx)
// Publish a message
broker.Publish(EventTypeCreated, "test message")
// Verify message is received
select {
case event := <-ch:
assert.Equal(t, EventTypeCreated, event.Type)
assert.Equal(t, "test message", event.Payload)
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout waiting for message")
}
}
func TestBrokerShutdown(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Create multiple subscribers
ch1 := broker.Subscribe(context.Background())
ch2 := broker.Subscribe(context.Background())
assert.Equal(t, 2, broker.GetSubscriberCount())
// Shutdown should close all channels and clean up
broker.Shutdown()
// Verify channels are closed
_, ok1 := <-ch1
_, ok2 := <-ch2
assert.False(t, ok1, "channel 1 should be closed")
assert.False(t, ok2, "channel 2 should be closed")
// Verify subscriber count is reset
assert.Equal(t, 0, broker.GetSubscriberCount())
}
func TestBrokerConcurrency(t *testing.T) {
t.Parallel()
broker := NewBroker[int]()
// Create a large number of subscribers
const numSubscribers = 100
var wg sync.WaitGroup
wg.Add(numSubscribers)
// Create a channel to collect received events
receivedEvents := make(chan int, numSubscribers)
for i := range numSubscribers {
go func(id int) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
// Receive one message then cancel
select {
case event := <-ch:
receivedEvents <- event.Payload
case <-time.After(1 * time.Second):
t.Errorf("timeout waiting for message %d", id)
}
cancel()
}(i)
}
// Give subscribers time to set up
time.Sleep(10 * time.Millisecond)
// Publish messages to all subscribers
for i := range numSubscribers {
broker.Publish(EventTypeCreated, i)
}
// Wait for all subscribers to finish
wg.Wait()
close(receivedEvents)
// Give time for cleanup goroutines to run
time.Sleep(10 * time.Millisecond)
// Verify all subscribers are cleaned up
assert.Equal(t, 0, broker.GetSubscriberCount())
// Verify we received the expected number of events
count := 0
for range receivedEvents {
count++
}
assert.Equal(t, numSubscribers, count)
}
+18 -14
View File
@@ -2,23 +2,27 @@ package pubsub
import "context"
type EventType string
const (
EventTypeCreated EventType = "created"
EventTypeUpdated EventType = "updated"
EventTypeDeleted EventType = "deleted"
CreatedEvent EventType = "created"
UpdatedEvent EventType = "updated"
DeletedEvent EventType = "deleted"
)
type Event[T any] struct {
Type EventType
Payload T
type Suscriber[T any] interface {
Subscribe(context.Context) <-chan Event[T]
}
type Subscriber[T any] interface {
Subscribe(ctx context.Context) <-chan Event[T]
}
type (
// EventType identifies the type of event
EventType string
type Publisher[T any] interface {
Publish(eventType EventType, payload T)
}
// Event represents an event in the lifecycle of a resource
Event[T any] struct {
Type EventType
Payload T
}
Publisher[T any] interface {
Publish(EventType, T)
}
)
+71 -176
View File
@@ -3,13 +3,10 @@ package session
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
"github.com/kujtimiihoxha/opencode/internal/db"
"github.com/kujtimiihoxha/opencode/internal/pubsub"
)
type Session struct {
@@ -20,197 +17,117 @@ type Session struct {
PromptTokens int64
CompletionTokens int64
Cost float64
Summary string
SummarizedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
CreatedAt int64
UpdatedAt int64
}
const (
EventSessionCreated pubsub.EventType = "session_created"
EventSessionUpdated pubsub.EventType = "session_updated"
EventSessionDeleted pubsub.EventType = "session_deleted"
)
type Service interface {
pubsub.Subscriber[Session]
pubsub.Suscriber[Session]
Create(ctx context.Context, title string) (Session, error)
CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
Get(ctx context.Context, id string) (Session, error)
List(ctx context.Context) ([]Session, error)
Update(ctx context.Context, session Session) (Session, error)
Save(ctx context.Context, session Session) (Session, error)
Delete(ctx context.Context, id string) error
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Session]
mu sync.RWMutex
}
var globalSessionService *service
func InitService(dbConn *sql.DB) error {
if globalSessionService != nil {
return fmt.Errorf("session service already initialized")
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Session]()
globalSessionService = &service{
db: queries,
broker: broker,
}
return nil
}
func GetService() Service {
if globalSessionService == nil {
panic("session service not initialized. Call session.InitService() first.")
}
return globalSessionService
*pubsub.Broker[Session]
q db.Querier
}
func (s *service) Create(ctx context.Context, title string) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if title == "" {
title = "New Session - " + time.Now().Format("2006-01-02 15:04:05")
}
dbSessParams := db.CreateSessionParams{
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: uuid.New().String(),
Title: title,
}
dbSession, err := s.db.CreateSession(ctx, dbSessParams)
})
if err != nil {
return Session{}, fmt.Errorf("db.CreateSession: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionCreated, session)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if title == "" {
title = "Task Session - " + time.Now().Format("2006-01-02 15:04:05")
}
if toolCallID == "" {
toolCallID = uuid.New().String()
}
dbSessParams := db.CreateSessionParams{
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: toolCallID,
ParentSessionID: sql.NullString{String: parentSessionID, Valid: parentSessionID != ""},
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: title,
}
dbSession, err := s.db.CreateSession(ctx, dbSessParams)
})
if err != nil {
return Session{}, fmt.Errorf("db.CreateTaskSession: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionCreated, session)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) Get(ctx context.Context, id string) (Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbSession, err := s.db.GetSessionByID(ctx, id)
func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: "title-" + parentSessionID,
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: "Generate a title",
})
if err != nil {
if err == sql.ErrNoRows {
return Session{}, fmt.Errorf("session ID '%s' not found", id)
}
return Session{}, fmt.Errorf("db.GetSessionByID: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
session, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteSession(ctx, session.ID)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, session)
return nil
}
func (s *service) Get(ctx context.Context, id string) (Session, error) {
dbSession, err := s.q.GetSessionByID(ctx, id)
if err != nil {
return Session{}, err
}
return s.fromDBItem(dbSession), nil
}
func (s *service) List(ctx context.Context) ([]Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbSessions, err := s.db.ListSessions(ctx)
if err != nil {
return nil, fmt.Errorf("db.ListSessions: %w", err)
}
sessions := make([]Session, len(dbSessions))
for i, dbSess := range dbSessions {
sessions[i] = s.fromDBItem(dbSess)
}
return sessions, nil
}
func (s *service) Update(ctx context.Context, session Session) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if session.ID == "" {
return Session{}, fmt.Errorf("cannot update session with empty ID")
}
params := db.UpdateSessionParams{
func (s *service) Save(ctx context.Context, session Session) (Session, error) {
dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{
ID: session.ID,
Title: session.Title,
PromptTokens: session.PromptTokens,
CompletionTokens: session.CompletionTokens,
Cost: session.Cost,
Summary: sql.NullString{String: session.Summary, Valid: session.Summary != ""},
SummarizedAt: sql.NullString{String: session.SummarizedAt.UTC().Format(time.RFC3339Nano), Valid: !session.SummarizedAt.IsZero()},
}
dbSession, err := s.db.UpdateSession(ctx, params)
})
if err != nil {
return Session{}, fmt.Errorf("db.UpdateSession: %w", err)
return Session{}, err
}
updatedSession := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionUpdated, updatedSession)
return updatedSession, nil
session = s.fromDBItem(dbSession)
s.Publish(pubsub.UpdatedEvent, session)
return session, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
dbSess, err := s.db.GetSessionByID(ctx, id)
func (s *service) List(ctx context.Context) ([]Session, error) {
dbSessions, err := s.q.ListSessions(ctx)
if err != nil {
s.mu.Unlock()
if err == sql.ErrNoRows {
return fmt.Errorf("session ID '%s' not found for deletion", id)
}
return fmt.Errorf("db.GetSessionByID before delete: %w", err)
return nil, err
}
sessionToPublish := s.fromDBItem(dbSess)
s.mu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteSession(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteSession: %w", err)
sessions := make([]Session, len(dbSessions))
for i, dbSession := range dbSessions {
sessions[i] = s.fromDBItem(dbSession)
}
s.broker.Publish(EventSessionDeleted, sessionToPublish)
return nil
return sessions, nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Session] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.Session) Session {
var summarizedAt time.Time
if item.SummarizedAt.Valid {
parsedTime, err := time.Parse(time.RFC3339Nano, item.SummarizedAt.String)
if err == nil {
summarizedAt = parsedTime
}
}
createdAt, _ := time.Parse(time.RFC3339Nano, item.CreatedAt)
updatedAt, _ := time.Parse(time.RFC3339Nano, item.UpdatedAt)
func (s service) fromDBItem(item db.Session) Session {
return Session{
ID: item.ID,
ParentSessionID: item.ParentSessionID.String,
@@ -219,37 +136,15 @@ func (s *service) fromDBItem(item db.Session) Session {
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
Cost: item.Cost,
Summary: item.Summary.String,
SummarizedAt: summarizedAt,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func Create(ctx context.Context, title string) (Session, error) {
return GetService().Create(ctx, title)
}
func CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
return GetService().CreateTaskSession(ctx, toolCallID, parentSessionID, title)
}
func Get(ctx context.Context, id string) (Session, error) {
return GetService().Get(ctx, id)
}
func List(ctx context.Context) ([]Session, error) {
return GetService().List(ctx)
}
func Update(ctx context.Context, session Session) (Session, error) {
return GetService().Update(ctx, session)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Session] {
return GetService().Subscribe(ctx)
func NewService(q db.Querier) Service {
broker := pubsub.NewBroker[Session]()
return &service{
broker,
q,
}
}
-117
View File
@@ -1,117 +0,0 @@
package status
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/sst/opencode/internal/pubsub"
)
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelDebug Level = "debug"
)
type StatusMessage struct {
Level Level `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
const (
EventStatusPublished pubsub.EventType = "status_published"
)
type Service interface {
pubsub.Subscriber[StatusMessage]
Info(message string)
Warn(message string)
Error(message string)
Debug(message string)
}
type service struct {
broker *pubsub.Broker[StatusMessage]
mu sync.RWMutex
}
var globalStatusService *service
func InitService() error {
if globalStatusService != nil {
return fmt.Errorf("status service already initialized")
}
broker := pubsub.NewBroker[StatusMessage]()
globalStatusService = &service{
broker: broker,
}
return nil
}
func GetService() Service {
if globalStatusService == nil {
panic("status service not initialized. Call status.InitService() at application startup.")
}
return globalStatusService
}
func (s *service) Info(message string) {
s.publish(LevelInfo, message)
slog.Info(message)
}
func (s *service) Warn(message string) {
s.publish(LevelWarn, message)
slog.Warn(message)
}
func (s *service) Error(message string) {
s.publish(LevelError, message)
slog.Error(message)
}
func (s *service) Debug(message string) {
s.publish(LevelDebug, message)
slog.Debug(message)
}
func (s *service) publish(level Level, messageText string) {
statusMsg := StatusMessage{
Level: level,
Message: messageText,
Timestamp: time.Now(),
}
s.broker.Publish(EventStatusPublished, statusMsg)
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return s.broker.Subscribe(ctx)
}
func Info(message string) {
GetService().Info(message)
}
func Warn(message string) {
GetService().Warn(message)
}
func Error(message string) {
GetService().Error(message)
}
func Debug(message string) {
GetService().Debug(message)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return GetService().Subscribe(ctx)
}
+39 -53
View File
@@ -6,41 +6,28 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/version"
"github.com/kujtimiihoxha/opencode/internal/config"
"github.com/kujtimiihoxha/opencode/internal/session"
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
"github.com/kujtimiihoxha/opencode/internal/version"
)
type SendMsg struct {
Text string
Attachments []message.Attachment
Text string
}
func header(width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(width),
)
}
type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
type EditorFocusMsg bool
func lspsConfigured(width int) string {
cfg := config.Get()
title := "LSP Servers"
title := "LSP Configuration"
title = ansi.Truncate(title, width, "…")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
lsps := baseStyle.
Width(width).
Foreground(t.Primary()).
Bold(true).
Render(title)
lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
// Get LSP names and sort them for consistent ordering
var lspNames []string
@@ -52,19 +39,16 @@ func lspsConfigured(width int) string {
var lspViews []string
for _, name := range lspNames {
lsp := cfg.LSP[name]
lspName := baseStyle.
Foreground(t.Text()).
Render(fmt.Sprintf("• %s", name))
lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
fmt.Sprintf("• %s", name),
)
cmd := lsp.Command
cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
lspPath := baseStyle.
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" (%s)", cmd))
lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
fmt.Sprintf(" (%s)", cmd),
)
lspViews = append(lspViews,
baseStyle.
styles.BaseStyle.
Width(width).
Render(
lipgloss.JoinHorizontal(
@@ -75,8 +59,7 @@ func lspsConfigured(width int) string {
),
)
}
return baseStyle.
return styles.BaseStyle.
Width(width).
Render(
lipgloss.JoinVertical(
@@ -92,14 +75,10 @@ func lspsConfigured(width int) string {
func logo(width int) string {
logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
versionText := baseStyle.
Foreground(t.TextMuted()).
Render(version.Version)
version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
return baseStyle.
return styles.BaseStyle.
Bold(true).
Width(width).
Render(
@@ -107,27 +86,34 @@ func logo(width int) string {
lipgloss.Left,
logo,
" ",
versionText,
version,
),
)
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
repo := "https://github.com/kujtimiihoxha/opencode"
return styles.BaseStyle.
Foreground(styles.ForgroundDim).
Width(width).
Render(repo)
}
func cwd(width int) string {
cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
return styles.BaseStyle.
Foreground(styles.ForgroundDim).
Width(width).
Render(cwd)
}
func header(width int) string {
header := lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(width),
)
return header
}
+85 -192
View File
@@ -1,38 +1,32 @@
package chat
import (
"fmt"
"os"
"os/exec"
"slices"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/kujtimiihoxha/opencode/internal/app"
"github.com/kujtimiihoxha/opencode/internal/session"
"github.com/kujtimiihoxha/opencode/internal/tui/layout"
"github.com/kujtimiihoxha/opencode/internal/tui/styles"
"github.com/kujtimiihoxha/opencode/internal/tui/util"
)
type editorCmp struct {
width int
height int
app *app.App
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
app *app.App
session session.Session
textarea textarea.Model
}
type EditorKeyMaps struct {
type FocusEditorMsg bool
type focusedEditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Blur key.Binding
}
type bluredEditorKeyMaps struct {
@@ -40,16 +34,15 @@ type bluredEditorKeyMaps struct {
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
var focusedKeyMaps = focusedEditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter", "ctrl+s"),
key.WithHelp("enter", "send message"),
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "send message"),
),
Blur: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "focus messages"),
),
OpenEditor: key.NewBinding(
key.WithKeys("ctrl+e"),
@@ -57,36 +50,30 @@ var editorMaps = EditorKeyMaps{
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
var bluredKeyMaps = bluredEditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("ctrl+s", "enter"),
key.WithHelp("ctrl+s/enter", "send message"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
Focus: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "focus editor"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attchments"),
OpenEditor: key.NewBinding(
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"),
),
}
const (
maxAttachments = 5
)
func (m *editorCmp) openEditor(value string) tea.Cmd {
func openEditor() tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
@@ -95,24 +82,15 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
return util.ReportError(err)
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
Text: string(content),
}
})
}
@@ -122,23 +100,19 @@ func (m *editorCmp) Init() tea.Cmd {
}
func (m *editorCmp) send() tea.Cmd {
if m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) {
status.Warn("Agent is working, please wait...")
return nil
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return util.ReportWarn("Agent is working, please wait...")
}
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
m.attachments = nil
m.textarea.Blur()
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
Text: value,
}),
)
}
@@ -146,98 +120,52 @@ func (m *editorCmp) send() tea.Cmd {
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
case SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
}
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
case FocusEditorMsg:
if msg {
m.textarea.Focus()
return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
num := int(msg.Runes[0] - '0')
m.deleteMode = false
if num < 10 && len(m.attachments) > num {
if num == 0 {
m.attachments = m.attachments[num+1:]
} else {
m.attachments = slices.Delete(m.attachments, num, num+1)
}
return m, nil
if key.Matches(msg, focusedKeyMaps.OpenEditor) {
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return m, util.ReportWarn("Agent is working, please wait...")
}
return m, openEditor()
}
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
// if the key does not match any binding, return
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
return m, m.send()
}
if key.Matches(msg, editorMaps.OpenEditor) {
if m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) {
status.Warn("Agent is working, please wait...")
return m, nil
}
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) {
return m, m.send()
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) {
m.textarea.Blur()
return m, util.CmdHandler(EditorFocusMsg(false))
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) {
m.textarea.Focus()
return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *editorCmp) View() string {
t := theme.CurrentTheme()
style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
// Style the prompt with theme colors
style := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
if len(m.attachments) == 0 {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
)
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
m.textarea.SetWidth(width)
return nil
}
@@ -245,70 +173,35 @@ func (m *editorCmp) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height()
}
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
}
func (m *editorCmp) BindingKeys() []key.Binding {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
if m.textarea.Focused() {
bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
} else {
bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
}
bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
return bindings
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
return ta
}
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
ti := textarea.New()
ti.Prompt = " "
ti.ShowLineNumbers = false
ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
ti.CharLimit = -1
ti.Focus()
return &editorCmp{
app: app,
textarea: ta,
textarea: ti,
}
}

Some files were not shown because too many files have changed in this diff Show More