diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f225aa7..fa911ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- go-version: ['1.22.x']
+ go-version: ['1.26.x']
steps:
- uses: actions/checkout@v4
@@ -30,8 +30,16 @@ jobs:
run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi
if: runner.os == 'Linux'
+ - name: Vet
+ run: go vet ./...
+
+ - name: Run Tests
+ run: go test -v -race -timeout 10m ./...
+ if: runner.os != 'Windows'
+
- name: Run Tests
- run: go test -v ./...
+ run: go test -v -timeout 10m ./...
+ if: runner.os == 'Windows'
- name: Build
run: go build -v ./...
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 7e98b47..27b0983 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.22.x'
+ go-version: '1.26.x'
- name: Install dependencies
run: go mod download
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bd74024..14ffc05 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.22.x'
+ go-version: '1.26.x'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index eeee028..81e61f0 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -17,17 +17,17 @@ builds:
- CGO_ENABLED=0
ldflags:
- -s -w
- - -X main.version={{.Version}}
- - -X main.commit={{.ShortCommit}}
- - -X main.date={{.Date}}
- - -X main.builtBy=goreleaser
+ - -X github.com/Nithwin/WindMist/cmd.Version={{.Version}}
+ - -X github.com/Nithwin/WindMist/cmd.Commit={{.ShortCommit}}
+ - -X github.com/Nithwin/WindMist/cmd.Date={{.Date}}
archives:
- - format: tar.gz
- name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+ - formats: [tar.gz]
+ name_template: >-
+ {{ .ProjectName }}_{{ .Version }}_{{ if eq .Os "darwin" }}macOS{{ else }}{{ title .Os }}{{ end }}_{{ if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }}
format_overrides:
- goos: windows
- format: zip
+ formats: [zip]
checksum:
name_template: "checksums.txt"
@@ -52,4 +52,4 @@ brews:
homepage: "https://github.com/Nithwin/WindMist"
description: "AI coding agent for the terminal."
test: |
- system "#{bin}/windmist --version"
+ system "#{bin}/windmist", "version"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6098e8..a888e46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [v2.0.1] - 2026-08-06
+
+### Fixed
+- **Release packaging**: GoReleaser archive names now match `install.sh` / npm (`Linux`/`macOS`/`Windows` + `x86_64`), and ldflags correctly inject `cmd.Version` / `Commit` / `Date`.
+- **Gemini tool calling**: Thought signatures are attached to the first `functionCall` Part (required by Gemini 3); removed invalid signatures on tool-response Parts.
+- **Gemini streaming**: Removed the hard 60s HTTP client timeout that aborted long generations.
+- **RAG indexing**: `IndexProject(".")` no longer skips the entire workspace; vocabulary ranking is deterministic and vectors are re-embedded after rebuild.
+- **Sessions**: Absolute project paths on `/new`, nanosecond session IDs, and race-free async title updates via the Bubble Tea main loop.
+- **Agent lifecycle**: Provider/model switches close the previous agent (MCP/LSP) before creating a new one; session/mode changes use `Reconfigure` instead of leaking processes.
+- **Remote `/provider`**: No longer wipes the configured model when switching providers.
+- **MCP**: Safe type assertions when parsing tool schemas; array `items` types are preserved for Gemini.
+- **Uninstall `--purge`**: Also removes `~/.windmist` (sessions + RAG), not only `~/.config/windmist`.
+- **CI**: Go 1.26, `go vet`, and race tests aligned with `go.mod` / CHANGELOG claims.
+- **Docs/install**: Correct module path and `go build` instructions in README.
+
+### Changed
+- **Token efficiency (free-tier friendly)**: New `chat` mode for greetings/simple questions (no tools, tiny system prompt). Auto-router uses local heuristics first so "hi" no longer burns a classify call or drops into plan mode. System prompts collapsed; repo map capped at 80 files; history/context windows tightened.
+
+---
+
## [v2.0.0] - 2026-07-27
### Added
diff --git a/README.md b/README.md
index 4338686..be01664 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,20 @@
-# π WindMist `v2.0.0`
+# π WindMist `v2.0.1`
### Autonomous AI Software Engineer Running Directly in Your Terminal
-[](CHANGELOG.md)
+[](CHANGELOG.md)
[](LICENSE)
[](https://golang.org)
[](https://discord.gg/9hNxQdHYX)
[](CONTRIBUTING.md)
**A modern open-source AI coding assistant running right inside your terminal.**
-WindMist (`v2.0.0`) is built in high-performance Go to inspect code, edit files atomically across your workspace, and engage in multi-turn reasoning loops with local tools.
+WindMist (`v2.0.1`) is built in high-performance Go to inspect code, edit files atomically across your workspace, and engage in multi-turn reasoning loops with local tools.
> **π Official Website:** [windmist.vercel.app](https://windmist.vercel.app/) | **π» Website Repo:** [`windmist-site`](https://github.com/Nithwin/windmist-site) | **π¬ Community:** [Discord](https://discord.gg/9hNxQdHYX)
@@ -36,18 +36,24 @@ Experience an interactive AI pair programming session directly in your terminal:
## βοΈ Installation
-To install `windmist` (`v2.0.0`) using the Go toolchain (`Go 1.26+` required):
+To install `windmist` (`v2.0.1`) using the Go toolchain (`Go 1.26+` required):
```bash
-go install github.com/Nithwin/windmist/cmd/windmist@latest
+go install github.com/Nithwin/WindMist@latest
```
Or clone and build directly from source:
```bash
-git clone https://github.com/Nithwin/windmist.git
-cd windmist
-go build -o windmist ./cmd/windmist
+git clone https://github.com/Nithwin/WindMist.git
+cd WindMist
+go build -o windmist .
+```
+
+Or use the universal installer (Linux/macOS):
+
+```bash
+curl -sSL https://raw.githubusercontent.com/Nithwin/WindMist/v2/scripts/install.sh | bash
```
---
@@ -71,7 +77,7 @@ go build -o windmist ./cmd/windmist
## β¨ Features & Capabilities
-WindMist (`v2.0.0`) provides a robust, native engineering environment inside your terminal:
+WindMist (`v2.0.1`) provides a robust, native engineering environment inside your terminal:
* β
**Interactive AI Chat & TUI:** Rich Bubble Tea and Lip Gloss interface with real-time streaming, markdown rendering, and syntax coloring.
* β
**Multi-Agent Orchestration:** Delegate background research to cheaper, faster Sub-agents while keeping your main context clean.
@@ -93,7 +99,7 @@ WindMist (`v2.0.0`) provides a robust, native engineering environment inside you
| `windmist chat
` | Run a single-turn or multi-turn agent instruction directly from the command line. |
| `windmist set ` | Configure local environment and provider settings (`~/.windmist/config.yaml`). |
| `windmist show` | Display current local configuration settings. |
-| `windmist version` | Print current semantic release build version (`v2.0.0`). |
+| `windmist version` | Print current semantic release build version (`v2.0.1`). |
---
diff --git a/cmd/root.go b/cmd/root.go
index 3991cd5..e54c191 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -27,5 +27,6 @@ var rootCmd = &cobra.Command{
// Execute runs the root command.
func Execute() error {
+ rootCmd.Version = Version
return rootCmd.Execute()
}
diff --git a/cmd/uninstall.go b/cmd/uninstall.go
index cf3cb03..145e932 100644
--- a/cmd/uninstall.go
+++ b/cmd/uninstall.go
@@ -21,7 +21,7 @@ var (
var uninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Uninstall the WindMist CLI executable and optionally purge configuration",
- Long: `Safely removes the WindMist CLI executable from your system binary path and optionally cleans up saved configuration files (` + "`~/.config/windmist`" + `).`,
+ Long: `Safely removes the WindMist CLI executable from your system binary path and optionally cleans up saved configuration and data (` + "`~/.config/windmist`" + ` and ` + "`~/.windmist`" + `).`,
RunE: func(cmd *cobra.Command, args []string) error {
execPath, err := os.Executable()
if err != nil {
@@ -47,9 +47,34 @@ var uninstallCmd = &cobra.Command{
if !uninstallPurgeFlag && !uninstallYesFlag {
cfgDir, _ := config.ConfigDir()
- if cfgDir != "" {
- if _, err := os.Stat(cfgDir); err == nil {
- fmt.Printf("β Also remove all configuration and chat history in %s? [y/N]: ", cfgDir)
+ home, _ := os.UserHomeDir()
+ dataDir := ""
+ if home != "" {
+ dataDir = filepath.Join(home, ".windmist")
+ }
+ purgeTarget := cfgDir
+ if dataDir != "" {
+ if purgeTarget != "" {
+ purgeTarget = purgeTarget + " and " + dataDir
+ } else {
+ purgeTarget = dataDir
+ }
+ }
+ if purgeTarget != "" {
+ cfgExists := false
+ if cfgDir != "" {
+ if _, err := os.Stat(cfgDir); err == nil {
+ cfgExists = true
+ }
+ }
+ dataExists := false
+ if dataDir != "" {
+ if _, err := os.Stat(dataDir); err == nil {
+ dataExists = true
+ }
+ }
+ if cfgExists || dataExists {
+ fmt.Printf("β Also remove all configuration and chat history in %s? [y/N]: ", purgeTarget)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if strings.EqualFold(input, "y") || strings.EqualFold(input, "yes") {
@@ -88,6 +113,15 @@ var uninstallCmd = &cobra.Command{
}
}
}
+ if home, err := os.UserHomeDir(); err == nil {
+ dataDir := filepath.Join(home, ".windmist")
+ if _, err := os.Stat(dataDir); err == nil {
+ fmt.Printf("ποΈ Removing data directory at %s...\n", dataDir)
+ if err := os.RemoveAll(dataDir); err != nil {
+ fmt.Printf("β οΈ Warning: failed to remove data directory: %v\n", err)
+ }
+ }
+ }
}
fmt.Println("\n⨠WindMist has been successfully uninstalled. Goodbye!")
diff --git a/cmd/version.go b/cmd/version.go
index a11c69c..e50e604 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -7,7 +7,7 @@ import (
)
var (
- Version = "v2.0.0"
+ Version = "v2.0.1"
Commit = "none"
Date = "unknown"
)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index a670fc2..520097c 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -113,6 +113,27 @@ func (a *Agent) Close() {
}
}
+// Reconfigure updates session/mode/store settings without restarting MCP/LSP.
+func (a *Agent) Reconfigure(cfg Config) {
+ if cfg.Mode == "" {
+ cfg.Mode = string(ModeBuild)
+ }
+ a.config.SessionID = cfg.SessionID
+ a.config.Mode = cfg.Mode
+ if cfg.Store != nil {
+ a.config.Store = cfg.Store
+ }
+ if cfg.MaxTurns > 0 {
+ a.config.MaxTurns = cfg.MaxTurns
+ }
+ if cfg.MaxContextTokens > 0 {
+ a.config.MaxContextTokens = cfg.MaxContextTokens
+ }
+ if cfg.Memory != nil {
+ a.config.Memory = cfg.Memory
+ }
+}
+
// Manager returns the tools manager associated with the agent.
func (a *Agent) Manager() *tools.Manager {
return a.manager
diff --git a/internal/agent/executor.go b/internal/agent/executor.go
index 71126ea..68d7a6e 100644
--- a/internal/agent/executor.go
+++ b/internal/agent/executor.go
@@ -186,7 +186,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s
// toolDefinitions converts the registered tool definitions from tools.Manager into ai.ToolDefinition format.
func (a *Agent) toolDefinitions(modeConfig ModeConfig) []ai.ToolDefinition {
- if a.manager == nil {
+ if a.manager == nil || !modeConfig.AllowTools {
return nil
}
@@ -211,7 +211,8 @@ func (a *Agent) toolDefinitions(modeConfig ModeConfig) []ai.ToolDefinition {
})
}
- if a.mcpManager != nil {
+ // MCP tools are write-capable; only attach them in build mode.
+ if a.mcpManager != nil && modeConfig.AllowFileEdits {
defs = append(defs, a.mcpManager.GetTools()...)
}
diff --git a/internal/agent/intent.go b/internal/agent/intent.go
new file mode 100644
index 0000000..78aba60
--- /dev/null
+++ b/internal/agent/intent.go
@@ -0,0 +1,75 @@
+package agent
+
+import (
+ "regexp"
+ "strings"
+ "unicode/utf8"
+)
+
+var (
+ greetingRE = regexp.MustCompile(`(?i)^(hi|hello|hey|yo|sup|hola|howdy|good\s+(morning|afternoon|evening)|thanks|thank\s*you|thx|ty|ok|okay|cool|great|nice|bye|goodbye|see\s+ya)[\s!.?]*$`)
+ buildRE = regexp.MustCompile(`(?i)\b(implement|fix|create|add|write|edit|modify|update|refactor|rename|delete|remove|install|migrate|generate|scaffold|build|make|patch|apply|replace|insert|debug|resolve)\b`)
+ planRE = regexp.MustCompile(`(?i)\b(plan|analyze|analyse|review|explain|search|find|locate|how\s+(do|does|to)|what\s+(is|are|does)|why\s+(is|are|does)|where\s+(is|are)|compare|outline|architect|design\s+a|walk\s*me\s*through|summarize|summarise)\b`)
+ codeHintRE = regexp.MustCompile(`(?i)(\x60{3}|@[\w./\\-]+|\.(go|ts|tsx|js|jsx|py|rs|java|kt|c|cpp|h|cs|rb|php|swift)\b|func\s+\w+|class\s+\w+|def\s+\w+)`)
+)
+
+// ClassifyIntent resolves auto-mode without an LLM call when confidence is high.
+// Returns (mode, ok). When ok is false, the caller should use the LLM router.
+func ClassifyIntent(userPrompt string) (Mode, bool) {
+ p := strings.TrimSpace(userPrompt)
+ if p == "" {
+ return ModeChat, true
+ }
+
+ lower := strings.ToLower(p)
+ runes := utf8.RuneCountInString(p)
+
+ // Pure greetings / acknowledgements β never need plan or build.
+ if greetingRE.MatchString(strings.TrimSpace(lower)) {
+ return ModeChat, true
+ }
+
+ // Very short small-talk without code signals.
+ if runes <= 40 && !codeHintRE.MatchString(p) && !buildRE.MatchString(lower) {
+ // "hi there", "how's it going", "who are you", etc.
+ if !planRE.MatchString(lower) || runes <= 20 {
+ return ModeChat, true
+ }
+ }
+
+ hasBuild := buildRE.MatchString(lower)
+ hasPlan := planRE.MatchString(lower)
+ hasCode := codeHintRE.MatchString(p)
+
+ // Explicit implementation intent.
+ if hasBuild && (hasCode || runes > 25) {
+ return ModeBuild, true
+ }
+ if hasBuild && !hasPlan {
+ return ModeBuild, true
+ }
+
+ // Analysis / explanation without edit verbs.
+ if hasPlan && !hasBuild {
+ // Short conceptual questions stay in chat (no tools/repo map tax).
+ if runes < 100 && !hasCode && !strings.Contains(lower, "codebase") &&
+ !strings.Contains(lower, "this project") && !strings.Contains(lower, "this repo") {
+ return ModeChat, true
+ }
+ return ModePlan, true
+ }
+
+ // Short questions with no engineering verbs β chat.
+ if runes < 60 && !hasBuild && !hasCode {
+ return ModeChat, true
+ }
+
+ return "", false
+}
+
+// IsTrivialPrompt reports whether a message is too trivial to spend an
+// auto-title API call on (greetings, thanks, etc.).
+func IsTrivialPrompt(userPrompt string) bool {
+ mode, ok := ClassifyIntent(userPrompt)
+ return ok && mode == ModeChat && utf8.RuneCountInString(strings.TrimSpace(userPrompt)) <= 40
+}
diff --git a/internal/agent/intent_test.go b/internal/agent/intent_test.go
new file mode 100644
index 0000000..40b8537
--- /dev/null
+++ b/internal/agent/intent_test.go
@@ -0,0 +1,50 @@
+package agent
+
+import "testing"
+
+func TestClassifyIntent_GreetingsGoToChat(t *testing.T) {
+ cases := []string{"hi", "Hi!", "hello", "hey there", "thanks", "thank you", "ok", "bye"}
+ for _, c := range cases {
+ mode, ok := ClassifyIntent(c)
+ if !ok || mode != ModeChat {
+ t.Fatalf("%q β (%q, %v), want (chat, true)", c, mode, ok)
+ }
+ }
+}
+
+func TestClassifyIntent_BuildVerbs(t *testing.T) {
+ cases := []string{
+ "fix the login bug",
+ "implement user auth",
+ "create a new README",
+ "refactor the agent loop",
+ "add a /mode command",
+ }
+ for _, c := range cases {
+ mode, ok := ClassifyIntent(c)
+ if !ok || mode != ModeBuild {
+ t.Fatalf("%q β (%q, %v), want (build, true)", c, mode, ok)
+ }
+ }
+}
+
+func TestClassifyIntent_PlanVsChat(t *testing.T) {
+ mode, ok := ClassifyIntent("explain this codebase architecture")
+ if !ok || mode != ModePlan {
+ t.Fatalf("codebase question β (%q, %v), want plan", mode, ok)
+ }
+
+ mode, ok = ClassifyIntent("what is a mutex?")
+ if !ok || mode != ModeChat {
+ t.Fatalf("simple conceptual Q β (%q, %v), want chat", mode, ok)
+ }
+}
+
+func TestIsTrivialPrompt(t *testing.T) {
+ if !IsTrivialPrompt("hi") {
+ t.Fatal("hi should be trivial")
+ }
+ if IsTrivialPrompt("fix the nil pointer in agent loop") {
+ t.Fatal("engineering task should not be trivial")
+ }
+}
diff --git a/internal/agent/limits.go b/internal/agent/limits.go
index ef5815b..0f5eb3e 100644
--- a/internal/agent/limits.go
+++ b/internal/agent/limits.go
@@ -8,13 +8,13 @@ var ErrMaxTurnsExceeded = errors.New("agent reached maximum reasoning turns")
const (
// DefaultMaxTurns is the maximum number of reasoning iterations
// the agent will perform before stopping.
- DefaultMaxTurns = 25
+ DefaultMaxTurns = 20
// MaxToolCallsPerTurn is the maximum number of tool calls the
// agent will execute from a single model response.
- MaxToolCallsPerTurn = 32
+ MaxToolCallsPerTurn = 16
// DefaultMaxContextTokens is the maximum number of tokens to keep in
- // the conversation history sliding window.
- DefaultMaxContextTokens = 15000
+ // the conversation history sliding window (tuned for free-tier limits).
+ DefaultMaxContextTokens = 8000
)
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
index 65feb1c..1764b72 100644
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@ -24,9 +24,10 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
effectiveMode := a.config.Mode
if effectiveMode == string(ModeAuto) {
- resolvedMode := a.orchestrateMode(ctx, userPrompt)
+ resolvedMode, viaLLM := a.resolveMode(ctx, userPrompt)
effectiveMode = resolvedMode
- if onChunk != nil {
+ // Only announce when we spent an LLM call or selected an engineering mode.
+ if onChunk != nil && (viaLLM || resolvedMode != string(ModeChat)) {
onChunk(fmt.Sprintf("\n> π€ **Auto-Router**: Selected `%s` mode.\n\n", resolvedMode))
}
}
@@ -37,11 +38,12 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
}
prunedHistory := a.config.Memory.Prune(messages, a.config.MaxContextTokens)
- // Build dynamic system prompt based on mode.
- // Re-read cwd each turn in case a tool changed directory.
cwd, _ = os.Getwd()
modeConfig := GetModeConfig(Mode(effectiveMode))
- dynamicSystemPrompt := prompt.Build(cwd, modeConfig.SystemPrompt)
+ dynamicSystemPrompt := prompt.Build(cwd, modeConfig.SystemPrompt, prompt.Options{
+ IncludeRepoMap: modeConfig.IncludeRepoMap,
+ IncludeGuides: modeConfig.AllowTools,
+ })
req := &ai.GenerateRequest{
System: dynamicSystemPrompt,
@@ -49,7 +51,6 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
Tools: a.toolDefinitions(modeConfig),
}
- // Enforce rate limits before making the API call
if err := a.limiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limit exceeded or context cancelled: %w", err)
}
@@ -66,7 +67,6 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
break
}
- // Don't retry if context is cancelled by user
if ctx.Err() != nil {
break
}
@@ -75,7 +75,6 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
break
}
- // Wait before retrying with exponential backoff
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
@@ -107,7 +106,6 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
}, nil
}
- // Enforce MaxToolCallsPerTurn to prevent runaway tool execution
calls := resp.ToolCalls
if len(calls) > MaxToolCallsPerTurn {
if onChunk != nil {
@@ -124,33 +122,43 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s
return nil, ErrMaxTurnsExceeded
}
+// resolveMode picks chat/plan/build. Prefer local heuristics to save free-tier quota.
+// viaLLM is true only when the LLM classifier was actually called.
+func (a *Agent) resolveMode(ctx context.Context, userPrompt string) (mode string, viaLLM bool) {
+ if m, ok := ClassifyIntent(userPrompt); ok {
+ return string(m), false
+ }
+ return a.orchestrateMode(ctx, userPrompt), true
+}
+
// orchestrateMode sends a fast prompt to the LLM to classify the user's intent.
func (a *Agent) orchestrateMode(ctx context.Context, userPrompt string) string {
- systemPrompt := `You are an AI orchestrator for a coding assistant.
-Your ONLY job is to classify the user's prompt into one of two modes:
-1. "build" - The user wants you to write code, fix a bug, create a file, or modify the codebase.
-2. "plan" - The user just wants you to analyze, review, explain, search, or output a step-by-step plan WITHOUT modifying any files.
+ systemPrompt := `Classify the user message into exactly one mode:
+- chat: greeting, thanks, small talk, or a short question that needs no codebase tools
+- plan: analyze/explain/search/review the project without editing files
+- build: write, edit, fix, create, or otherwise change files/code
-Reply with EXACTLY ONE WORD: either "build" or "plan". Do not include any punctuation or extra text.`
+Reply with ONE word only: chat, plan, or build.`
req := &ai.GenerateRequest{
System: systemPrompt,
Messages: []ai.Message{
{Role: ai.RoleUser, Content: userPrompt},
},
- // No tools needed for classification
}
- // We use the same provider to classify, but ideally a smaller model.
- // For now, we just use the active model.
resp, err := a.provider.Generate(ctx, req)
if err != nil {
- return string(ModeBuild) // Default fallback
+ return string(ModeChat) // Prefer cheap fallback on free tier
}
res := strings.ToLower(strings.TrimSpace(resp.Text))
- if strings.Contains(res, "plan") {
+ switch {
+ case strings.Contains(res, "build"):
+ return string(ModeBuild)
+ case strings.Contains(res, "plan"):
return string(ModePlan)
+ default:
+ return string(ModeChat)
}
- return string(ModeBuild)
}
diff --git a/internal/agent/mode.go b/internal/agent/mode.go
index 6e1db6c..5cf9975 100644
--- a/internal/agent/mode.go
+++ b/internal/agent/mode.go
@@ -8,8 +8,10 @@ import (
type Mode string
const (
- // ModeAuto automatically decides between Plan and Build based on the prompt.
+ // ModeAuto automatically decides between Chat, Plan, and Build based on the prompt.
ModeAuto Mode = "auto"
+ // ModeChat is a lightweight conversational mode: no tools, minimal prompt.
+ ModeChat Mode = "chat"
// ModeBuild has full read/write access and autonomy.
ModeBuild Mode = "build"
// ModePlan is read-only. It can search and analyze, but cannot write files.
@@ -23,47 +25,65 @@ type ModeConfig struct {
SystemPrompt string
AllowFileEdits bool
AllowCommands bool
+ // AllowTools controls whether any tools (including MCP) are sent to the model.
+ AllowTools bool
+ // IncludeRepoMap injects a workspace file tree into the system prompt.
+ IncludeRepoMap bool
}
// GetModeConfig returns the configuration for a given mode.
func GetModeConfig(mode Mode) ModeConfig {
switch mode {
+ case ModeChat:
+ return ModeConfig{
+ Name: ModeChat,
+ Description: "Lightweight chat. No tools; minimal prompt for greetings and simple questions.",
+ SystemPrompt: "You are WindMist, a concise coding assistant. Answer briefly and helpfully. Do not invent file contents or pretend to have edited code. If the user wants changes in their project, tell them to ask you to implement it.",
+ AllowFileEdits: false,
+ AllowCommands: false,
+ AllowTools: false,
+ IncludeRepoMap: false,
+ }
case ModePlan:
return ModeConfig{
Name: ModePlan,
- Description: "Safe Chat & Plan Mode. Analyzes and plans but cannot edit files.",
- SystemPrompt: "You are WindMist in CHAT/PLAN mode. Your job is to answer questions, search the codebase, read files, and output detailed plans. YOU CANNOT MODIFY FILES OR WRITE CODE TO DISK. Do not attempt to use any write tools.",
+ Description: "Read-only analysis and planning. Cannot edit files.",
+ SystemPrompt: "You are WindMist in PLAN mode. Answer questions, search/read the codebase, and propose plans. Do NOT modify files or run destructive commands.",
AllowFileEdits: false,
AllowCommands: false,
+ AllowTools: true,
+ IncludeRepoMap: true,
}
default:
- // Default to build (even if auto, the actual execution mode resolves to build/plan)
+ // Default to build (even if auto, execution resolves to chat/plan/build)
return ModeConfig{
Name: ModeBuild,
Description: "Full autonomy mode. Can read, write, and execute.",
- SystemPrompt: "You are WindMist, an expert autonomous coding agent in BUILD mode. Your job is to implement features, fix bugs, and refactor code directly. You have full access to the filesystem. When asked to complete a task, you should read relevant files, make the necessary edits using your tools, and run commands to verify your work. Act surgically and efficiently.",
+ SystemPrompt: "You are WindMist in BUILD mode. Implement features, fix bugs, and edit files directly. Inspect before editing. Prefer the smallest safe change. Verify when practical.",
AllowFileEdits: true,
AllowCommands: true,
+ AllowTools: true,
+ IncludeRepoMap: true,
}
}
}
// FilterTools returns only the tools allowed by the given ModeConfig.
func FilterTools(manager *tools.Manager, config ModeConfig) []tools.Definition {
+ if !config.AllowTools {
+ return nil
+ }
+
var allowed []tools.Definition
for _, tool := range manager.List() {
def := tool.Definition()
- // If edits are denied, filter out PermWrite and PermDangerous
+ // If edits are denied, filter out write/dangerous tools
if !config.AllowFileEdits && (def.Category == tools.CategoryEditing || def.Permission == tools.PermWrite || def.Permission == tools.PermDangerous) {
continue
}
- // Wait, if commands are denied, we could filter out system/command tools,
- // but maybe we just require permission instead of completely filtering.
- // For now, in plan mode, we completely disable editing tools.
-
allowed = append(allowed, def)
}
diff --git a/internal/agent/prompt/builder.go b/internal/agent/prompt/builder.go
index 3ab006d..74c2a17 100644
--- a/internal/agent/prompt/builder.go
+++ b/internal/agent/prompt/builder.go
@@ -6,32 +6,41 @@ import (
"strings"
)
-// Build constructs the complete system prompt for WindMist.
-// It dynamically generates a map of the workspace if cwd is provided.
-func Build(cwd string, modeSystemPrompt string) string {
+// Options controls which optional sections are included in the system prompt.
+type Options struct {
+ IncludeRepoMap bool
+ IncludeGuides bool // developer + tool workflow guidance
+}
+
+// Build constructs the system prompt for WindMist.
+func Build(cwd string, modeSystemPrompt string, opts Options) string {
if modeSystemPrompt == "" {
modeSystemPrompt = System()
}
- sections := []string{
- modeSystemPrompt,
- Developer(),
- Tools(),
+ sections := []string{modeSystemPrompt}
+
+ if opts.IncludeGuides {
+ sections = append(sections, Workflow())
}
- if cwd != "" {
+ if opts.IncludeRepoMap && cwd != "" {
sections = append(sections, RepoMap(cwd))
+ }
- // Look for custom AGENTS.md or .windmist/prompt.md conventions
+ if cwd != "" {
if custom := loadCustomPrompt(cwd); custom != "" {
- sections = append(sections, "## Workspace Conventions\n\nThe following rules apply specifically to this workspace:\n\n"+custom)
+ // Cap custom prompts to avoid blowing free-tier context.
+ if len(custom) > 4000 {
+ custom = custom[:4000] + "\nβ¦[truncated]"
+ }
+ sections = append(sections, "## Workspace Conventions\n\n"+custom)
}
}
return strings.Join(sections, "\n\n")
}
-// loadCustomPrompt checks for local workspace prompt files.
func loadCustomPrompt(cwd string) string {
paths := []string{
filepath.Join(cwd, "AGENTS.md"),
diff --git a/internal/agent/prompt/developer.go b/internal/agent/prompt/developer.go
deleted file mode 100644
index a6fb7ed..0000000
--- a/internal/agent/prompt/developer.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package prompt
-
-// Developer returns the behavioral and workflow instructions for WindMist.
-// These instructions define how WindMist should approach software engineering
-// tasks and how it should use its available tools.
-func Developer() string {
- return `
-## Software Engineering Workflow
-
-Approach every task methodically.
-
-Understand the problem before making changes.
-
-Collect enough information to make informed decisions instead of guessing.
-
-When a task requires modifying an existing project:
-
-1. Discover the relevant files.
-2. Read the necessary context.
-3. Plan the required changes.
-4. Perform the smallest safe edits.
-5. Verify that the requested task has been completed.
-
-Never modify code that is unrelated to the user's request.
-
-## Tool Usage
-
-Tools exist to gather information and modify projects safely.
-
-Never guess the contents of files if the information can be obtained using available tools.
-
-Prefer inspecting the project before editing it.
-
-Do not rewrite an entire file when a targeted edit is sufficient.
-
-Choose the most precise editing operation available.
-
-If an operation fails because the expected content or location was incorrect, inspect the project again before attempting another modification.
-
-## Working with Existing Projects
-
-Respect the existing architecture, coding style, formatting, and naming conventions.
-
-Do not introduce unnecessary abstractions.
-
-Do not rename files, functions, or variables unless required.
-
-Avoid changing behavior outside the requested scope.
-
-Preserve existing comments unless they are incorrect or obsolete.
-
-## Working with New Projects
-
-When creating a new project:
-
-- Create a logical directory structure.
-- Keep implementations simple.
-- Prefer maintainable code over clever code.
-- Include only files that provide value.
-- Avoid unnecessary dependencies.
-
-## Error Recovery
-
-When a tool reports an error:
-
-- Read the error carefully.
-- Determine why it happened.
-- Gather additional information if needed.
-- Retry only when there is a clear reason to believe the next attempt will succeed.
-
-Do not repeatedly execute the same failing action.
-
-## Decision Making
-
-Prefer evidence over assumptions.
-
-If multiple solutions exist:
-
-- Choose the simplest solution that satisfies the user's request.
-- Prefer maintainability.
-- Prefer readability.
-- Prefer consistency with the existing project.
-
-## Completion
-
-Only finish a task when the user's request has been satisfied.
-
-If something cannot be completed because information is missing, explain what is needed instead of guessing.
-`
-}
diff --git a/internal/agent/prompt/repomap.go b/internal/agent/prompt/repomap.go
index 6ddda7d..36621fe 100644
--- a/internal/agent/prompt/repomap.go
+++ b/internal/agent/prompt/repomap.go
@@ -7,47 +7,39 @@ import (
"strings"
)
-// ignoredDirs is a list of directories we shouldn't map to avoid exploding tokens
var ignoredDirs = map[string]bool{
- ".git": true,
- "node_modules": true,
- "vendor": true,
- "dist": true,
- "build": true,
- "target": true,
- ".next": true,
- ".gemini": true,
- "__pycache__": true,
+ ".git": true, "node_modules": true, "vendor": true, "dist": true,
+ "build": true, "target": true, ".next": true, ".gemini": true,
+ "__pycache__": true, ".venv": true, "venv": true, "coverage": true,
}
-// RepoMap dynamically builds a file tree of the workspace to inject into the system prompt.
-// This prevents the AI from needing to run `list_dir` manually and stops hallucinated file paths.
+// RepoMap builds a compact workspace tree for the system prompt.
func RepoMap(cwd string) string {
var sb strings.Builder
- sb.WriteString("## Repository Map\n\n")
- sb.WriteString(fmt.Sprintf("You are currently working in the directory: **%s**\n\n", cwd))
- sb.WriteString("Below is a tree representation of the files in the current workspace.\n")
- sb.WriteString("Use this map to understand the project structure and locate files instantly.\n")
- sb.WriteString("This map is dynamically updated on every turn to reflect the current state of the filesystem.\n\n")
- sb.WriteString("```\n.\n")
+ sb.WriteString("## Repo map (")
+ sb.WriteString(cwd)
+ sb.WriteString(")\n```\n")
fileCount := 0
- maxFiles := 250 // hard limit to prevent token blowout
+ maxFiles := 80 // keep free-tier prompts lean
+ maxDepth := 2
- err := filepath.WalkDir(cwd, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return nil // skip errors
+ _ = filepath.WalkDir(cwd, func(path string, d os.DirEntry, err error) error {
+ if err != nil || path == cwd {
+ return nil
}
- if path == cwd {
- return nil
+ if d.IsDir() && ignoredDirs[d.Name()] {
+ return filepath.SkipDir
}
- // Check if we should ignore this directory
- if d.IsDir() {
- if ignoredDirs[d.Name()] {
+ relPath, _ := filepath.Rel(cwd, path)
+ depth := strings.Count(relPath, string(os.PathSeparator))
+ if depth > maxDepth {
+ if d.IsDir() {
return filepath.SkipDir
}
+ return nil
}
fileCount++
@@ -58,35 +50,20 @@ func RepoMap(cwd string) string {
return nil
}
- // Calculate depth
- relPath, _ := filepath.Rel(cwd, path)
- depth := strings.Count(relPath, string(os.PathSeparator))
-
- if depth > 2 && d.IsDir() {
- return filepath.SkipDir
- }
-
indent := strings.Repeat(" ", depth)
- prefix := "βββ "
-
- sb.WriteString(indent + prefix + d.Name())
+ name := d.Name()
if d.IsDir() {
- sb.WriteString("/")
+ name += "/"
}
- sb.WriteString("\n")
-
+ sb.WriteString(indent)
+ sb.WriteString(name)
+ sb.WriteByte('\n')
return nil
})
- if err != nil {
- sb.WriteString(" [Error generating map]\n")
- }
-
if fileCount > maxFiles {
- sb.WriteString(fmt.Sprintf("\n... [Map truncated. Project has >%d files. Use list_dir tool to explore further.]\n", maxFiles))
+ sb.WriteString(fmt.Sprintf("β¦[+%d more; use list_dir]\n", fileCount-maxFiles))
}
-
- sb.WriteString("```\n")
-
+ sb.WriteString("```")
return sb.String()
}
diff --git a/internal/agent/prompt/system.go b/internal/agent/prompt/system.go
index 44cb251..2f8e06d 100644
--- a/internal/agent/prompt/system.go
+++ b/internal/agent/prompt/system.go
@@ -1,66 +1,6 @@
package prompt
-// System returns the core identity and behavioral instructions for WindMist.
-// This prompt defines what WindMist is, its mission, and the principles that
-// should guide every response. Tool usage and workflow instructions are added
-// separately by other prompt sections.
+// System returns a short identity prompt used when no mode prompt is supplied.
func System() string {
- return `
-You are WindMist, an AI software engineering agent.
-
-Your purpose is to help users design, build, debug, refactor, test, and maintain software projects. Your primary objective is to solve software engineering tasks accurately, safely, and efficiently.
-
-You are an engineering agent, not a general chatbot. Your decisions should prioritize correctness, reliability, maintainability, and the user's intent.
-
-## Mission
-
-Your mission is to help users complete software engineering tasks from start to finish. Break problems into manageable steps when appropriate, gather the information you need, and make thoughtful decisions based on available evidence rather than assumptions.
-
-When solving problems, prefer understanding before modification.
-
-## Capabilities
-
-You can:
-
-- Understand existing codebases.
-- Create new projects and files.
-- Modify existing code.
-- Debug and fix software issues.
-- Refactor code while preserving behavior.
-- Explain code, architectures, and technical concepts.
-- Help users learn software engineering.
-
-## Core Principles
-
-- Always prioritize correctness over speed.
-- Preserve the user's intent.
-- Make the smallest safe change that accomplishes the goal.
-- Avoid modifying unrelated code.
-- Never invent facts about a codebase or project.
-- Base your decisions on information available through the conversation and the tools provided.
-- If information is missing, gather it before making important decisions.
-- Be transparent when uncertain instead of pretending to know.
-
-## Engineering Philosophy
-
-Approach every task like an experienced software engineer.
-
-Before making changes, understand the relevant code and its context.
-
-Prefer simple, maintainable solutions over unnecessary complexity.
-
-Avoid introducing new dependencies, abstractions, or files unless they provide clear value.
-
-Respect the existing style and structure of the project unless the user requests otherwise.
-
-## Communication
-
-Be concise, clear, and professional.
-
-Explain what you changed and why when appropriate.
-
-Focus on solving the user's request rather than providing unnecessary background information.
-
-Do not expose internal reasoning or hidden decision-making processes.
-`
+ return `You are WindMist, an AI coding agent. Be correct, concise, and intent-preserving. Prefer the smallest safe change. Do not invent file contentsβinspect with tools when unsure.`
}
diff --git a/internal/agent/prompt/tools.go b/internal/agent/prompt/tools.go
deleted file mode 100644
index 275b231..0000000
--- a/internal/agent/prompt/tools.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package prompt
-
-// Tools returns guidance for selecting and using the available tools.
-// These instructions teach WindMist how to choose the safest and most
-// appropriate tool for each situation.
-func Tools() string {
- return `
-## General Tool Usage
-
-Use tools whenever they provide information that is not already available.
-
-Do not assume the contents of files, directories, or projects.
-
-Gather information before making important decisions.
-
-Avoid unnecessary tool calls. Each tool invocation should have a clear purpose.
-
-## Understanding a Project
-
-When working in an existing project:
-
-- Discover relevant files before editing.
-- Read the necessary context before making changes.
-- Understand the surrounding implementation before modifying code.
-
-Do not edit code you have not inspected unless the task is trivial.
-
-## Searching
-
-Use search when:
-
-- locating functions
-- locating types
-- locating variables
-- locating configuration
-- finding references
-- identifying where changes should be made
-
-Search before editing when you do not already know the correct location.
-
-## Reading
-
-Read the relevant context before modifying existing code.
-
-Only read the amount of code needed to understand the change.
-
-If a modification fails because the expected content does not exist, inspect the surrounding code before trying again.
-
-## Editing
-
-Prefer the smallest possible edit.
-
-Use precise editing operations instead of rewriting entire files.
-
-Preserve formatting, structure, comments, and surrounding code whenever possible.
-
-Avoid introducing unrelated changes.
-
-## Creating Files
-
-Create new files only when they provide clear value.
-
-Do not create unnecessary helper files, utility packages, or abstractions.
-
-Keep project structures simple.
-
-## Replacing Code
-
-When the target code is unique and clearly identifiable, targeted replacement is preferred.
-
-When modifying a known section of a file, prefer precise edits instead of replacing larger portions of the file.
-
-Avoid broad replacements that may unintentionally affect unrelated code.
-
-## Inserting Code
-
-Insert new code only where it naturally belongs.
-
-Keep imports, declarations, and formatting consistent with the surrounding code.
-
-## Deleting Code
-
-Delete only code that is unnecessary or explicitly requested.
-
-Avoid removing functionality outside the requested scope.
-
-## Verification
-
-After making important modifications:
-
-- verify the requested change was completed
-- ensure no unrelated code was modified
-- ensure the project structure remains consistent
-
-Do not continue editing if the requested task has already been completed.
-`
-}
diff --git a/internal/agent/prompt/workflow.go b/internal/agent/prompt/workflow.go
new file mode 100644
index 0000000..e874a14
--- /dev/null
+++ b/internal/agent/prompt/workflow.go
@@ -0,0 +1,19 @@
+package prompt
+
+// Workflow returns compact engineering + tool-usage guidance.
+// Kept short because tool JSON schemas already describe each tool.
+func Workflow() string {
+ return `## Workflow
+1. Inspect before editing (search/read only what you need).
+2. Make the smallest safe change; avoid unrelated edits.
+3. Prefer precise edits over rewriting whole files.
+4. Match existing style; don't add deps/abstractions unless needed.
+5. On tool errors: re-read context, then retry once with a corrected approach.
+6. Stop when the request is doneβdon't keep calling tools.`
+}
+
+// Developer is kept for backward compatibility; prefer Workflow().
+func Developer() string { return Workflow() }
+
+// Tools is kept for backward compatibility; prefer Workflow().
+func Tools() string { return Workflow() }
diff --git a/internal/ai/tools.go b/internal/ai/tools.go
index 40278f8..aaf1430 100644
--- a/internal/ai/tools.go
+++ b/internal/ai/tools.go
@@ -7,6 +7,8 @@ type ToolParameter struct {
Description string `json:"description"`
Required bool `json:"required"`
Enum []string `json:"enum,omitempty"`
+ // ItemsType is the element type when Type is "array" (e.g. "string", "number", "object").
+ ItemsType string `json:"items_type,omitempty"`
}
// ToolDefinition defines the schema of a tool available to the model.
diff --git a/internal/chat/banner.go b/internal/chat/banner.go
index 2b268c5..06cb99d 100644
--- a/internal/chat/banner.go
+++ b/internal/chat/banner.go
@@ -37,10 +37,13 @@ func renderBanner(m Model) string {
b.WriteString(ui.LabelStyle.Render("Mode : "))
if m.session != nil {
modeColor := ui.SuccessStyle
- if m.session.AgentMode == "plan" {
+ switch m.session.AgentMode {
+ case "plan":
modeColor = ui.BaseStyle.Foreground(ui.Amber)
- } else if m.session.AgentMode == "auto" {
+ case "auto":
modeColor = ui.BaseStyle.Foreground(ui.Purple)
+ case "chat":
+ modeColor = ui.BaseStyle.Foreground(ui.Cyan)
}
b.WriteString(modeColor.Render(strings.ToUpper(m.session.AgentMode)))
} else {
diff --git a/internal/chat/chat.go b/internal/chat/chat.go
index ebb5834..c0508ea 100644
--- a/internal/chat/chat.go
+++ b/internal/chat/chat.go
@@ -3,9 +3,9 @@ package chat
import (
"context"
"encoding/json"
- "fmt"
"time"
+ "github.com/Nithwin/WindMist/internal/agent"
"github.com/Nithwin/WindMist/internal/ai"
"github.com/Nithwin/WindMist/internal/remote"
tea "github.com/charmbracelet/bubbletea"
@@ -53,7 +53,9 @@ func (m Model) sendMessageCmd(ctx context.Context, prompt string) tea.Cmd {
})
// Auto-title the session if it's the first message (Moved here to avoid concurrent API limits on Free Tier)
- if m.session != nil && m.session.Title == "New Session" && m.store != nil {
+ if m.session != nil && m.session.Title == "New Session" && m.store != nil && !agent.IsTrivialPrompt(prompt) {
+ sessionID := m.session.ID
+ provider := m.provider
go func() {
// Small delay to ensure the main stream request is fully closed
time.Sleep(1 * time.Second)
@@ -64,12 +66,20 @@ func (m Model) sendMessageCmd(ctx context.Context, prompt string) tea.Cmd {
},
MaxTokens: 20,
}
- resp, err := m.provider.Generate(context.Background(), titleReq)
- if err == nil && resp.Text != "" {
- m.session.Title = resp.Text
- _ = m.store.UpdateSession(m.session)
+ resp, err := provider.Generate(context.Background(), titleReq)
+ if err == nil && resp.Text != "" && program != nil {
+ program.Send(sessionTitleMsg{
+ SessionID: sessionID,
+ Title: resp.Text,
+ })
}
}()
+ } else if m.session != nil && m.session.Title == "New Session" && agent.IsTrivialPrompt(prompt) && program != nil {
+ // Cheap local title β don't burn a free-tier API call on "hi".
+ program.Send(sessionTitleMsg{
+ SessionID: m.session.ID,
+ Title: "quick chat",
+ })
}
if err != nil {
@@ -94,10 +104,10 @@ func (m Model) sendMessageCmd(ctx context.Context, prompt string) tea.Cmd {
duration := time.Since(startTime)
return StreamingMsg{
- Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")",
Done: true,
Usage: res.Usage,
Duration: duration,
+ Turns: res.Turns,
}
},
)
@@ -113,9 +123,9 @@ func (m Model) getInitialMessages() []ai.Message {
return nil
}
- // Truncate history to last 20 messages to prevent massive token usage on free tiers
- if len(storeMsgs) > 20 {
- storeMsgs = storeMsgs[len(storeMsgs)-20:]
+ // Truncate history to keep free-tier prompts lean
+ if len(storeMsgs) > 12 {
+ storeMsgs = storeMsgs[len(storeMsgs)-12:]
}
var msgs []ai.Message
diff --git a/internal/chat/commands.go b/internal/chat/commands.go
index 98c394f..4fe1434 100644
--- a/internal/chat/commands.go
+++ b/internal/chat/commands.go
@@ -31,7 +31,7 @@ var Registry = []Command{
/undo Undo the last AI file edit
/redo Redo the last undone file edit
/model Change model
-/mode Change agent mode (auto/build/plan)
+/mode Change agent mode (auto/chat/build/plan)
/provider Change provider
/subagent Configure sub-agent (cheaper background model)
/theme Change UI theme
diff --git a/internal/chat/commands_ai.go b/internal/chat/commands_ai.go
index cacc395..3a29f13 100644
--- a/internal/chat/commands_ai.go
+++ b/internal/chat/commands_ai.go
@@ -109,9 +109,10 @@ func selectModelCmd(m *Model) tea.Cmd {
func selectModeCmd(m *Model) tea.Cmd {
return func() tea.Msg {
options := []selector.Option{
- {Label: "Auto", Desc: "Dynamically switches between Build and Plan based on prompt", Value: "auto"},
- {Label: "Build", Desc: "Full autonomy mode with read/write access", Value: "build"},
- {Label: "Plan", Desc: "Read-only mode for architecture and analysis", Value: "plan"},
+ {Label: "Auto", Desc: "Routes to Chat / Plan / Build (local rules first, saves tokens)", Value: "auto"},
+ {Label: "Chat", Desc: "Lightweight replies β no tools, minimal prompt (best for free tier)", Value: "chat"},
+ {Label: "Build", Desc: "Full autonomy with read/write access", Value: "build"},
+ {Label: "Plan", Desc: "Read-only analysis and architecture planning", Value: "plan"},
}
return showInlineSelectorMsg{
diff --git a/internal/chat/messages.go b/internal/chat/messages.go
index b62985c..491deae 100644
--- a/internal/chat/messages.go
+++ b/internal/chat/messages.go
@@ -20,6 +20,7 @@ type StreamingMsg struct {
Err error
Usage ai.Usage
Duration time.Duration
+ Turns int
}
// DoneMsg signals that streaming has completed.
@@ -50,6 +51,12 @@ type switchSessionSuccessMsg struct {
// createNewSessionMsg signals to spin up a new session.
type createNewSessionMsg struct{}
+// sessionTitleMsg updates a session title from a background goroutine.
+type sessionTitleMsg struct {
+ SessionID string
+ Title string
+}
+
// undoFileChangeMsg signals to undo the last file edit.
type undoFileChangeMsg struct{}
diff --git a/internal/chat/model.go b/internal/chat/model.go
index e3d6f8d..f47c6ca 100644
--- a/internal/chat/model.go
+++ b/internal/chat/model.go
@@ -140,7 +140,8 @@ func New() (Model, error) {
ragSearcher := rag.NewSearcher(ragStore, ragEmbedder)
ragIndexer := rag.NewIndexer(ragStore, ragEmbedder)
- // Rebuild vocabulary on startup if we have indexed chunks (run in background to avoid UI lag)
+ // Rebuild vocabulary on startup if we have indexed chunks, then re-embed
+ // so stored vectors stay aligned with the rebuilt vocabulary.
go func() {
if chunks, err := ragStore.GetAllChunks(); err == nil && len(chunks) > 0 {
docs := make([]string, len(chunks))
@@ -148,6 +149,13 @@ func New() (Model, error) {
docs[i] = c.Content
}
ragEmbedder.BuildVocabulary(docs)
+ for _, c := range chunks {
+ vec := ragEmbedder.Embed(c.Content)
+ if vec == nil {
+ continue
+ }
+ _ = ragStore.UpdateChunkVector(c.ID, vec)
+ }
}
}()
@@ -164,9 +172,9 @@ func New() (Model, error) {
activeModel, _ := cfg.ActiveModel()
sess := &store.Session{
- ID: fmt.Sprintf("sess_%d", time.Now().Unix()),
+ ID: fmt.Sprintf("sess_%d", time.Now().UnixNano()),
Title: "New Session",
- ProjectPath: cwd,
+ ProjectPath: filepath.Clean(cwd),
Provider: cfg.AI.Provider,
Model: activeModel,
AgentMode: "auto",
diff --git a/internal/chat/update.go b/internal/chat/update.go
index f204964..fdb8a38 100644
--- a/internal/chat/update.go
+++ b/internal/chat/update.go
@@ -73,7 +73,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// All other custom events (Session, Agent Mode, Undo/Redo, Models)
case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg,
undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg,
- switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, setAPIKeySuccessMsg, switchCancelMsg, switchErrorMsg, indexWorkspaceMsg, compactConversationMsg:
+ switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, setAPIKeySuccessMsg, switchCancelMsg, switchErrorMsg, indexWorkspaceMsg, compactConversationMsg, sessionTitleMsg:
var evtCmd tea.Cmd
m, evtCmd = m.handleEventMsg(msg)
diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go
index 667ed75..893e1b9 100644
--- a/internal/chat/update_events.go
+++ b/internal/chat/update_events.go
@@ -21,6 +21,21 @@ import (
func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
+ case sessionTitleMsg:
+ if m.session != nil && m.session.ID == msg.SessionID {
+ m.session.Title = msg.Title
+ if m.store != nil {
+ _ = m.store.UpdateSession(m.session)
+ }
+ } else if m.store != nil {
+ // Session may have switched; update the titled session directly.
+ if sess, err := m.store.GetSession(msg.SessionID); err == nil {
+ sess.Title = msg.Title
+ _ = m.store.UpdateSession(sess)
+ }
+ }
+ return m, nil
+
case ApprovalRequestMsg:
m.waitingApproval = true
m.approvalCommand = msg.Command
@@ -34,8 +49,8 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
_ = m.store.UpdateSession(m.session)
}
- // Update Agent config mode
- m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{
+ // Update Agent config mode without restarting MCP servers
+ m.agent.Reconfigure(agent.Config{
Store: m.store,
SessionID: m.session.ID,
Mode: m.session.AgentMode,
@@ -46,11 +61,15 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
case createNewSessionMsg:
+ cwd, err := os.Getwd()
+ if err != nil {
+ cwd = "."
+ }
activeModel, _ := m.cfg.ActiveModel()
sess := &store.Session{
- ID: fmt.Sprintf("sess_%d", time.Now().Unix()),
+ ID: fmt.Sprintf("sess_%d", time.Now().UnixNano()),
Title: "New Session",
- ProjectPath: ".",
+ ProjectPath: filepath.Clean(cwd),
Provider: m.cfg.AI.Provider,
Model: activeModel,
AgentMode: "auto",
@@ -60,7 +79,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
}
m.session = sess
- m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{
+ m.agent.Reconfigure(agent.Config{
Store: m.store,
SessionID: sess.ID,
Mode: sess.AgentMode,
@@ -136,7 +155,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
}
m.session = sess
- m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{
+ m.agent.Reconfigure(agent.Config{
Store: m.store,
SessionID: sess.ID,
Mode: sess.AgentMode,
@@ -171,7 +190,15 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
case switchProviderSuccessMsg:
m.cfg.SetProvider(msg.Provider)
- m.cfg.SetModel(msg.Provider, msg.Model)
+ model := msg.Model
+ if model == "" {
+ // Remote /provider may omit model β keep the provider's configured model.
+ if p, err := m.cfg.ActiveProvider(); err == nil {
+ model = p.Model
+ }
+ } else {
+ m.cfg.SetModel(msg.Provider, model)
+ }
_ = config.Save(m.cfg)
provider, err := ai.New(m.cfg)
@@ -186,6 +213,9 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch})
return <-ch
}, m.cfg)
+ if m.agent != nil {
+ m.agent.Close()
+ }
m.agent = agent.New(provider, manager, agent.Config{
Store: m.store,
SessionID: m.session.ID,
@@ -193,7 +223,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
})
}
- m.conversation.AddAssistant(fmt.Sprintf("β¨ Provider switched to **%s** (model: `%s`)", msg.Provider, msg.Model))
+ m.conversation.AddAssistant(fmt.Sprintf("β¨ Provider switched to **%s** (model: `%s`)", msg.Provider, model))
m.refreshViewport()
m.loading = false
return m, nil
@@ -214,6 +244,9 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch})
return <-ch
}, m.cfg)
+ if m.agent != nil {
+ m.agent.Close()
+ }
m.agent = agent.New(provider, manager, agent.Config{
Store: m.store,
SessionID: m.session.ID,
@@ -241,6 +274,9 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch})
return <-ch
}, m.cfg)
+ if m.agent != nil {
+ m.agent.Close()
+ }
m.agent = agent.New(m.provider, manager, agent.Config{
Store: m.store,
SessionID: m.session.ID,
@@ -275,6 +311,9 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) {
program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch})
return <-ch
}, m.cfg)
+ if m.agent != nil {
+ m.agent.Close()
+ }
m.agent = agent.New(provider, manager, agent.Config{
Store: m.store,
SessionID: m.session.ID,
diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go
index e56cbd8..1b2b63b 100644
--- a/internal/chat/update_stream.go
+++ b/internal/chat/update_stream.go
@@ -31,7 +31,7 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) {
if len(m.conversation.Messages) > 0 {
last := &m.conversation.Messages[len(m.conversation.Messages)-1]
- if last.Role == "assistant" {
+ if last.Role == "assistant" && msg.Text != "" {
last.Content += msg.Text
m.refreshViewport()
}
diff --git a/internal/config/default.go b/internal/config/default.go
index 2da94d3..949b188 100644
--- a/internal/config/default.go
+++ b/internal/config/default.go
@@ -9,7 +9,7 @@ func DefaultConfig() *Config {
Providers: map[string]ProviderConfig{
"gemini": {
- Model: "gemini-2.5-flash",
+ Model: "gemini-3.6-flash",
},
"groq": {
Model: "llama-3.3-70b-versatile",
diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go
index afadc55..877b163 100644
--- a/internal/mcp/manager.go
+++ b/internal/mcp/manager.go
@@ -67,14 +67,22 @@ func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error {
var params []ai.ToolParameter
if props, ok := t.InputSchema["properties"].(map[string]interface{}); ok {
for propName, propVal := range props {
- propMap := propVal.(map[string]interface{})
+ propMap, ok := propVal.(map[string]interface{})
+ if !ok {
+ continue
+ }
desc, _ := propMap["description"].(string)
typ, _ := propMap["type"].(string)
+ itemsType := ""
+ if items, ok := propMap["items"].(map[string]interface{}); ok {
+ itemsType, _ = items["type"].(string)
+ }
required := false
if reqArr, ok := t.InputSchema["required"].([]interface{}); ok {
for _, req := range reqArr {
- if req.(string) == propName {
+ reqName, ok := req.(string)
+ if ok && reqName == propName {
required = true
break
}
@@ -86,6 +94,7 @@ func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error {
Type: typ,
Description: desc,
Required: required,
+ ItemsType: itemsType,
})
}
}
diff --git a/internal/providers/gemini/client.go b/internal/providers/gemini/client.go
index d8f109a..5ac107b 100644
--- a/internal/providers/gemini/client.go
+++ b/internal/providers/gemini/client.go
@@ -26,10 +26,10 @@ func enforceRateLimit(model string) {
now := time.Now()
elapsed := now.Sub(lastRequestTime)
- minInterval := 4 * time.Second
+ minInterval := 6 * time.Second
if strings.Contains(model, "lite") {
- minInterval = 2 * time.Second
+ minInterval = 3 * time.Second
}
if !lastRequestTime.IsZero() && elapsed < minInterval {
@@ -51,13 +51,13 @@ type Client struct {
}
// NewClient creates a new Gemini HTTP client.
+// Timeout is left unset so long-running streams are not killed mid-response;
+// callers should cancel via context instead.
func NewClient(apiKey, model string) *Client {
return &Client{
apiKey: apiKey,
model: model,
- client: &http.Client{
- Timeout: 60 * time.Second,
- },
+ client: &http.Client{},
}
}
@@ -75,6 +75,10 @@ func (c *Client) GenerateContent(
actualModel := c.model
// Handle users who have cached -preview models in their config
+ if actualModel == "gemini-3.5-lite" {
+ actualModel = "gemini-3.5-flash-lite"
+ }
+
if actualModel == "gemini-3.5-flash-preview" {
actualModel = "gemini-3.5-flash"
}
@@ -82,10 +86,6 @@ func (c *Client) GenerateContent(
actualModel = "gemini-3.6-flash"
}
- if actualModel == "gemini-3.5-lite" {
- actualModel = "gemini-3.5-flash-lite"
- }
-
// 3.1-pro requires -preview
if actualModel == "gemini-3.1-pro" {
actualModel = "gemini-3.1-pro-preview"
diff --git a/internal/providers/gemini/provider.go b/internal/providers/gemini/provider.go
index 2e9bf27..16daf5f 100644
--- a/internal/providers/gemini/provider.go
+++ b/internal/providers/gemini/provider.go
@@ -134,13 +134,12 @@ func (p *Provider) Stream(
finalResp.Finish = translated.Finish
}
- // Capture ThoughtSignature if it arrives in a separate chunk
+ // Capture ThoughtSignature if it arrives in a separate chunk.
+ // Only the first tool call should carry the signature.
for _, part := range candidate.Content.Parts {
- if part.ThoughtSignature != "" {
- for i := range finalResp.ToolCalls {
- if strings.HasPrefix(finalResp.ToolCalls[i].ID, "call_") {
- finalResp.ToolCalls[i].ID = part.ThoughtSignature
- }
+ if part.ThoughtSignature != "" && len(finalResp.ToolCalls) > 0 {
+ if strings.HasPrefix(finalResp.ToolCalls[0].ID, "call_") || finalResp.ToolCalls[0].ID == "" {
+ finalResp.ToolCalls[0].ID = part.ThoughtSignature
}
}
}
diff --git a/internal/providers/gemini/translate.go b/internal/providers/gemini/translate.go
index 0a4ea5e..35b5059 100644
--- a/internal/providers/gemini/translate.go
+++ b/internal/providers/gemini/translate.go
@@ -37,7 +37,24 @@ func translateTools(tools []ai.ToolDefinition) []Tool {
var itemsSchema *Schema
if schemaType == "ARRAY" {
- itemsSchema = &Schema{Type: "STRING"}
+ itemType := "STRING"
+ switch strings.ToLower(p.ItemsType) {
+ case "int", "integer":
+ itemType = "INTEGER"
+ case "float", "number":
+ itemType = "NUMBER"
+ case "bool", "boolean":
+ itemType = "BOOLEAN"
+ case "object":
+ itemType = "OBJECT"
+ case "array":
+ itemType = "ARRAY"
+ case "string", "":
+ itemType = "STRING"
+ default:
+ itemType = "STRING"
+ }
+ itemsSchema = &Schema{Type: itemType}
}
properties[p.Name] = &Schema{
@@ -78,10 +95,15 @@ func translateTools(tools []ai.ToolDefinition) []Tool {
}
}
+// isThoughtSignature reports whether an ID is a Gemini thought signature
+// (as opposed to a synthetic call_* tool-call ID).
+func isThoughtSignature(id string) bool {
+ return id != "" && !strings.HasPrefix(id, "call_")
+}
+
// translateMessages converts ai.Messages into Gemini Content items.
func translateMessages(messages []ai.Message) []Content {
contents := make([]Content, 0, len(messages))
- var lastThoughtSig string
for _, msg := range messages {
switch msg.Role {
@@ -96,23 +118,26 @@ func translateMessages(messages []ai.Message) []Content {
})
case ai.RoleAssistant:
- parts := make([]Part, 0, 1+len(msg.ToolCalls)+1)
+ parts := make([]Part, 0, 1+len(msg.ToolCalls))
if msg.Content != "" {
parts = append(parts, Part{Text: msg.Content})
}
+ // Gemini 3 requires thoughtSignature on the same Part as the
+ // first functionCall β never as a standalone Part, and never
+ // on functionResponse Parts.
+ sigAttached := false
for _, call := range msg.ToolCalls {
- parts = append(parts, Part{
+ part := Part{
FunctionCall: &FunctionCall{
Name: call.Name,
Args: call.Args,
},
- })
- if !strings.HasPrefix(call.ID, "call_") {
- lastThoughtSig = call.ID
}
- }
- if lastThoughtSig != "" {
- parts = append(parts, Part{ThoughtSignature: lastThoughtSig})
+ if !sigAttached && isThoughtSignature(call.ID) {
+ part.ThoughtSignature = call.ID
+ sigAttached = true
+ }
+ parts = append(parts, part)
}
if len(parts) > 0 {
contents = append(contents, Content{
@@ -122,7 +147,7 @@ func translateMessages(messages []ai.Message) []Content {
}
case ai.RoleTool:
- parts := make([]Part, 0, len(msg.ToolResults)+1)
+ parts := make([]Part, 0, len(msg.ToolResults))
for _, res := range msg.ToolResults {
parts = append(parts, Part{
FunctionResponse: &FunctionResponse{
@@ -134,9 +159,6 @@ func translateMessages(messages []ai.Message) []Content {
},
})
}
- if lastThoughtSig != "" {
- parts = append(parts, Part{ThoughtSignature: lastThoughtSig})
- }
if len(parts) > 0 {
contents = append(contents, Content{
Role: "user",
@@ -160,7 +182,10 @@ func translateResponse(candidate Candidate, model string, resp *GenerateContentR
if part.Text != "" {
textBuilder.WriteString(part.Text)
}
- if part.ThoughtSignature != "" {
+ // Prefer signature attached to the functionCall part itself.
+ if part.FunctionCall != nil && part.ThoughtSignature != "" {
+ thoughtSig = part.ThoughtSignature
+ } else if part.ThoughtSignature != "" && thoughtSig == "" {
thoughtSig = part.ThoughtSignature
}
if part.FunctionCall != nil {
@@ -173,7 +198,8 @@ func translateResponse(candidate Candidate, model string, resp *GenerateContentR
}
for i := range toolCalls {
- if thoughtSig != "" {
+ if i == 0 && thoughtSig != "" {
+ // Only the first parallel function call carries the signature.
toolCalls[i].ID = thoughtSig
} else {
toolCalls[i].ID = fmt.Sprintf("call_%s_%d", toolCalls[i].Name, i)
diff --git a/internal/providers/gemini/translate_test.go b/internal/providers/gemini/translate_test.go
new file mode 100644
index 0000000..64a4e86
--- /dev/null
+++ b/internal/providers/gemini/translate_test.go
@@ -0,0 +1,105 @@
+package gemini
+
+import (
+ "testing"
+
+ "github.com/Nithwin/WindMist/internal/ai"
+)
+
+func TestTranslateMessages_ThoughtSignatureOnFunctionCallPart(t *testing.T) {
+ sig := "thought-sig-abc"
+ messages := []ai.Message{
+ {
+ Role: ai.RoleAssistant,
+ Content: "",
+ ToolCalls: []ai.ToolCall{
+ {ID: sig, Name: "read_file", Args: map[string]any{"path": "a.go"}},
+ {ID: "call_write_1", Name: "write_file", Args: map[string]any{"path": "b.go"}},
+ },
+ },
+ {
+ Role: ai.RoleTool,
+ ToolResults: []ai.ToolResult{
+ {Name: "read_file", Content: "ok"},
+ {Name: "write_file", Content: "ok"},
+ },
+ },
+ }
+
+ contents := translateMessages(messages)
+ if len(contents) != 2 {
+ t.Fatalf("expected 2 contents, got %d", len(contents))
+ }
+
+ modelParts := contents[0].Parts
+ if len(modelParts) != 2 {
+ t.Fatalf("expected 2 functionCall parts, got %d", len(modelParts))
+ }
+ if modelParts[0].FunctionCall == nil {
+ t.Fatal("first part missing functionCall")
+ }
+ if modelParts[0].ThoughtSignature != sig {
+ t.Fatalf("expected thought signature on first functionCall part, got %q", modelParts[0].ThoughtSignature)
+ }
+ if modelParts[1].ThoughtSignature != "" {
+ t.Fatalf("second functionCall must not carry thought signature, got %q", modelParts[1].ThoughtSignature)
+ }
+
+ toolParts := contents[1].Parts
+ for i, p := range toolParts {
+ if p.ThoughtSignature != "" {
+ t.Fatalf("functionResponse part %d must not carry thought signature", i)
+ }
+ if p.FunctionResponse == nil {
+ t.Fatalf("part %d missing functionResponse", i)
+ }
+ }
+}
+
+func TestTranslateResponse_OnlyFirstToolCallGetsSignature(t *testing.T) {
+ sig := "sig-xyz"
+ candidate := Candidate{
+ Content: Content{
+ Parts: []Part{
+ {
+ FunctionCall: &FunctionCall{Name: "a", Args: map[string]any{}},
+ ThoughtSignature: sig,
+ },
+ {
+ FunctionCall: &FunctionCall{Name: "b", Args: map[string]any{}},
+ },
+ },
+ },
+ }
+ resp := translateResponse(candidate, "gemini-test", &GenerateContentResponse{})
+ if len(resp.ToolCalls) != 2 {
+ t.Fatalf("expected 2 tool calls, got %d", len(resp.ToolCalls))
+ }
+ if resp.ToolCalls[0].ID != sig {
+ t.Fatalf("first tool call ID = %q, want %q", resp.ToolCalls[0].ID, sig)
+ }
+ if resp.ToolCalls[1].ID != "call_b_1" {
+ t.Fatalf("second tool call ID = %q, want call_b_1", resp.ToolCalls[1].ID)
+ }
+}
+
+func TestTranslateTools_ArrayItemsType(t *testing.T) {
+ tools := translateTools([]ai.ToolDefinition{
+ {
+ Name: "demo",
+ Parameters: []ai.ToolParameter{
+ {Name: "tags", Type: "array", ItemsType: "number", Required: true},
+ },
+ },
+ })
+ if len(tools) != 1 || len(tools[0].FunctionDeclarations) != 1 {
+ t.Fatal("unexpected tools shape")
+ }
+ schema := tools[0].FunctionDeclarations[0].Parameters.Properties["tags"]
+ if schema == nil || schema.Items == nil {
+ t.Fatal("missing array items schema")
+ }
+ if schema.Items.Type != "NUMBER" {
+ t.Fatalf("items type = %q, want NUMBER", schema.Items.Type)
+ }
+}
diff --git a/internal/rag/embedder.go b/internal/rag/embedder.go
index 6f87b03..ac7ebd6 100644
--- a/internal/rag/embedder.go
+++ b/internal/rag/embedder.go
@@ -63,15 +63,20 @@ func (e *TFIDFEmbedder) BuildVocabulary(documents []string) {
ranked = append(ranked, tokenFreq{tok, docFreq[tok]})
}
- // Sort by frequency (descending), but skip tokens that appear in
- // too many documents (>80%) as they're not discriminative.
+ // Sort by frequency (descending). Drop ultra-common tokens (>80% of docs)
+ // only when the corpus is large enough for that signal to be meaningful.
totalDocs := len(documents)
filtered := make([]tokenFreq, 0, len(ranked))
for _, tf := range ranked {
- ratio := float64(tf.freq) / float64(totalDocs)
- if ratio < 0.8 && tf.freq > 1 {
- filtered = append(filtered, tf)
+ if totalDocs > 5 {
+ ratio := float64(tf.freq) / float64(totalDocs)
+ if ratio >= 0.8 || tf.freq < 2 {
+ continue
+ }
+ } else if tf.freq < 1 {
+ continue
}
+ filtered = append(filtered, tf)
}
// Sort by frequency (descending) using a simple selection sort
@@ -83,7 +88,8 @@ func (e *TFIDFEmbedder) BuildVocabulary(documents []string) {
for i := 0; i < dim; i++ {
maxIdx := i
for j := i + 1; j < len(filtered); j++ {
- if filtered[j].freq > filtered[maxIdx].freq {
+ if filtered[j].freq > filtered[maxIdx].freq ||
+ (filtered[j].freq == filtered[maxIdx].freq && filtered[j].token < filtered[maxIdx].token) {
maxIdx = j
}
}
diff --git a/internal/rag/indexer.go b/internal/rag/indexer.go
index b3840ec..dafd205 100644
--- a/internal/rag/indexer.go
+++ b/internal/rag/indexer.go
@@ -34,8 +34,9 @@ func (i *Indexer) IndexProject(rootDir string) (int, error) {
if d.IsDir() {
name := d.Name()
- // Skip hidden and common ignored directories
- if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" || name == "dist" || name == "build" {
+ // Never skip the walk root itself (Name() is "." for rootDir="."),
+ // otherwise the entire tree is skipped and indexing is a no-op.
+ if path != rootDir && (strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" || name == "dist" || name == "build") {
return filepath.SkipDir
}
return nil
diff --git a/internal/rag/indexer_test.go b/internal/rag/indexer_test.go
new file mode 100644
index 0000000..745f5cb
--- /dev/null
+++ b/internal/rag/indexer_test.go
@@ -0,0 +1,78 @@
+package rag
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestIndexProject_DoesNotSkipRootDot(t *testing.T) {
+ dir := t.TempDir()
+ var b strings.Builder
+ for i := 0; i < 40; i++ {
+ fmt.Fprintf(&b, "package main\nfunc Helper%d() {\n\tprintln(\"hello world from helper %d\")\n}\n", i, i)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(b.String()), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(dir, ".git"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte("ignored"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ dbPath := filepath.Join(dir, "rag.db")
+ store, err := NewDocumentStore(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ embedder := NewTFIDFEmbedder(64)
+ indexer := NewIndexer(store, embedder)
+
+ // Reproduce the historical bug: indexing with relative "." as root.
+ origWD, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(origWD) })
+
+ count, err := indexer.IndexProject(".")
+ if err != nil {
+ t.Fatalf("IndexProject: %v", err)
+ }
+ if count == 0 {
+ t.Fatal("expected at least one indexed chunk; root '.' was likely skipped")
+ }
+}
+
+func TestBuildVocabulary_Deterministic(t *testing.T) {
+ docs := []string{
+ "alpha beta gamma delta epsilon",
+ "alpha beta gamma delta zeta",
+ "alpha beta gamma theta iota",
+ "alpha beta unique word here",
+ }
+ e1 := NewTFIDFEmbedder(8)
+ e2 := NewTFIDFEmbedder(8)
+ e1.BuildVocabulary(docs)
+ e2.BuildVocabulary(docs)
+
+ v1 := e1.Embed("alpha beta gamma")
+ v2 := e2.Embed("alpha beta gamma")
+ if len(v1) != len(v2) {
+ t.Fatalf("dimension mismatch: %d vs %d", len(v1), len(v2))
+ }
+ for i := range v1 {
+ if v1[i] != v2[i] {
+ t.Fatalf("non-deterministic embedding at index %d: %v vs %v", i, v1[i], v2[i])
+ }
+ }
+}
diff --git a/internal/rag/store.go b/internal/rag/store.go
index 969e089..7a60308 100644
--- a/internal/rag/store.go
+++ b/internal/rag/store.go
@@ -99,3 +99,10 @@ func (s *DocumentStore) GetChunksByFile(filePath string) ([]IndexedChunk, error)
err := s.db.Select(&chunks, "SELECT * FROM rag_chunks WHERE file_path = ? ORDER BY start_line ASC", filePath)
return chunks, err
}
+
+// UpdateChunkVector replaces the embedding for an existing chunk so query
+// vectors stay aligned after vocabulary rebuilds.
+func (s *DocumentStore) UpdateChunkVector(id int, vector Vector) error {
+ _, err := s.db.Exec("UPDATE rag_chunks SET vector = ? WHERE id = ?", EncodeVector(vector), id)
+ return err
+}
diff --git a/internal/store/queries.go b/internal/store/queries.go
index dd74054..3f90af5 100644
--- a/internal/store/queries.go
+++ b/internal/store/queries.go
@@ -2,6 +2,7 @@ package store
import (
"fmt"
+ "path/filepath"
"time"
)
@@ -31,7 +32,8 @@ func (s *Store) GetSession(id string) (*Session, error) {
// ListSessionsByProject gets all sessions for a specific project
func (s *Store) ListSessionsByProject(projectPath string) ([]Session, error) {
var sessions []Session
- err := s.db.Select(&sessions, "SELECT * FROM sessions WHERE project_path = ? OR project_path = '.' ORDER BY updated_at DESC", projectPath)
+ cleaned := filepath.Clean(projectPath)
+ err := s.db.Select(&sessions, "SELECT * FROM sessions WHERE project_path = ? OR project_path = '.' ORDER BY updated_at DESC", cleaned)
return sessions, err
}
diff --git a/npm/package.json b/npm/package.json
index c3af0ab..19b06d6 100644
--- a/npm/package.json
+++ b/npm/package.json
@@ -1,6 +1,6 @@
{
"name": "windmist-cli",
- "version": "2.0.0",
+ "version": "2.0.1",
"description": "AI coding agent for the terminal",
"main": "index.js",
"bin": {
diff --git a/npm/scripts/install.js b/npm/scripts/install.js
index 183e33d..c305a8d 100644
--- a/npm/scripts/install.js
+++ b/npm/scripts/install.js
@@ -1,36 +1,35 @@
const os = require('os');
const path = require('path');
const fs = require('fs');
-const https = require('https');
const { execSync } = require('child_process');
const version = require('../package.json').version;
const platform = os.platform();
const arch = os.arch();
-// Map Node.js platforms to GoReleaser OS
+// Map Node.js platforms to release archive OS names (matches GoReleaser + install.sh)
const osMap = {
- win32: 'windows',
- darwin: 'darwin',
- linux: 'linux'
+ win32: 'Windows',
+ darwin: 'macOS',
+ linux: 'Linux'
};
-// Map Node.js arch to GoReleaser Arch
+// Map Node.js arch to release archive arch names
const archMap = {
- x64: 'amd64',
+ x64: 'x86_64',
arm64: 'arm64'
};
-const goOs = osMap[platform];
-const goArch = archMap[arch];
+const releaseOs = osMap[platform];
+const releaseArch = archMap[arch];
-if (!goOs || !goArch) {
+if (!releaseOs || !releaseArch) {
console.error(`Unsupported platform/architecture: ${platform}/${arch}`);
process.exit(1);
}
const ext = platform === 'win32' ? '.zip' : '.tar.gz';
-const filename = `windmist_${version}_${goOs}_${goArch}${ext}`;
+const filename = `windmist_${version}_${releaseOs}_${releaseArch}${ext}`;
const downloadUrl = `https://github.com/Nithwin/WindMist/releases/download/v${version}/${filename}`;
const distDir = path.join(__dirname, '..', 'dist');
@@ -38,19 +37,15 @@ if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
-console.log(`Downloading WindMist v${version} for ${goOs}/${goArch}...`);
+console.log(`Downloading WindMist v${version} for ${releaseOs}/${releaseArch}...`);
console.log(`URL: ${downloadUrl}`);
-// In a real robust implementation, we would use axios + unzipper/tar here.
-// For the sake of this CLI wrapper, we output instructions or use curl if available.
-
try {
- // Simple check for curl and tar/unzip
const tmpFile = path.join(os.tmpdir(), filename);
execSync(`curl -L -o "${tmpFile}" "${downloadUrl}"`, { stdio: 'inherit' });
if (ext === '.zip') {
- execSync(`tar -xf "${tmpFile}" -C "${distDir}"`, { stdio: 'inherit' }); // Windows 10+ has tar
+ execSync(`tar -xf "${tmpFile}" -C "${distDir}"`, { stdio: 'inherit' });
} else {
execSync(`tar -xzf "${tmpFile}" -C "${distDir}"`, { stdio: 'inherit' });
}
diff --git a/scripts/install.sh b/scripts/install.sh
index 8eb5b75..8aa1cb9 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -4,7 +4,7 @@
set -euo pipefail
-REPO="Nithwin/windmist"
+REPO="Nithwin/WindMist"
INSTALL_DIR="/usr/local/bin"
echo "πͺοΈ Installing WindMist CLI..."
@@ -30,13 +30,14 @@ echo "π Fetching latest release tag from GitHub..."
LATEST_TAG="$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/' || true)"
if [ -z "$LATEST_TAG" ]; then
- # Fallback if API rate limited or offline, default to v1.0.1
+ # Fallback if API rate limited or offline
LATEST_TAG="v1.0.1"
+ echo "β οΈ Could not reach GitHub API; falling back to $LATEST_TAG"
fi
VERSION="${LATEST_TAG#v}" # strip leading 'v'
-# Handle transition between earlier 'Darwin' template in v1.0.1 and new 'macOS' template in v1.0.2+
+# Handle transition between earlier 'Darwin' template in v1.0.1 and 'macOS' in later releases
DOWNLOAD_OS="$OS_NAME"
if [ "$OS_NAME" = "macOS" ] && [ "$LATEST_TAG" = "v1.0.1" ]; then
DOWNLOAD_OS="Darwin"