first commit

This commit is contained in:
2026-03-08 15:40:34 +07:00
commit 8dc496b626
159 changed files with 27932 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
package agent
import (
"sync"
"time"
"ai-agent/internal/config"
"ai-agent/internal/ice"
"ai-agent/internal/llm"
"ai-agent/internal/mcp"
"ai-agent/internal/memory"
"ai-agent/internal/permission"
)
type Agent struct {
mu sync.RWMutex
llmClient llm.Client
registry *mcp.Registry
messages []llm.Message
skillContent string
loadedCtx string
numCtx int
memoryStore *memory.Store
iceEngine *ice.Engine
router *config.Router
modePrefix string
toolsEnabled bool
workDir string
ignoreContent string
permChecker *permission.Checker
approvalCallback func(permission.ApprovalRequest)
toolsConfig config.ToolsConfig
}
func New(llmClient llm.Client, registry *mcp.Registry, numCtx int) *Agent {
return &Agent{
llmClient: llmClient,
registry: registry,
numCtx: numCtx,
toolsEnabled: true,
}
}
func (a *Agent) SetRouter(router *config.Router) {
a.router = router
}
func (a *Agent) SetModeContext(prefix string, allowTools bool) {
a.modePrefix = prefix
a.toolsEnabled = allowTools
}
func (a *Agent) AppendLoadedContext(content string) {
if a.loadedCtx == "" {
a.loadedCtx = content
} else {
a.loadedCtx += content
}
}
func (a *Agent) Router() *config.Router {
return a.router
}
func (a *Agent) NumCtx() int {
return a.numCtx
}
func (a *Agent) SetMemoryStore(store *memory.Store) {
a.memoryStore = store
}
func (a *Agent) AddUserMessage(content string) {
a.mu.Lock()
defer a.mu.Unlock()
a.messages = append(a.messages, llm.Message{
Role: "user",
Content: content,
})
}
func (a *Agent) Messages() []llm.Message {
a.mu.RLock()
defer a.mu.RUnlock()
return a.messages
}
func (a *Agent) ClearHistory() {
a.mu.Lock()
defer a.mu.Unlock()
a.messages = nil
}
func (a *Agent) AppendMessage(msg llm.Message) {
a.mu.Lock()
defer a.mu.Unlock()
a.messages = append(a.messages, msg)
}
func (a *Agent) ReplaceMessages(msgs []llm.Message) {
a.mu.Lock()
defer a.mu.Unlock()
a.messages = msgs
}
func (a *Agent) SetSkillContent(content string) {
a.skillContent = content
}
func (a *Agent) SetLoadedContext(content string) {
a.loadedCtx = content
}
func (a *Agent) Model() string {
return a.llmClient.Model()
}
func (a *Agent) LLMClient() llm.Client {
return a.llmClient
}
func (a *Agent) ToolCount() int {
count := a.registry.ToolCount()
if a.memoryStore != nil {
count += 2
}
return count
}
func (a *Agent) ServerCount() int {
return a.registry.ServerCount()
}
func (a *Agent) ServerNames() []string {
return a.registry.ServerNames()
}
func (a *Agent) SetWorkDir(dir string) {
a.workDir = dir
}
func (a *Agent) SetIgnoreContent(content string) {
a.ignoreContent = content
}
func (a *Agent) SetPermissionChecker(checker *permission.Checker) {
a.permChecker = checker
}
func (a *Agent) SetApprovalCallback(cb func(permission.ApprovalRequest)) {
a.approvalCallback = cb
}
func (a *Agent) SetICEEngine(engine *ice.Engine) {
a.iceEngine = engine
}
func (a *Agent) ICEEngine() *ice.Engine {
return a.iceEngine
}
func (a *Agent) SetToolsConfig(cfg config.ToolsConfig) {
a.toolsConfig = cfg
}
func (a *Agent) MaxIterations() int {
if a.toolsConfig.MaxIterations > 0 {
return a.toolsConfig.MaxIterations
}
return 10
}
func (a *Agent) ToolTimeout() time.Duration {
if a.toolsConfig.Timeout != "" {
if d, err := time.ParseDuration(a.toolsConfig.Timeout); err == nil {
return d
}
}
return 30 * time.Second
}
func (a *Agent) MaxGrepResults() int {
if a.toolsConfig.MaxGrepResults > 0 {
return a.toolsConfig.MaxGrepResults
}
return 500
}
func (a *Agent) Close() {
if a.iceEngine != nil {
_ = a.iceEngine.Flush()
}
a.registry.Close()
}
+95
View File
@@ -0,0 +1,95 @@
package agent
import (
"context"
"fmt"
"strings"
"ai-agent/internal/llm"
)
const compactThreshold = 0.75
const keepMessages = 4
func (a *Agent) shouldCompact(promptTokens int) bool {
if a.numCtx <= 0 || promptTokens <= 0 {
return false
}
return float64(promptTokens) > float64(a.numCtx)*compactThreshold
}
func (a *Agent) compact(ctx context.Context, out Output) bool {
a.mu.RLock()
msgCount := len(a.messages)
a.mu.RUnlock()
if msgCount <= keepMessages+1 {
return false
}
a.mu.RLock()
splitAt := msgCount - keepMessages
older := make([]llm.Message, splitAt)
copy(older, a.messages[:splitAt])
recent := make([]llm.Message, keepMessages)
copy(recent, a.messages[splitAt:])
a.mu.RUnlock()
summary := summarizeMessages(older)
var summaryBuf strings.Builder
err := a.llmClient.ChatStream(ctx, llm.ChatOptions{
Messages: []llm.Message{
{Role: "user", Content: summary},
},
System: "You are a conversation summarizer. Produce a concise summary of the conversation so far, capturing all key facts, decisions, tool results, and user requests. Keep it under 500 words. Output only the summary, no preamble.",
}, func(chunk llm.StreamChunk) error {
if chunk.Text != "" {
summaryBuf.WriteString(chunk.Text)
}
return nil
})
if err != nil {
out.Error(fmt.Sprintf("compaction failed: %v", err))
return false
}
summaryText := summaryBuf.String()
if summaryText == "" {
return false
}
if a.iceEngine != nil {
if err := a.iceEngine.IndexSummary(ctx, summaryText); err != nil {
out.Error(fmt.Sprintf("ICE summary indexing failed: %v", err))
}
}
compacted := make([]llm.Message, 0, 1+len(recent))
compacted = append(compacted, llm.Message{
Role: "user",
Content: fmt.Sprintf("[Conversation summary: %s]", summaryText),
})
compacted = append(compacted, recent...)
a.ReplaceMessages(compacted)
out.SystemMessage(fmt.Sprintf("Context compacted: %d messages summarized, %d kept", len(older), len(recent)))
return true
}
func summarizeMessages(msgs []llm.Message) string {
var b strings.Builder
b.WriteString("Summarize this conversation:\n\n")
for _, msg := range msgs {
switch msg.Role {
case "user":
fmt.Fprintf(&b, "User: %s\n", msg.Content)
case "assistant":
if msg.Content != "" {
fmt.Fprintf(&b, "Assistant: %s\n", msg.Content)
}
for _, tc := range msg.ToolCalls {
fmt.Fprintf(&b, "Assistant called tool %s(%s)\n", tc.Name, FormatToolArgs(tc.Arguments))
}
case "tool":
content := msg.Content
if len(content) > 300 {
content = content[:297] + "..."
}
fmt.Fprintf(&b, "Tool %s result: %s\n", msg.ToolName, content)
}
}
return b.String()
}
+143
View File
@@ -0,0 +1,143 @@
package agent
import (
"strings"
"testing"
"time"
"ai-agent/internal/llm"
"ai-agent/internal/mcp"
)
type mockOutput struct {
texts []string
errors []string
sysMsgs []string
}
func (m *mockOutput) StreamText(text string) {
m.texts = append(m.texts, text)
}
func (m *mockOutput) StreamDone(_, _ int) {}
func (m *mockOutput) ToolCallStart(_ string, _ map[string]any) {}
func (m *mockOutput) ToolCallResult(_ string, _ string, _ bool, _ time.Duration) {}
func (m *mockOutput) SystemMessage(msg string) {
m.sysMsgs = append(m.sysMsgs, msg)
}
func (m *mockOutput) Error(msg string) {
m.errors = append(m.errors, msg)
}
func TestShouldCompact(t *testing.T) {
tests := []struct {
name string
numCtx int
promptTokens int
want bool
}{
{"below 75%", 1000, 749, false},
{"above 75%", 1000, 751, true},
{"exactly 75% (strict >)", 1000, 750, false},
{"numCtx zero", 0, 500, false},
{"promptTokens zero", 1000, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ag := &Agent{
numCtx: tt.numCtx,
registry: mcp.NewRegistry(),
}
got := ag.shouldCompact(tt.promptTokens)
if got != tt.want {
t.Errorf("shouldCompact(%d) with numCtx=%d = %v, want %v",
tt.promptTokens, tt.numCtx, got, tt.want)
}
})
}
}
func TestSummarizeMessages(t *testing.T) {
tests := []struct {
name string
msgs []llm.Message
contains []string
}{
{
name: "user message",
msgs: []llm.Message{
{Role: "user", Content: "hello"},
},
contains: []string{"User: hello"},
},
{
name: "assistant message",
msgs: []llm.Message{
{Role: "assistant", Content: "hi there"},
},
contains: []string{"Assistant: hi there"},
},
{
name: "tool message",
msgs: []llm.Message{
{Role: "tool", Content: "result data", ToolName: "read_file"},
},
contains: []string{"Tool read_file result: result data"},
},
{
name: "tool content truncation at 300 chars",
msgs: []llm.Message{
{Role: "tool", Content: strings.Repeat("x", 400), ToolName: "big_tool"},
},
contains: []string{"Tool big_tool result: " + strings.Repeat("x", 297) + "..."},
},
{
name: "empty slice",
msgs: []llm.Message{},
contains: []string{"Summarize this conversation:"},
},
{
name: "assistant with tool calls",
msgs: []llm.Message{
{
Role: "assistant",
ToolCalls: []llm.ToolCall{
{Name: "search", Arguments: map[string]any{"q": "test"}},
},
},
},
contains: []string{"Assistant called tool search("},
},
{
name: "mixed messages",
msgs: []llm.Message{
{Role: "user", Content: "find files"},
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{
{Name: "glob", Arguments: map[string]any{"pattern": "*.go"}},
}},
{Role: "tool", Content: "file1.go\nfile2.go", ToolName: "glob"},
{Role: "assistant", Content: "Found 2 files"},
},
contains: []string{
"User: find files",
"Assistant called tool glob(",
"Tool glob result:",
"Assistant: Found 2 files",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := summarizeMessages(tt.msgs)
for _, want := range tt.contains {
if !strings.Contains(result, want) {
t.Errorf("summarizeMessages() missing %q in:\n%s", want, result)
}
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"fmt"
"io"
"os"
"time"
)
// HeadlessOutput implements the Output interface for non-interactive / pipe mode.
// Text is written to stdout; tool calls, system messages, and errors go to stderr.
type HeadlessOutput struct {
stdout io.Writer
stderr io.Writer
}
// NewHeadlessOutput creates a HeadlessOutput that writes text to os.Stdout
// and diagnostics to os.Stderr.
func NewHeadlessOutput() *HeadlessOutput {
return &HeadlessOutput{
stdout: os.Stdout,
stderr: os.Stderr,
}
}
// newHeadlessOutput creates a HeadlessOutput with custom writers (for testing).
func newHeadlessOutput(stdout, stderr io.Writer) *HeadlessOutput {
return &HeadlessOutput{
stdout: stdout,
stderr: stderr,
}
}
// StreamText writes incremental text content to stdout.
func (h *HeadlessOutput) StreamText(text string) {
fmt.Fprint(h.stdout, text)
}
// StreamDone writes a trailing newline to ensure output is terminated.
func (h *HeadlessOutput) StreamDone(evalCount, promptTokens int) {
fmt.Fprintln(h.stdout)
}
// ToolCallStart writes a brief tool invocation notice to stderr.
func (h *HeadlessOutput) ToolCallStart(name string, args map[string]any) {
fmt.Fprintf(h.stderr, "→ %s %s\n", name, FormatToolArgs(args))
}
// ToolCallResult writes the tool result summary to stderr.
func (h *HeadlessOutput) ToolCallResult(name string, result string, isError bool, duration time.Duration) {
status := "ok"
if isError {
status = "ERROR"
}
// Truncate long results for stderr display.
display := result
if len(display) > 200 {
display = display[:197] + "..."
}
fmt.Fprintf(h.stderr, "← %s [%s %s] %s\n", name, status, duration.Round(time.Millisecond), display)
}
// SystemMessage writes a system message to stderr.
func (h *HeadlessOutput) SystemMessage(msg string) {
fmt.Fprintf(h.stderr, "[system] %s\n", msg)
}
// Error writes an error message to stderr.
func (h *HeadlessOutput) Error(msg string) {
fmt.Fprintf(h.stderr, "[error] %s\n", msg)
}
+154
View File
@@ -0,0 +1,154 @@
package agent
import (
"bytes"
"strings"
"testing"
"time"
)
// Verify HeadlessOutput satisfies the Output interface at compile time.
var _ Output = (*HeadlessOutput)(nil)
func TestHeadlessOutput_StreamText(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.StreamText("hello ")
out.StreamText("world")
if got := stdout.String(); got != "hello world" {
t.Errorf("StreamText: stdout = %q, want %q", got, "hello world")
}
if stderr.Len() != 0 {
t.Errorf("StreamText: unexpected stderr output: %q", stderr.String())
}
}
func TestHeadlessOutput_StreamDone(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.StreamText("response")
out.StreamDone(100, 50)
if got := stdout.String(); got != "response\n" {
t.Errorf("StreamDone: stdout = %q, want %q", got, "response\n")
}
if stderr.Len() != 0 {
t.Errorf("StreamDone: unexpected stderr output: %q", stderr.String())
}
}
func TestHeadlessOutput_ToolCallStart(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.ToolCallStart("read_file", map[string]any{"path": "/tmp/test.go"})
if stdout.Len() != 0 {
t.Errorf("ToolCallStart: unexpected stdout output: %q", stdout.String())
}
got := stderr.String()
if !strings.Contains(got, "read_file") {
t.Errorf("ToolCallStart: stderr = %q, missing tool name", got)
}
if !strings.HasPrefix(got, "→ ") {
t.Errorf("ToolCallStart: stderr = %q, missing arrow prefix", got)
}
}
func TestHeadlessOutput_ToolCallResult(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.ToolCallResult("read_file", "file contents here", false, 150*time.Millisecond)
if stdout.Len() != 0 {
t.Errorf("ToolCallResult: unexpected stdout output: %q", stdout.String())
}
got := stderr.String()
if !strings.Contains(got, "read_file") {
t.Errorf("ToolCallResult: stderr = %q, missing tool name", got)
}
if !strings.Contains(got, "ok") {
t.Errorf("ToolCallResult: stderr = %q, missing ok status", got)
}
if !strings.Contains(got, "file contents here") {
t.Errorf("ToolCallResult: stderr = %q, missing result content", got)
}
}
func TestHeadlessOutput_ToolCallResult_Error(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.ToolCallResult("write_file", "permission denied", true, 50*time.Millisecond)
got := stderr.String()
if !strings.Contains(got, "ERROR") {
t.Errorf("ToolCallResult error: stderr = %q, missing ERROR status", got)
}
}
func TestHeadlessOutput_ToolCallResult_LongResult(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
longResult := strings.Repeat("x", 300)
out.ToolCallResult("search", longResult, false, 100*time.Millisecond)
got := stderr.String()
if strings.Contains(got, strings.Repeat("x", 300)) {
t.Error("ToolCallResult: long result should be truncated")
}
if !strings.Contains(got, "...") {
t.Error("ToolCallResult: truncated result should end with ...")
}
}
func TestHeadlessOutput_SystemMessage(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.SystemMessage("compacting conversation")
if stdout.Len() != 0 {
t.Errorf("SystemMessage: unexpected stdout output: %q", stdout.String())
}
got := stderr.String()
if !strings.Contains(got, "[system]") {
t.Errorf("SystemMessage: stderr = %q, missing [system] prefix", got)
}
if !strings.Contains(got, "compacting conversation") {
t.Errorf("SystemMessage: stderr = %q, missing message", got)
}
}
func TestHeadlessOutput_Error(t *testing.T) {
var stdout, stderr bytes.Buffer
out := newHeadlessOutput(&stdout, &stderr)
out.Error("something went wrong")
if stdout.Len() != 0 {
t.Errorf("Error: unexpected stdout output: %q", stdout.String())
}
got := stderr.String()
if !strings.Contains(got, "[error]") {
t.Errorf("Error: stderr = %q, missing [error] prefix", got)
}
if !strings.Contains(got, "something went wrong") {
t.Errorf("Error: stderr = %q, missing message", got)
}
}
func TestNewHeadlessOutput(t *testing.T) {
out := NewHeadlessOutput()
if out == nil {
t.Fatal("NewHeadlessOutput returned nil")
}
if out.stdout == nil || out.stderr == nil {
t.Error("NewHeadlessOutput: writers should not be nil")
}
}
+277
View File
@@ -0,0 +1,277 @@
package agent
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"ai-agent/internal/llm"
permissionPkg "ai-agent/internal/permission"
)
func (a *Agent) Run(ctx context.Context, out Output) {
var tools []llm.ToolDef
if a.toolsEnabled {
tools = a.registry.Tools()
if a.memoryStore != nil {
tools = append(tools, a.memoryBuiltinToolDefs()...)
}
tools = append(tools, a.toolsBuiltinToolDefs()...)
}
var iceContext string
a.mu.RLock()
hasMessages := len(a.messages) > 0
var lastMsg llm.Message
if hasMessages {
lastMsg = a.messages[len(a.messages)-1]
}
a.mu.RUnlock()
if a.iceEngine != nil && hasMessages {
if lastMsg.Role == "user" {
if err := a.iceEngine.IndexMessage(ctx, "user", lastMsg.Content); err != nil {
out.Error(fmt.Sprintf("ICE indexing failed: %v", err))
}
if assembled, err := a.iceEngine.AssembleContext(ctx, lastMsg.Content); err == nil {
iceContext = assembled
}
}
}
system := buildSystemPromptForModel(a.modePrefix, tools, a.skillContent, a.loadedCtx, a.memoryStore, iceContext, a.workDir, a.ignoreContent, a.llmClient.Model())
const maxRetries = 2
var lastPromptTokens int
var retryCount int
maxIters := a.MaxIterations()
for i := 0; i < maxIters; i++ {
select {
case <-ctx.Done():
return
default:
}
var textBuf strings.Builder
var toolCalls []llm.ToolCall
err := a.llmClient.ChatStream(ctx, llm.ChatOptions{
Messages: a.messages,
Tools: tools,
System: system,
}, func(chunk llm.StreamChunk) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if chunk.Text != "" {
textBuf.WriteString(chunk.Text)
out.StreamText(chunk.Text)
}
if len(chunk.ToolCalls) > 0 {
toolCalls = append(toolCalls, chunk.ToolCalls...)
}
if chunk.Done {
lastPromptTokens = chunk.PromptEvalCount
out.StreamDone(chunk.EvalCount, chunk.PromptEvalCount)
}
return nil
})
if err != nil {
if ctx.Err() != nil {
return
}
if retryCount < maxRetries && isRetryableError(err) {
retryCount++
out.Error(fmt.Sprintf("LLM produced malformed output, retrying (%d/%d)...", retryCount, maxRetries))
textBuf.Reset()
toolCalls = nil
continue
}
out.Error(fmt.Sprintf("LLM error: %v", err))
out.SystemMessage(fmt.Sprintf("⚠️ Model response failed: %v\n\nYou can try:\n- Checking if Ollama is running (`ollama ps`)\n- Switching to a different model (ctrl+m)\n- Reducing context size\n\nTool results are still available above.", err))
return
}
retryCount = 0
assistantMsg := llm.Message{
Role: "assistant",
Content: textBuf.String(),
ToolCalls: toolCalls,
}
a.AppendMessage(assistantMsg)
if a.iceEngine != nil && assistantMsg.Content != "" {
if err := a.iceEngine.IndexMessage(ctx, "assistant", assistantMsg.Content); err != nil {
out.Error(fmt.Sprintf("ICE indexing failed: %v", err))
}
}
if len(toolCalls) == 0 {
a.mu.RLock()
hasEnoughMessages := len(a.messages) >= 2
var userContent string
if hasEnoughMessages {
for idx := len(a.messages) - 2; idx >= 0; idx-- {
if a.messages[idx].Role == "user" {
userContent = a.messages[idx].Content
break
}
}
}
a.mu.RUnlock()
if a.iceEngine != nil && hasEnoughMessages && userContent != "" {
a.iceEngine.DetectAutoMemory(ctx, userContent, assistantMsg.Content)
}
return
}
type pendingTool struct {
tc llm.ToolCall
isMemoryTool bool
isMCPTool bool
}
var pending []pendingTool
for _, tc := range toolCalls {
if a.memoryStore != nil && a.isMemoryTool(tc.Name) {
pending = append(pending, pendingTool{tc: tc, isMemoryTool: true})
continue
}
if a.isToolsTool(tc.Name) {
out.ToolCallStart(tc.Name, tc.Arguments)
startTime := time.Now()
result, isErr := a.handleToolsTool(tc)
duration := time.Since(startTime)
out.ToolCallResult(tc.Name, result, isErr, duration)
a.AppendMessage(llm.Message{
Role: "tool",
Content: result,
ToolName: tc.Name,
ToolCallID: tc.ID,
})
continue
}
if a.permChecker != nil {
switch a.permChecker.ToCheckResult(tc.Name) {
case permissionPkg.CheckDeny:
errMsg := "tool call blocked by permission policy"
out.ToolCallStart(tc.Name, tc.Arguments)
out.ToolCallResult(tc.Name, errMsg, true, 0)
a.AppendMessage(llm.Message{
Role: "tool",
Content: errMsg,
ToolName: tc.Name,
ToolCallID: tc.ID,
})
continue
case permissionPkg.CheckAsk:
if a.approvalCallback != nil {
allowed, always := permissionPkg.RequestApproval(tc.Name, tc.Arguments, a.approvalCallback)
if always {
a.permChecker.SetPolicy(tc.Name, permissionPkg.PolicyAllow)
}
if !allowed {
errMsg := "tool call denied by user"
out.ToolCallStart(tc.Name, tc.Arguments)
out.ToolCallResult(tc.Name, errMsg, true, 0)
a.AppendMessage(llm.Message{
Role: "tool",
Content: errMsg,
ToolName: tc.Name,
ToolCallID: tc.ID,
})
continue
}
}
}
}
pending = append(pending, pendingTool{tc: tc, isMCPTool: true})
}
if len(pending) > 0 {
var wg sync.WaitGroup
mu := sync.Mutex{}
results := make([]llm.Message, len(pending))
for i, p := range pending {
wg.Add(1)
go func(idx int, tool pendingTool) {
defer wg.Done()
tc := tool.tc
out.ToolCallStart(tc.Name, tc.Arguments)
startTime := time.Now()
var result string
var isErr bool
if tool.isMemoryTool {
result, isErr = a.handleMemoryTool(tc)
} else if tool.isMCPTool {
toolResult, err := a.registry.CallTool(ctx, tc.Name, tc.Arguments)
if err != nil {
result = fmt.Sprintf("ERROR: Tool '%s' failed: %v\nThis tool call failed but you can still complete the task with other available information.", tc.Name, err)
isErr = true
} else {
result = toolResult.Content
isErr = toolResult.IsError
}
}
duration := time.Since(startTime)
out.ToolCallResult(tc.Name, result, isErr, duration)
mu.Lock()
results[idx] = llm.Message{
Role: "tool",
Content: result,
ToolName: tc.Name,
ToolCallID: tc.ID,
}
mu.Unlock()
}(i, p)
}
wg.Wait()
for _, msg := range results {
if msg.ToolName != "" {
a.AppendMessage(msg)
}
}
}
if a.shouldCompact(lastPromptTokens) {
if a.compact(ctx, out) {
system = buildSystemPromptForModel(a.modePrefix, tools, a.skillContent, a.loadedCtx, a.memoryStore, iceContext, a.workDir, a.ignoreContent, a.llmClient.Model())
}
}
if i == maxIters-2 {
out.Error(fmt.Sprintf("approaching iteration limit (%d/%d)", i+2, maxIters))
}
}
out.Error(fmt.Sprintf("reached max iterations (%d)", maxIters))
}
func isRetryableError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "parse JSON") || strings.Contains(msg, "unexpected end of JSON")
}
func FormatToolArgs(args map[string]any) string {
if len(args) == 0 {
return ""
}
var parts []string
for key, value := range args {
var valStr string
switch v := value.(type) {
case string:
if len(v) > 47 {
valStr = `"` + v[:44] + `..."`
} else {
valStr = `"` + v + `"`
}
case int, float64, bool:
valStr = fmt.Sprintf("%v", v)
case []any:
valStr = fmt.Sprintf("[%d items]", len(v))
case map[string]any:
valStr = fmt.Sprintf("{%d fields}", len(v))
default:
valStr = fmt.Sprintf("%v", v)
}
parts = append(parts, fmt.Sprintf("%s=%s", key, valStr))
}
sort.Strings(parts)
result := strings.Join(parts, " ")
if len(result) > 60 {
return result[:57] + "..."
}
return result
}
+73
View File
@@ -0,0 +1,73 @@
package agent
import (
"strings"
"testing"
)
func TestFormatToolArgs(t *testing.T) {
tests := []struct {
name string
args map[string]any
want string
contains []string
maxLen int
}{
{
name: "empty map",
args: map[string]any{},
want: "",
},
{
name: "simple map",
args: map[string]any{"key": "value"},
contains: []string{"key=", `"value"`},
},
{
name: "long args truncated at 60",
args: map[string]any{"data": strings.Repeat("a", 300)},
maxLen: 60,
},
{
name: "multiple args",
args: map[string]any{"path": "/tmp/test", "command": "ls"},
contains: []string{"path=", "command="},
},
{
name: "numeric args",
args: map[string]any{"count": 42, "ratio": 3.14},
contains: []string{"count=42", "ratio=3.14"},
},
{
name: "array args",
args: map[string]any{"items": []any{1, 2, 3}},
contains: []string{"items=", "[3 items]"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatToolArgs(tt.args)
if tt.want != "" && got != tt.want {
t.Errorf("FormatToolArgs() = %q, want %q", got, tt.want)
}
for _, substr := range tt.contains {
if !strings.Contains(got, substr) {
t.Errorf("FormatToolArgs() = %q, missing %q", got, substr)
}
}
if tt.maxLen > 0 {
if len(got) > tt.maxLen {
t.Errorf("FormatToolArgs() len = %d, want <= %d", len(got), tt.maxLen)
}
// Check for truncation indicator (either "..." in value or at end)
if !strings.Contains(got, "...") {
t.Errorf("FormatToolArgs() should contain '...' when truncated, got %q", got)
}
}
})
}
}
+171
View File
@@ -0,0 +1,171 @@
package agent
import (
"fmt"
"strings"
"ai-agent/internal/llm"
"ai-agent/internal/memory"
)
func (a *Agent) memoryBuiltinToolDefs() []llm.ToolDef {
return memory.BuiltinToolDefs()
}
func (a *Agent) isMemoryTool(name string) bool {
return memory.IsBuiltinTool(name)
}
func (a *Agent) handleMemoryTool(tc llm.ToolCall) (string, bool) {
switch tc.Name {
case "memory_save":
return a.handleMemorySave(tc.Arguments)
case "memory_recall":
return a.handleMemoryRecall(tc.Arguments)
case "memory_delete":
return a.handleMemoryDelete(tc.Arguments)
case "memory_update":
return a.handleMemoryUpdate(tc.Arguments)
case "memory_list":
return a.handleMemoryList(tc.Arguments)
default:
return fmt.Sprintf("unknown memory tool: %s", tc.Name), true
}
}
func (a *Agent) handleMemorySave(args map[string]any) (string, bool) {
content, _ := args["content"].(string)
if content == "" {
return "error: content is required", true
}
var tags []string
if rawTags, ok := args["tags"]; ok {
switch v := rawTags.(type) {
case []any:
for _, t := range v {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
case []string:
tags = v
}
}
id, err := a.memoryStore.Save(content, tags)
if err != nil {
return fmt.Sprintf("error saving memory: %v", err), true
}
return fmt.Sprintf("Memory saved (id: %d)", id), false
}
func (a *Agent) handleMemoryRecall(args map[string]any) (string, bool) {
query, _ := args["query"].(string)
if query == "" {
return "error: query is required", true
}
memories := a.memoryStore.Recall(query, 5)
if len(memories) == 0 {
return "No matching memories found.", false
}
var b strings.Builder
fmt.Fprintf(&b, "Found %d matching memories:\n", len(memories))
for _, mem := range memories {
fmt.Fprintf(&b, "- [%d] %s", mem.ID, mem.Content)
if len(mem.Tags) > 0 {
fmt.Fprintf(&b, " (tags: %s)", strings.Join(mem.Tags, ", "))
}
b.WriteString("\n")
}
return b.String(), false
}
func (a *Agent) handleMemoryDelete(args map[string]any) (string, bool) {
idVal, ok := args["id"]
if !ok {
return "error: id is required", true
}
var id int
switch v := idVal.(type) {
case float64:
id = int(v)
case int:
id = v
default:
return "error: id must be a number", true
}
deleted, err := a.memoryStore.Delete(id)
if err != nil {
return fmt.Sprintf("error deleting memory: %v", err), true
}
if !deleted {
return fmt.Sprintf("memory with id %d not found", id), true
}
return fmt.Sprintf("Memory %d deleted", id), false
}
func (a *Agent) handleMemoryUpdate(args map[string]any) (string, bool) {
idVal, ok := args["id"]
if !ok {
return "error: id is required", true
}
var id int
switch v := idVal.(type) {
case float64:
id = int(v)
case int:
id = v
default:
return "error: id must be a number", true
}
content, _ := args["content"].(string)
var tags []string
if rawTags, ok := args["tags"]; ok {
switch v := rawTags.(type) {
case []any:
for _, t := range v {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
case []string:
tags = v
}
}
if content == "" && len(tags) == 0 {
return "error: at least one of content or tags is required", true
}
updated, err := a.memoryStore.Update(id, content, tags)
if err != nil {
return fmt.Sprintf("error updating memory: %v", err), true
}
if !updated {
return fmt.Sprintf("memory with id %d not found", id), true
}
return fmt.Sprintf("Memory %d updated", id), false
}
func (a *Agent) handleMemoryList(args map[string]any) (string, bool) {
limit := 20
if rawLimit, ok := args["limit"]; ok {
switch v := rawLimit.(type) {
case float64:
limit = int(v)
case int:
limit = v
}
}
memories := a.memoryStore.Recent(limit)
if len(memories) == 0 {
return "No memories stored.", false
}
var b strings.Builder
fmt.Fprintf(&b, "Stored memories (%d total):\n", a.memoryStore.Count())
for _, mem := range memories {
fmt.Fprintf(&b, "- [%d] %s", mem.ID, mem.Content)
if len(mem.Tags) > 0 {
fmt.Fprintf(&b, " (tags: %s)", strings.Join(mem.Tags, ", "))
}
b.WriteString("\n")
}
return b.String(), false
}
+159
View File
@@ -0,0 +1,159 @@
package agent
import (
"path/filepath"
"strings"
"testing"
"ai-agent/internal/llm"
"ai-agent/internal/mcp"
"ai-agent/internal/memory"
)
func newTestAgentWithMemory(t *testing.T) *Agent {
t.Helper()
store := memory.NewStore(filepath.Join(t.TempDir(), "test-memories.json"))
return &Agent{memoryStore: store, registry: mcp.NewRegistry()}
}
func TestHandleMemoryTool(t *testing.T) {
tests := []struct {
name string
toolCall llm.ToolCall
wantSubstr string
wantErr bool
}{
{
name: "dispatch to save",
toolCall: llm.ToolCall{
Name: "memory_save",
Arguments: map[string]any{"content": "test fact", "tags": []any{"tag1"}},
},
wantSubstr: "Memory saved (id:",
wantErr: false,
},
{
name: "dispatch to recall",
toolCall: llm.ToolCall{
Name: "memory_recall",
Arguments: map[string]any{"query": "test"},
},
wantSubstr: "No matching memories found.",
wantErr: false,
},
{
name: "unknown tool",
toolCall: llm.ToolCall{
Name: "unknown",
Arguments: map[string]any{},
},
wantSubstr: "unknown memory tool: unknown",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ag := newTestAgentWithMemory(t)
result, isErr := ag.handleMemoryTool(tt.toolCall)
if isErr != tt.wantErr {
t.Errorf("handleMemoryTool() isErr = %v, want %v", isErr, tt.wantErr)
}
if !strings.Contains(result, tt.wantSubstr) {
t.Errorf("handleMemoryTool() = %q, want substring %q", result, tt.wantSubstr)
}
})
}
}
func TestHandleMemorySave(t *testing.T) {
tests := []struct {
name string
args map[string]any
wantSubstr string
wantErr bool
}{
{
name: "valid with tags as []any",
args: map[string]any{"content": "test fact", "tags": []any{"tag1", "tag2"}},
wantSubstr: "Memory saved (id:",
wantErr: false,
},
{
name: "valid without tags",
args: map[string]any{"content": "another fact"},
wantSubstr: "Memory saved (id:",
wantErr: false,
},
{
name: "missing content",
args: map[string]any{},
wantSubstr: "error: content is required",
wantErr: true,
},
{
name: "empty content",
args: map[string]any{"content": ""},
wantSubstr: "error: content is required",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ag := newTestAgentWithMemory(t)
result, isErr := ag.handleMemorySave(tt.args)
if isErr != tt.wantErr {
t.Errorf("handleMemorySave() isErr = %v, want %v", isErr, tt.wantErr)
}
if !strings.Contains(result, tt.wantSubstr) {
t.Errorf("handleMemorySave() = %q, want substring %q", result, tt.wantSubstr)
}
})
}
}
func TestHandleMemoryRecall(t *testing.T) {
tests := []struct {
name string
setup func(ag *Agent)
args map[string]any
wantSubstr string
wantErr bool
}{
{
name: "valid recall finds saved memory",
setup: func(ag *Agent) {
_, _ = ag.memoryStore.Save("user prefers Go", []string{"language"})
},
args: map[string]any{"query": "Go"},
wantSubstr: "Found 1 matching memories:",
wantErr: false,
},
{
name: "missing query",
setup: func(ag *Agent) {},
args: map[string]any{},
wantSubstr: "error: query is required",
wantErr: true,
},
{
name: "no matches",
setup: func(ag *Agent) {},
args: map[string]any{"query": "nonexistent"},
wantSubstr: "No matching memories found.",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ag := newTestAgentWithMemory(t)
tt.setup(ag)
result, isErr := ag.handleMemoryRecall(tt.args)
if isErr != tt.wantErr {
t.Errorf("handleMemoryRecall() isErr = %v, want %v", isErr, tt.wantErr)
}
if !strings.Contains(result, tt.wantSubstr) {
t.Errorf("handleMemoryRecall() = %q, want substring %q", result, tt.wantSubstr)
}
})
}
}
+24
View File
@@ -0,0 +1,24 @@
package agent
import "time"
// Output is the interface the agent uses to stream results to the UI.
type Output interface {
// StreamText sends incremental text content.
StreamText(text string)
// StreamDone signals that the current response is complete.
StreamDone(evalCount, promptTokens int)
// ToolCallStart signals the beginning of a tool invocation.
ToolCallStart(name string, args map[string]any)
// ToolCallResult delivers the result of a tool invocation.
ToolCallResult(name string, result string, isError bool, duration time.Duration)
// SystemMessage displays a system-level message to the user.
SystemMessage(msg string)
// Error reports a non-fatal error to the user.
Error(msg string)
}
+272
View File
@@ -0,0 +1,272 @@
package agent
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ai-agent/internal/llm"
"ai-agent/internal/memory"
)
const systemTemplate = `You are a helpful personal assistant running locally on the user's machine.
You have access to tools via MCP servers. You MUST use tools to accomplish tasks — do not guess or make up answers when a tool can provide the real information.
%s
Current date: %s
%s%s
%s%s%s
## Available Tools
%s
## Guidelines
- **ALWAYS use your tools** when the user asks you to read, explore, search, or modify files. You have filesystem tools — use them.
- When the user says "read this codebase" or similar, use list/read tools starting from the working directory shown above.
- Be concise and direct in your responses.
- When a tool call fails, explain what happened and suggest alternatives.
- For multi-step tasks, explain your plan briefly before executing.
- Format responses in markdown when it improves readability.
- If you're unsure about something, say so rather than guessing.
- Never fabricate tool results — always call the actual tool.
- Do NOT claim you cannot access files or the filesystem. You have tools for that — use them.
%s`
const smallModelTemplate = `You are a local AI assistant. Use tools to read/write files and run commands.
%sDate: %s
%s%s
%s
## Tools
%s
Guidelines:
- Be concise and direct
- Use tools when needed to complete tasks
- If a tool fails, continue with available information
- Don't guess - use tools to verify
- You can complete tasks even if some tools fail
%s`
func isSmallModel(modelName string) bool {
lower := strings.ToLower(modelName)
if strings.Contains(lower, "0.8b") || strings.Contains(lower, "1b") || strings.Contains(lower, "2b") {
return true
}
return false
}
func buildSystemPrompt(modePrefix string, tools []llm.ToolDef, skillContent, loadedContext string, memStore *memory.Store, iceContext, workDir, ignoreContent string) string {
return buildSystemPromptForModel(modePrefix, tools, skillContent, loadedContext, memStore, iceContext, workDir, ignoreContent, "")
}
func buildSystemPromptForModel(modePrefix string, tools []llm.ToolDef, skillContent, loadedContext string, memStore *memory.Store, iceContext, workDir, ignoreContent string, modelName string) string {
useSmallModel := isSmallModel(modelName)
var toolList string
if len(tools) == 0 {
toolList = "No tools currently available.\n"
} else if useSmallModel {
toolList = simplifyToolsForSmallModel(tools)
} else {
var b strings.Builder
for _, t := range tools {
fmt.Fprintf(&b, "- **%s**: %s\n", t.Name, t.Description)
}
toolList = b.String()
}
envSection := buildEnvironmentSection(workDir)
var skillSection string
if skillContent != "" {
skillSection = fmt.Sprintf("\n## Active Skills\n%s\n", skillContent)
}
var ctxSection string
if loadedContext != "" {
ctxSection = fmt.Sprintf("\n## Loaded Context\n%s\n", loadedContext)
}
var memorySection string
if iceContext != "" {
memorySection = iceContext
} else if memStore != nil {
memorySection = buildMemorySection(memStore)
}
var memoryGuidelines string
if memStore != nil {
memoryGuidelines = `
## Memory Guidelines
- You have access to persistent memory via memory_save and memory_recall tools.
- Proactively save important user preferences, project facts, and key decisions.
- When the user shares personal information (name, preferences, etc.), save it.
- Use memory_recall to look up previously saved information when relevant.
- Don't save trivial or session-specific information.
`
}
var ignoreSection string
if ignoreContent != "" {
ignoreSection = fmt.Sprintf("\n## Ignored Paths\nThe following paths/patterns should be excluded from file operations:\n%s\n", ignoreContent)
}
var modePrefixSection string
if modePrefix != "" {
modePrefixSection = "\n" + modePrefix + "\n"
}
dateStr := time.Now().Format("Monday, January 2, 2006")
if useSmallModel {
return fmt.Sprintf(smallModelTemplate,
modePrefixSection,
dateStr,
envSection,
ignoreSection,
skillSection,
toolList,
memoryGuidelines,
)
}
return fmt.Sprintf(systemTemplate,
modePrefixSection,
dateStr,
envSection,
ignoreSection,
skillSection,
ctxSection,
memorySection,
toolList,
memoryGuidelines,
)
}
func simplifyToolsForSmallModel(tools []llm.ToolDef) string {
var b strings.Builder
for _, t := range tools {
desc := t.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
fmt.Fprintf(&b, "- %s: %s\n", t.Name, desc)
}
return b.String()
}
func buildEnvironmentSection(workDir string) string {
if workDir == "" {
return ""
}
var b strings.Builder
b.WriteString("\n## Environment\n")
b.WriteString(fmt.Sprintf("Working directory: %s\n", workDir))
if info := detectProjectInfo(workDir); info != "" {
b.WriteString(info)
}
if gitInfo := detectGitInfo(workDir); gitInfo != "" {
b.WriteString(gitInfo)
}
return b.String()
}
func detectProjectInfo(workDir string) string {
markers := []struct {
file string
desc string
}{
{"go.mod", "Go module"},
{"package.json", "Node.js/JavaScript"},
{"Cargo.toml", "Rust"},
{"pyproject.toml", "Python"},
{"setup.py", "Python"},
{"Makefile", ""},
{"Taskfile.yml", ""},
}
var found []string
for _, m := range markers {
if _, err := os.Stat(filepath.Join(workDir, m.file)); err == nil {
if m.desc != "" {
found = append(found, fmt.Sprintf("%s (%s)", m.file, m.desc))
} else {
found = append(found, m.file)
}
}
}
if len(found) == 0 {
return ""
}
return fmt.Sprintf("Project markers: %s\n", strings.Join(found, ", "))
}
func detectGitInfo(workDir string) string {
gitDir := filepath.Join(workDir, ".git")
if _, err := os.Stat(gitDir); err != nil {
return ""
}
var b strings.Builder
branch := runGitCommand(workDir, "rev-parse", "--abbrev-ref", "HEAD")
if branch != "" {
b.WriteString(fmt.Sprintf("Git branch: %s\n", branch))
}
status := runGitCommand(workDir, "status", "--porcelain")
if status != "" {
lines := strings.Split(strings.TrimSpace(status), "\n")
var modified, added, deleted int
for _, line := range lines {
if len(line) >= 2 {
switch line[0] {
case 'M', 'm':
modified++
case 'A':
added++
case 'D':
deleted++
}
}
}
if modified > 0 || added > 0 || deleted > 0 {
statusParts := []string{}
if modified > 0 {
statusParts = append(statusParts, fmt.Sprintf("%d modified", modified))
}
if added > 0 {
statusParts = append(statusParts, fmt.Sprintf("%d added", added))
}
if deleted > 0 {
statusParts = append(statusParts, fmt.Sprintf("%d deleted", deleted))
}
b.WriteString(fmt.Sprintf("Git status: %s\n", strings.Join(statusParts, ", ")))
}
}
recentLog := runGitCommand(workDir, "log", "-3", "--oneline")
if recentLog != "" {
b.WriteString(fmt.Sprintf("Recent commits:\n"))
for _, line := range strings.Split(strings.TrimSpace(recentLog), "\n") {
b.WriteString(fmt.Sprintf(" - %s\n", line))
}
}
if b.Len() == 0 {
return ""
}
return b.String()
}
func runGitCommand(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func buildMemorySection(store *memory.Store) string {
if store.Count() == 0 {
return ""
}
recent := store.Recent(10)
if len(recent) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n## Remembered Facts\n")
for _, mem := range recent {
b.WriteString(fmt.Sprintf("- %s", mem.Content))
if len(mem.Tags) > 0 {
b.WriteString(fmt.Sprintf(" [tags: %s]", strings.Join(mem.Tags, ", ")))
}
b.WriteString("\n")
}
return b.String()
}
+186
View File
@@ -0,0 +1,186 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"ai-agent/internal/llm"
"ai-agent/internal/memory"
)
func TestBuildSystemPrompt(t *testing.T) {
tests := []struct {
name string
tools []llm.ToolDef
skillContent string
loadedCtx string
memStore *memory.Store
iceContext string
contains []string
notContains []string
}{
{
name: "no optional sections",
contains: []string{"No tools currently available.", "Current date:"},
notContains: []string{"Active Skills", "Loaded Context", "Remembered Facts"},
},
{
name: "with tools",
tools: []llm.ToolDef{
{Name: "test_tool", Description: "does stuff"},
},
contains: []string{"test_tool", "does stuff"},
notContains: []string{"No tools currently available."},
},
{
name: "with skills",
skillContent: "skill content here",
contains: []string{"Active Skills", "skill content here"},
},
{
name: "with loaded context",
loadedCtx: "some loaded context",
contains: []string{"Loaded Context", "some loaded context"},
},
{
name: "ICE overrides memory",
iceContext: "ice assembled context",
contains: []string{"ice assembled context"},
notContains: []string{"Remembered Facts"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildSystemPrompt("", tt.tools, tt.skillContent, tt.loadedCtx, tt.memStore, tt.iceContext, "", "")
for _, want := range tt.contains {
if !strings.Contains(result, want) {
t.Errorf("buildSystemPrompt() missing %q", want)
}
}
for _, notWant := range tt.notContains {
if strings.Contains(result, notWant) {
t.Errorf("buildSystemPrompt() should not contain %q", notWant)
}
}
})
}
t.Run("with memory store entries", func(t *testing.T) {
store := memory.NewStore(filepath.Join(t.TempDir(), "test-memories.json"))
_, _ = store.Save("user prefers dark mode", []string{"preference"})
result := buildSystemPrompt("", nil, "", "", store, "", "", "")
if !strings.Contains(result, "Remembered Facts") {
t.Error("expected Remembered Facts section")
}
if !strings.Contains(result, "user prefers dark mode") {
t.Error("expected memory content in prompt")
}
})
}
func TestBuildSystemPrompt_WithWorkDir(t *testing.T) {
result := buildSystemPrompt("", nil, "", "", nil, "", "/home/user/myproject", "")
if !strings.Contains(result, "Working directory: /home/user/myproject") {
t.Error("expected working directory in prompt")
}
if !strings.Contains(result, "Environment") {
t.Error("expected Environment section header")
}
}
func TestBuildSystemPrompt_EmptyWorkDir(t *testing.T) {
result := buildSystemPrompt("", nil, "", "", nil, "", "", "")
if strings.Contains(result, "Working directory") {
t.Error("should not include working directory when empty")
}
}
func TestBuildSystemPrompt_WithIgnoreContent(t *testing.T) {
ignoreContent := "- node_modules\n- *.log\n- build/"
result := buildSystemPrompt("", nil, "", "", nil, "", "", ignoreContent)
if !strings.Contains(result, "Ignored Paths") {
t.Error("expected Ignored Paths section header")
}
if !strings.Contains(result, "node_modules") {
t.Error("expected node_modules in ignore section")
}
if !strings.Contains(result, "*.log") {
t.Error("expected *.log in ignore section")
}
}
func TestBuildSystemPrompt_EmptyIgnoreContent(t *testing.T) {
result := buildSystemPrompt("", nil, "", "", nil, "", "", "")
if strings.Contains(result, "Ignored Paths") {
t.Error("should not include Ignored Paths when content is empty")
}
}
func TestDetectProjectInfo_GoProject(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o644)
info := detectProjectInfo(dir)
if !strings.Contains(info, "go.mod") {
t.Errorf("expected go.mod in project info, got %q", info)
}
if !strings.Contains(info, "Go module") {
t.Errorf("expected 'Go module' in project info, got %q", info)
}
}
func TestDetectProjectInfo_EmptyDir(t *testing.T) {
dir := t.TempDir()
info := detectProjectInfo(dir)
if info != "" {
t.Errorf("expected empty for dir with no markers, got %q", info)
}
}
func TestBuildMemorySection(t *testing.T) {
tests := []struct {
name string
setup func(s *memory.Store)
contains []string
wantEmpty bool
}{
{
name: "empty store",
setup: func(s *memory.Store) {},
wantEmpty: true,
},
{
name: "store with tagged entry",
setup: func(s *memory.Store) {
_, _ = s.Save("likes Go", []string{"lang", "preference"})
},
contains: []string{"Remembered Facts", "likes Go", "[tags: lang, preference]"},
},
{
name: "store with untagged entry",
setup: func(s *memory.Store) {
_, _ = s.Save("project uses modules", nil)
},
contains: []string{"Remembered Facts", "project uses modules"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := memory.NewStore(filepath.Join(t.TempDir(), "mem.json"))
tt.setup(store)
result := buildMemorySection(store)
if tt.wantEmpty {
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
return
}
for _, want := range tt.contains {
if !strings.Contains(result, want) {
t.Errorf("buildMemorySection() missing %q in:\n%s", want, result)
}
}
})
}
}
+688
View File
@@ -0,0 +1,688 @@
package agent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"ai-agent/internal/llm"
"ai-agent/internal/tools"
)
const (
maxTimeout = 120 * time.Second
)
func (a *Agent) toolsBuiltinToolDefs() []llm.ToolDef {
return tools.AllToolDefs()
}
func (a *Agent) isToolsTool(name string) bool {
return tools.IsBuiltinTool(name)
}
func (a *Agent) handleToolsTool(tc llm.ToolCall) (string, bool) {
switch tc.Name {
case "grep":
return a.handleGrep(tc.Arguments)
case "read":
return a.handleRead(tc.Arguments)
case "write":
return a.handleWrite(tc.Arguments)
case "glob":
return a.handleGlob(tc.Arguments)
case "bash":
return a.handleBash(tc.Arguments)
case "ls":
return a.handleLs(tc.Arguments)
case "find":
return a.handleFind(tc.Arguments)
case "diff":
return a.handleDiff(tc.Arguments)
case "edit":
return a.handleEdit(tc.Arguments)
case "mkdir":
return a.handleMkdir(tc.Arguments)
case "remove":
return a.handleRemove(tc.Arguments)
case "copy":
return a.handleCopy(tc.Arguments)
case "move":
return a.handleMove(tc.Arguments)
case "exists":
return a.handleExists(tc.Arguments)
default:
return fmt.Sprintf("unknown tool: %s", tc.Name), true
}
}
func (a *Agent) handleGrep(args map[string]any) (string, bool) {
pattern, _ := args["pattern"].(string)
if pattern == "" {
return "error: pattern is required", true
}
path := a.getArgString(args, "path", a.workDir)
include := a.getArgString(args, "include", "")
context := a.getArgInt(args, "context", 3)
maxResults := a.MaxGrepResults()
if _, err := os.Stat(path); err != nil {
return fmt.Sprintf("error: path does not exist: %s", path), true
}
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Sprintf("error: invalid regex pattern: %v", err), true
}
var results []string
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if shouldSkipDir(info.Name()) {
return filepath.SkipDir
}
return nil
}
if include != "" {
matched, err := filepath.Match(include, info.Name())
if err != nil || !matched {
return nil
}
}
if strings.HasPrefix(info.Name(), ".") {
return nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return nil
}
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if re.MatchString(line) {
relPath, _ := filepath.Rel(path, filePath)
ctxStart := i - context
if ctxStart < 0 {
ctxStart = 0
}
ctxEnd := i + context + 1
if ctxEnd > len(lines) {
ctxEnd = len(lines)
}
results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, line))
if context > 0 && ctxStart < i {
for j := ctxStart; j < i; j++ {
if len(results) < maxResults {
results = append(results, fmt.Sprintf(" %d: %s", j+1, lines[j]))
}
}
}
if context > 0 && i+1 < ctxEnd {
for j := i + 1; j < ctxEnd; j++ {
if len(results) < maxResults {
results = append(results, fmt.Sprintf(" %d: %s", j+1, lines[j]))
}
}
}
if len(results) >= maxResults {
results = append(results, fmt.Sprintf("\n... (truncated, max %d results)", maxResults))
return filepath.SkipAll
}
}
}
return nil
})
if err != nil {
return fmt.Sprintf("error walking directory: %v", err), true
}
if len(results) == 0 {
return fmt.Sprintf("No matches found for pattern: %s", pattern), false
}
return strings.Join(results, "\n"), false
}
func (a *Agent) handleRead(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
if path == "" {
return "error: path is required", true
}
path = a.resolvePath(path)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("error reading file: %v", err), true
}
lines := strings.Split(string(data), "\n")
offset := a.getArgInt(args, "offset", 1)
limit := a.getArgInt(args, "limit", 0)
if offset > len(lines) {
return "error: offset beyond file length", true
}
if offset > 1 {
lines = lines[offset-1:]
}
if limit > 0 && len(lines) > limit {
lines = lines[:limit]
content := strings.Join(lines, "\n")
content += fmt.Sprintf("\n\n... (%d more lines)", len(lines)-limit)
return content, false
}
return strings.Join(lines, "\n"), false
}
func (a *Agent) handleWrite(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
content, _ := args["content"].(string)
if path == "" {
return "error: path is required", true
}
path = a.resolvePath(path)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Sprintf("error creating directory: %v", err), true
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Sprintf("error writing file: %v", err), true
}
return fmt.Sprintf("Written to %s (%d bytes)", path, len(content)), false
}
func (a *Agent) handleGlob(args map[string]any) (string, bool) {
pattern, _ := args["pattern"].(string)
if pattern == "" {
return "error: pattern is required", true
}
path := a.getArgString(args, "path", a.workDir)
if _, err := os.Stat(path); err != nil {
return fmt.Sprintf("error: path does not exist: %s", path), true
}
basePattern := filepath.Join(path, pattern)
matches, err := filepath.Glob(basePattern)
if err != nil {
return fmt.Sprintf("error: invalid pattern: %v", err), true
}
if len(matches) == 0 {
return fmt.Sprintf("No files match pattern: %s", pattern), false
}
relMatches := make([]string, 0, len(matches))
for _, m := range matches {
rel, err := filepath.Rel(path, m)
if err != nil {
continue
}
relMatches = append(relMatches, rel)
}
return strings.Join(relMatches, "\n"), false
}
func (a *Agent) handleBash(args map[string]any) (string, bool) {
command, _ := args["command"].(string)
if command == "" {
return "error: command is required", true
}
timeout := a.getArgInt(args, "timeout", int(a.ToolTimeout().Seconds()))
maxTimeoutSecs := int(a.ToolTimeout().Seconds())
if maxTimeoutSecs > 120 {
maxTimeoutSecs = 120
}
if timeout > maxTimeoutSecs {
timeout = maxTimeoutSecs
}
if timeout < 1 {
timeout = 1
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", command)
cmd.Dir = a.workDir
cmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += "STDERR:\n" + stderr.String()
}
if ctx.Err() == context.DeadlineExceeded {
return fmt.Sprintf("error: command timed out after %d seconds", timeout), true
}
if err != nil {
if output == "" {
return fmt.Sprintf("error: %v", err), true
}
return fmt.Sprintf("Command exited with error:\n%s", output), true
}
if output == "" {
return "Command completed successfully (no output)", false
}
return output, false
}
func (a *Agent) handleLs(args map[string]any) (string, bool) {
path := a.getArgString(args, "path", a.workDir)
path = a.resolvePath(path)
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Sprintf("error reading directory: %v", err), true
}
if len(entries) == 0 {
return "Directory is empty", false
}
var dirs []string
var files []string
for _, e := range entries {
name := e.Name()
if e.IsDir() {
dirs = append(dirs, name+"/")
} else {
files = append(files, name)
}
}
var result strings.Builder
for _, d := range dirs {
result.WriteString(d + "\n")
}
for _, f := range files {
result.WriteString(f + "\n")
}
return result.String(), false
}
func (a *Agent) handleFind(args map[string]any) (string, bool) {
name, _ := args["name"].(string)
if name == "" {
return "error: name is required", true
}
path := a.getArgString(args, "path", a.workDir)
fileType := a.getArgString(args, "type", "")
if _, err := os.Stat(path); err != nil {
return fmt.Sprintf("error: path does not exist: %s", path), true
}
re, err := regexp.Compile("^" + strings.ReplaceAll(name, "*", ".*") + "$")
if err != nil {
return fmt.Sprintf("error: invalid name pattern: %v", err), true
}
var results []string
err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if shouldSkipDir(info.Name()) && filePath != path {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
isDir := info.IsDir()
if fileType == "f" && isDir {
return nil
}
if fileType == "d" && !isDir {
return nil
}
if re.MatchString(info.Name()) {
relPath, _ := filepath.Rel(path, filePath)
if relPath != "." {
if isDir {
results = append(results, relPath+"/")
} else {
results = append(results, relPath)
}
}
}
return nil
})
if err != nil {
return fmt.Sprintf("error walking directory: %v", err), true
}
if len(results) == 0 {
return fmt.Sprintf("No files/directories found matching: %s", name), false
}
return strings.Join(results, "\n"), false
}
func (a *Agent) getArgString(args map[string]any, key, defaultValue string) string {
if v, ok := args[key].(string); ok && v != "" {
return v
}
return defaultValue
}
func (a *Agent) getArgInt(args map[string]any, key string, defaultValue int) int {
if v, ok := args[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
if n == "" {
return defaultValue
}
if i, err := strconv.Atoi(n); err == nil {
return i
}
}
}
return defaultValue
}
func (a *Agent) resolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(a.workDir, path)
}
func shouldSkipDir(name string) bool {
switch name {
case "node_modules", ".git", "__pycache__", ".venv", "venv",
"dist", "build", "target", ".cache", ".npm",
".svn", "CVS", ".hg", ".bzr":
return true
}
return strings.HasPrefix(name, ".")
}
func (a *Agent) handleDiff(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
newContent, _ := args["new_content"].(string)
if path == "" {
return "error: path is required", true
}
if newContent == "" {
return "error: new_content is required", true
}
path = a.resolvePath(path)
oldContent, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("error reading file: %v", err), true
}
oldLines := strings.Split(string(oldContent), "\n")
newLines := strings.Split(newContent, "\n")
diff := computeDiff(oldLines, newLines)
if diff == "" {
return "No changes (files are identical)", false
}
return diff, false
}
func computeDiff(oldLines, newLines []string) string {
var result strings.Builder
oldLen := len(oldLines)
newLen := len(newLines)
lcs := longestCommonSubsequence(oldLines, newLines)
oldIdx := 0
newIdx := 0
lcsIdx := 0
for oldIdx < oldLen || newIdx < newLen {
if lcsIdx < len(lcs) {
for oldIdx < oldLen && oldLines[oldIdx] != lcs[lcsIdx] {
result.WriteString(fmt.Sprintf("-%s\n", oldLines[oldIdx]))
oldIdx++
}
for newIdx < newLen && newLines[newIdx] != lcs[lcsIdx] {
result.WriteString(fmt.Sprintf("+%s\n", newLines[newIdx]))
newIdx++
}
if oldIdx < oldLen && newIdx < newLen {
result.WriteString(fmt.Sprintf(" %s\n", lcs[lcsIdx]))
oldIdx++
newIdx++
lcsIdx++
}
} else {
for oldIdx < oldLen {
result.WriteString(fmt.Sprintf("-%s\n", oldLines[oldIdx]))
oldIdx++
}
for newIdx < newLen {
result.WriteString(fmt.Sprintf("+%s\n", newLines[newIdx]))
newIdx++
}
}
}
return result.String()
}
func longestCommonSubsequence(a, b []string) []string {
m, n := len(a), len(b)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
if dp[i-1][j] > dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
}
var lcs []string
i, j := m, n
for i > 0 && j > 0 {
if a[i-1] == b[j-1] {
lcs = append([]string{a[i-1]}, lcs...)
i--
j--
} else if dp[i-1][j] > dp[i][j-1] {
i--
} else {
j--
}
}
return lcs
}
func (a *Agent) handleEdit(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
patch, _ := args["patch"].(string)
if path == "" {
return "error: path is required", true
}
if patch == "" {
return "error: patch is required", true
}
path = a.resolvePath(path)
oldContent, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("error reading file: %v", err), true
}
newContent, err := applyPatch(string(oldContent), patch)
if err != nil {
return fmt.Sprintf("error applying patch: %v", err), true
}
if err := os.WriteFile(path, []byte(newContent), 0644); err != nil {
return fmt.Sprintf("error writing file: %v", err), true
}
return fmt.Sprintf("Applied patch to %s (%d bytes)", path, len(newContent)), false
}
func (a *Agent) handleMkdir(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
if path == "" {
return "error: path is required", true
}
path = a.resolvePath(path)
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Sprintf("error creating directory: %v", err), true
}
return fmt.Sprintf("Created directory: %s", path), false
}
func (a *Agent) handleRemove(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
if path == "" {
return "error: path is required", true
}
path = a.resolvePath(path)
recursive := a.getArgBool(args, "recursive", false)
force := a.getArgBool(args, "force", false)
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
if force {
return "Removed (ignored nonexistent)", false
}
return fmt.Sprintf("error: path does not exist: %s", path), true
}
return fmt.Sprintf("error: %v", err), true
}
if info.IsDir() {
if recursive {
err = os.RemoveAll(path)
} else {
err = os.Remove(path)
}
} else {
err = os.Remove(path)
}
if err != nil {
return fmt.Sprintf("error removing: %v", err), true
}
return fmt.Sprintf("Removed: %s", path), false
}
func (a *Agent) handleCopy(args map[string]any) (string, bool) {
source, _ := args["source"].(string)
destination, _ := args["destination"].(string)
if source == "" || destination == "" {
return "error: source and destination are required", true
}
source = a.resolvePath(source)
destination = a.resolvePath(destination)
info, err := os.Stat(source)
if err != nil {
return fmt.Sprintf("error: %v", err), true
}
if info.IsDir() {
return "error: copying directories not supported (use bash with cp -r)", true
}
srcData, err := os.ReadFile(source)
if err != nil {
return fmt.Sprintf("error reading source: %v", err), true
}
dir := filepath.Dir(destination)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Sprintf("error creating destination directory: %v", err), true
}
err = os.WriteFile(destination, srcData, info.Mode())
if err != nil {
return fmt.Sprintf("error writing destination: %v", err), true
}
return fmt.Sprintf("Copied: %s -> %s", source, destination), false
}
func (a *Agent) handleMove(args map[string]any) (string, bool) {
source, _ := args["source"].(string)
destination, _ := args["destination"].(string)
if source == "" || destination == "" {
return "error: source and destination are required", true
}
source = a.resolvePath(source)
destination = a.resolvePath(destination)
dir := filepath.Dir(destination)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Sprintf("error creating destination directory: %v", err), true
}
err := os.Rename(source, destination)
if err != nil {
return fmt.Sprintf("error moving: %v", err), true
}
return fmt.Sprintf("Moved: %s -> %s", source, destination), false
}
func (a *Agent) handleExists(args map[string]any) (string, bool) {
path, _ := args["path"].(string)
if path == "" {
return "error: path is required", true
}
path = a.resolvePath(path)
info, err := os.Stat(path)
if os.IsNotExist(err) {
return fmt.Sprintf("false: %s does not exist", path), false
}
if err != nil {
return fmt.Sprintf("error: %v", err), true
}
if info.IsDir() {
return fmt.Sprintf("true: %s (directory)", path), false
}
return fmt.Sprintf("true: %s (file, %d bytes)", path, info.Size()), false
}
func (a *Agent) getArgBool(args map[string]any, key string, defaultValue bool) bool {
if v, ok := args[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return defaultValue
}
func applyPatch(content, patch string) (string, error) {
lines := strings.Split(content, "\n")
patchLines := strings.Split(patch, "\n")
var result []string
i := 0
for i < len(patchLines) {
line := patchLines[i]
if strings.HasPrefix(line, "@@") {
parts := strings.Fields(line)
if len(parts) < 4 {
return "", fmt.Errorf("invalid hunk header: %s", line)
}
oldSpec := strings.TrimPrefix(parts[1], "-")
oldParts := strings.Split(oldSpec, ",")
oldStart, _ := strconv.Atoi(oldParts[0])
newSpec := strings.TrimPrefix(parts[2], "+")
newParts := strings.Split(newSpec, ",")
newStart, _ := strconv.Atoi(newParts[0])
oldIdx := oldStart - 1
newIdx := newStart - 1
i++
for i < len(patchLines) && !strings.HasPrefix(patchLines[i], "@@") {
patchLine := patchLines[i]
if strings.HasPrefix(patchLine, "-") {
if oldIdx < len(lines) {
_ = lines[oldIdx]
oldIdx++
}
} else if strings.HasPrefix(patchLine, "+") {
content := strings.TrimPrefix(patchLine, "+")
result = append(result, content)
newIdx++
} else if strings.HasPrefix(patchLine, " ") || patchLine == "" {
if oldIdx < len(lines) {
result = append(result, lines[oldIdx])
oldIdx++
}
} else {
result = append(result, patchLine)
}
i++
}
continue
}
i++
}
if len(result) == 0 {
return content, nil
}
return strings.Join(result, "\n"), nil
}
+387
View File
@@ -0,0 +1,387 @@
package command
import (
"fmt"
"os"
"strings"
)
const maxContextFileSize = 32 * 1024 // 32KB
// RegisterBuiltins adds all built-in slash commands to the registry.
func RegisterBuiltins(r *Registry) {
r.Register(&Command{
Name: "help",
Aliases: []string{"h", "?"},
Description: "Show help overlay with shortcuts and commands",
Handler: func(_ *Context, _ []string) Result {
return Result{Action: ActionShowHelp}
},
})
r.Register(&Command{
Name: "clear",
Description: "Clear conversation history",
Handler: func(_ *Context, _ []string) Result {
return Result{
Text: "Conversation cleared.",
Action: ActionClear,
}
},
})
r.Register(&Command{
Name: "new",
Description: "Start a fresh conversation",
Handler: func(_ *Context, _ []string) Result {
return Result{
Text: "New conversation started.",
Action: ActionClear,
}
},
})
r.Register(&Command{
Name: "model",
Aliases: []string{"m"},
Description: "Show, switch, or list models",
Usage: "/model [name|list|fast|smart]",
Handler: func(ctx *Context, args []string) Result {
if len(args) == 0 {
return Result{Action: ActionShowModelPicker}
}
switch args[0] {
case "list", "ls":
var b strings.Builder
b.WriteString("Available models:\n")
for _, m := range ctx.ModelList {
marker := " "
if m == ctx.Model {
marker = "* "
}
fmt.Fprintf(&b, " %s%s\n", marker, m)
}
b.WriteString("\n* = current")
return Result{Text: b.String()}
case "fast":
if len(ctx.ModelList) > 0 {
return Result{
Text: fmt.Sprintf("Switching to fastest model: %s", ctx.ModelList[0]),
Action: ActionSwitchModel,
Data: ctx.ModelList[0],
}
}
return Result{Error: "No models available"}
case "smart":
if len(ctx.ModelList) > 0 {
smartModel := ctx.ModelList[len(ctx.ModelList)-1]
return Result{
Text: fmt.Sprintf("Switching to smartest model: %s", smartModel),
Action: ActionSwitchModel,
Data: smartModel,
}
}
return Result{Error: "No models available"}
default:
for _, m := range ctx.ModelList {
if m == args[0] {
return Result{
Text: fmt.Sprintf("Switching to model: %s", m),
Action: ActionSwitchModel,
Data: m,
}
}
}
return Result{Error: fmt.Sprintf("Unknown model: %s (use /model list to see available)", args[0])}
}
},
})
r.Register(&Command{
Name: "models",
Aliases: []string{"ml"},
Description: "Open model picker",
Handler: func(_ *Context, _ []string) Result {
return Result{Action: ActionShowModelPicker}
},
})
r.Register(&Command{
Name: "agent",
Aliases: []string{"a"},
Description: "Show or switch agent profile",
Usage: "/agent [name|list]",
Handler: func(ctx *Context, args []string) Result {
if len(args) == 0 || args[0] == "list" {
var b strings.Builder
if len(ctx.AgentList) == 0 {
b.WriteString("No agent profiles found in ~/.agents/agents/")
return Result{Text: b.String()}
}
b.WriteString("Available agent profiles:\n")
for _, a := range ctx.AgentList {
marker := " "
if a == ctx.AgentProfile {
marker = "* "
}
fmt.Fprintf(&b, " %s%s\n", marker, a)
}
b.WriteString("\n* = current")
return Result{Text: b.String()}
}
for _, a := range ctx.AgentList {
if a == args[0] {
return Result{
Text: fmt.Sprintf("Switching to agent: %s", a),
Action: ActionSwitchAgent,
Data: a,
}
}
}
return Result{Error: fmt.Sprintf("Unknown agent: %s (use /agent list to see available)", args[0])}
},
})
r.Register(&Command{
Name: "load",
Aliases: []string{"l"},
Description: "Load a markdown file as context",
Usage: "/load <path>",
Handler: func(_ *Context, args []string) Result {
if len(args) == 0 {
return Result{Error: "Usage: /load <path>"}
}
path := strings.Join(args, " ")
// Expand ~ to home directory.
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
path = home + path[1:]
}
}
info, err := os.Stat(path)
if err != nil {
return Result{Error: fmt.Sprintf("Cannot access %s: %v", path, err)}
}
if info.Size() > maxContextFileSize {
return Result{Error: fmt.Sprintf("File too large (%d bytes, max %d)", info.Size(), maxContextFileSize)}
}
data, err := os.ReadFile(path)
if err != nil {
return Result{Error: fmt.Sprintf("Cannot read %s: %v", path, err)}
}
return Result{
Text: fmt.Sprintf("Loaded context: %s (%d bytes)", path, len(data)),
Action: ActionLoadContext,
Data: path + "\x00" + string(data), // path\0content
}
},
})
r.Register(&Command{
Name: "unload",
Description: "Remove loaded context file",
Handler: func(ctx *Context, _ []string) Result {
if ctx.LoadedFile == "" {
return Result{Text: "No context file loaded."}
}
return Result{
Text: "Context unloaded.",
Action: ActionUnloadContext,
}
},
})
r.Register(&Command{
Name: "skill",
Aliases: []string{"sk"},
Description: "Manage skills (list, activate, deactivate)",
Usage: "/skill [list|activate|deactivate] [name]",
Handler: func(ctx *Context, args []string) Result {
if len(args) == 0 || args[0] == "list" {
return skillList(ctx)
}
if len(args) < 2 {
return Result{Error: "Usage: /skill [list|activate|deactivate] <name>"}
}
switch args[0] {
case "activate", "on":
return Result{
Text: fmt.Sprintf("Activated skill: %s", args[1]),
Action: ActionActivateSkill,
Data: args[1],
}
case "deactivate", "off":
return Result{
Text: fmt.Sprintf("Deactivated skill: %s", args[1]),
Action: ActionDeactivateSkill,
Data: args[1],
}
default:
return Result{Error: fmt.Sprintf("Unknown skill action: %s (use list, activate, or deactivate)", args[0])}
}
},
})
r.Register(&Command{
Name: "servers",
Description: "List connected MCP servers",
Handler: func(ctx *Context, _ []string) Result {
if len(ctx.ServerNames) == 0 {
return Result{Text: "No MCP servers connected."}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Connected servers (%d):\n", len(ctx.ServerNames)))
for _, name := range ctx.ServerNames {
fmt.Fprintf(&b, " - %s\n", name)
}
b.WriteString(fmt.Sprintf("\nTotal tools: %d", ctx.ToolCount))
return Result{Text: b.String()}
},
})
r.Register(&Command{
Name: "ice",
Description: "Show Infinite Context Engine status",
Handler: func(ctx *Context, _ []string) Result {
if !ctx.ICEEnabled {
return Result{Text: "ICE is not enabled. Add `ice: {enabled: true}` to your config.yaml"}
}
var b strings.Builder
b.WriteString("Infinite Context Engine (ICE)\n")
fmt.Fprintf(&b, " Status: enabled\n")
fmt.Fprintf(&b, " Conversations: %d stored\n", ctx.ICEConversations)
fmt.Fprintf(&b, " Session ID: %s\n", ctx.ICESessionID)
fmt.Fprintf(&b, " Embed model: nomic-embed-text\n")
return Result{Text: b.String()}
},
})
r.Register(&Command{
Name: "sessions",
Aliases: []string{"ss"},
Description: "Browse and restore saved sessions",
Handler: func(_ *Context, _ []string) Result {
return Result{Action: ActionShowSessions}
},
})
r.Register(&Command{
Name: "changes",
Description: "List files modified by the agent this session",
Handler: func(ctx *Context, _ []string) Result {
if len(ctx.FileChanges) == 0 {
return Result{Text: "No files modified this session."}
}
var b strings.Builder
fmt.Fprintf(&b, "Files modified (%d):\n", len(ctx.FileChanges))
for path, count := range ctx.FileChanges {
if count > 1 {
fmt.Fprintf(&b, " ✎ %s (%dx)\n", path, count)
} else {
fmt.Fprintf(&b, " ✎ %s\n", path)
}
}
return Result{Text: b.String()}
},
})
r.Register(&Command{
Name: "commit",
Aliases: []string{"ci"},
Description: "Generate commit message from staged changes and commit",
Handler: func(_ *Context, args []string) Result {
return Result{Action: ActionCommit, Data: strings.Join(args, " ")}
},
})
r.Register(&Command{
Name: "stats",
Description: "Show token usage statistics for this session",
Handler: func(ctx *Context, _ []string) Result {
if ctx.SessionTurnCount == 0 {
return Result{Text: "No token usage recorded yet."}
}
var b strings.Builder
b.WriteString("Session Token Stats\n")
fmt.Fprintf(&b, " Model: %s\n", ctx.CurrentModel)
fmt.Fprintf(&b, " Turns: %d\n", ctx.SessionTurnCount)
fmt.Fprintf(&b, " Output tokens: %d\n", ctx.SessionEvalTotal)
fmt.Fprintf(&b, " Prompt tokens: %d (last turn)\n", ctx.SessionPromptTotal)
if ctx.NumCtx > 0 {
fmt.Fprintf(&b, " Context window: %d\n", ctx.NumCtx)
pct := ctx.SessionPromptTotal * 100 / ctx.NumCtx
fmt.Fprintf(&b, " Context used: %d%%\n", pct)
}
avgOut := ctx.SessionEvalTotal / ctx.SessionTurnCount
fmt.Fprintf(&b, " Avg out/turn: %d\n", avgOut)
return Result{Text: b.String()}
},
})
r.Register(&Command{
Name: "export",
Description: "Export conversation to a markdown file",
Usage: "/export [path]",
Handler: func(_ *Context, args []string) Result {
if len(args) < 1 || args[0] == "" {
return Result{Error: "usage: /export <filepath>"}
}
return Result{
Text: fmt.Sprintf("Exporting conversation to: %s", args[0]),
Action: ActionExport,
Data: args[0],
}
},
})
r.Register(&Command{
Name: "import",
Description: "Import conversation from a markdown file",
Usage: "/import [path]",
Handler: func(_ *Context, args []string) Result {
if len(args) < 1 || args[0] == "" {
return Result{Error: "usage: /import <filepath>"}
}
return Result{
Text: fmt.Sprintf("Importing conversation from: %s", args[0]),
Action: ActionImport,
Data: args[0],
}
},
})
r.Register(&Command{
Name: "exit",
Aliases: []string{"quit", "q"},
Description: "Quit ai-agent",
Handler: func(_ *Context, _ []string) Result {
return Result{Action: ActionQuit}
},
})
}
func skillList(ctx *Context) Result {
if len(ctx.Skills) == 0 {
return Result{Text: "No skills found. Add .md files to ~/.config/ai-agent/skills/"}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Skills (%d):\n", len(ctx.Skills)))
for _, s := range ctx.Skills {
status := " "
if s.Active {
status = "* "
}
fmt.Fprintf(&b, " %s%s — %s\n", status, s.Name, s.Description)
}
b.WriteString("\n* = active")
return Result{Text: b.String()}
}
+380
View File
@@ -0,0 +1,380 @@
package command
import (
"os"
"path/filepath"
"strings"
"testing"
)
func newTestRegistry() *Registry {
r := NewRegistry()
RegisterBuiltins(r)
return r
}
func TestBuiltin_Help(t *testing.T) {
r := newTestRegistry()
result := r.Execute(&Context{}, "help", nil)
if result.Action != ActionShowHelp {
t.Errorf("help action = %d, want %d (ActionShowHelp)", result.Action, ActionShowHelp)
}
}
func TestBuiltin_Clear(t *testing.T) {
r := newTestRegistry()
result := r.Execute(&Context{}, "clear", nil)
if result.Action != ActionClear {
t.Errorf("clear action = %d, want %d (ActionClear)", result.Action, ActionClear)
}
if result.Text == "" {
t.Error("clear should have text")
}
}
func TestBuiltin_New(t *testing.T) {
r := newTestRegistry()
result := r.Execute(&Context{}, "new", nil)
if result.Action != ActionClear {
t.Errorf("new action = %d, want %d (ActionClear)", result.Action, ActionClear)
}
if result.Text == "" {
t.Error("new should have text")
}
}
func TestBuiltin_Model(t *testing.T) {
r := newTestRegistry()
ctx := &Context{
Model: "qwen3.5:0.8b",
ModelList: []string{"qwen3.5:0.8b", "qwen3.5:2b", "qwen3.5:4b", "qwen3.5:9b"},
}
tests := []struct {
name string
args []string
wantAction Action
wantData string
wantErr bool
checkText string
}{
{
name: "no args opens model picker",
args: nil,
wantAction: ActionShowModelPicker,
},
{
name: "list shows models",
args: []string{"list"},
checkText: "Available models",
},
{
name: "fast switches to first",
args: []string{"fast"},
wantAction: ActionSwitchModel,
wantData: "qwen3.5:0.8b",
},
{
name: "smart switches to last",
args: []string{"smart"},
wantAction: ActionSwitchModel,
wantData: "qwen3.5:9b",
},
{
name: "valid name switches",
args: []string{"qwen3.5:2b"},
wantAction: ActionSwitchModel,
wantData: "qwen3.5:2b",
},
{
name: "invalid name errors",
args: []string{"nonexistent"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := r.Execute(ctx, "model", tt.args)
if tt.wantErr {
if result.Error == "" {
t.Error("expected error")
}
return
}
if result.Error != "" {
t.Errorf("unexpected error: %s", result.Error)
return
}
if tt.wantAction != ActionNone && result.Action != tt.wantAction {
t.Errorf("action = %d, want %d", result.Action, tt.wantAction)
}
if tt.wantData != "" && result.Data != tt.wantData {
t.Errorf("data = %q, want %q", result.Data, tt.wantData)
}
if tt.checkText != "" && !strings.Contains(result.Text, tt.checkText) {
t.Errorf("text %q does not contain %q", result.Text, tt.checkText)
}
})
}
}
func TestBuiltin_Models(t *testing.T) {
r := newTestRegistry()
ctx := &Context{
Model: "qwen3.5:0.8b",
ModelList: []string{"qwen3.5:0.8b", "qwen3.5:2b"},
}
result := r.Execute(ctx, "models", nil)
if result.Action != ActionShowModelPicker {
t.Errorf("expected ActionShowModelPicker, got %d", result.Action)
}
}
func TestBuiltin_Agent(t *testing.T) {
r := newTestRegistry()
t.Run("no args lists agents", func(t *testing.T) {
ctx := &Context{AgentList: []string{"coder", "reviewer"}, AgentProfile: "coder"}
result := r.Execute(ctx, "agent", nil)
if !strings.Contains(result.Text, "Available agent profiles") {
t.Errorf("expected agent list, got %q", result.Text)
}
})
t.Run("list subcommand", func(t *testing.T) {
ctx := &Context{AgentList: []string{"coder"}}
result := r.Execute(ctx, "agent", []string{"list"})
if !strings.Contains(result.Text, "Available agent profiles") {
t.Errorf("expected agent list, got %q", result.Text)
}
})
t.Run("valid switch", func(t *testing.T) {
ctx := &Context{AgentList: []string{"coder", "reviewer"}}
result := r.Execute(ctx, "agent", []string{"reviewer"})
if result.Action != ActionSwitchAgent {
t.Errorf("action = %d, want %d", result.Action, ActionSwitchAgent)
}
if result.Data != "reviewer" {
t.Errorf("data = %q, want %q", result.Data, "reviewer")
}
})
t.Run("invalid errors", func(t *testing.T) {
ctx := &Context{AgentList: []string{"coder"}}
result := r.Execute(ctx, "agent", []string{"unknown"})
if result.Error == "" {
t.Error("expected error for unknown agent")
}
})
t.Run("no agents", func(t *testing.T) {
ctx := &Context{AgentList: []string{}}
result := r.Execute(ctx, "agent", nil)
if !strings.Contains(result.Text, "No agent profiles") {
t.Errorf("expected no agents message, got %q", result.Text)
}
})
}
func TestBuiltin_Load(t *testing.T) {
r := newTestRegistry()
t.Run("no args errors", func(t *testing.T) {
result := r.Execute(&Context{}, "load", nil)
if result.Error == "" {
t.Error("expected error for no args")
}
})
t.Run("valid file loads", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.md")
if err := os.WriteFile(path, []byte("# Hello"), 0644); err != nil {
t.Fatal(err)
}
result := r.Execute(&Context{}, "load", []string{path})
if result.Error != "" {
t.Errorf("unexpected error: %s", result.Error)
}
if result.Action != ActionLoadContext {
t.Errorf("action = %d, want %d", result.Action, ActionLoadContext)
}
// Data should be path\0content
parts := strings.SplitN(result.Data, "\x00", 2)
if len(parts) != 2 {
t.Fatalf("expected path\\0content, got %q", result.Data)
}
if parts[0] != path {
t.Errorf("data path = %q, want %q", parts[0], path)
}
if parts[1] != "# Hello" {
t.Errorf("data content = %q, want %q", parts[1], "# Hello")
}
})
t.Run("too large errors", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "big.md")
data := make([]byte, 33*1024) // > 32KB
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
result := r.Execute(&Context{}, "load", []string{path})
if result.Error == "" {
t.Error("expected error for oversized file")
}
if !strings.Contains(result.Error, "too large") {
t.Errorf("error = %q, want containing 'too large'", result.Error)
}
})
t.Run("nonexistent errors", func(t *testing.T) {
result := r.Execute(&Context{}, "load", []string{"/nonexistent/file.md"})
if result.Error == "" {
t.Error("expected error for nonexistent file")
}
})
}
func TestBuiltin_Unload(t *testing.T) {
r := newTestRegistry()
t.Run("no loaded file", func(t *testing.T) {
result := r.Execute(&Context{LoadedFile: ""}, "unload", nil)
if !strings.Contains(result.Text, "No context") {
t.Errorf("expected 'No context' message, got %q", result.Text)
}
})
t.Run("loaded file unloads", func(t *testing.T) {
result := r.Execute(&Context{LoadedFile: "something.md"}, "unload", nil)
if result.Action != ActionUnloadContext {
t.Errorf("action = %d, want %d", result.Action, ActionUnloadContext)
}
})
}
func TestBuiltin_Skill(t *testing.T) {
r := newTestRegistry()
ctx := &Context{
Skills: []SkillInfo{
{Name: "coder", Description: "Code generation", Active: true},
{Name: "reviewer", Description: "Code review", Active: false},
},
}
t.Run("no args lists skills", func(t *testing.T) {
result := r.Execute(ctx, "skill", nil)
if !strings.Contains(result.Text, "Skills") {
t.Errorf("expected skills list, got %q", result.Text)
}
})
t.Run("list subcommand", func(t *testing.T) {
result := r.Execute(ctx, "skill", []string{"list"})
if !strings.Contains(result.Text, "Skills") {
t.Errorf("expected skills list, got %q", result.Text)
}
})
t.Run("activate", func(t *testing.T) {
result := r.Execute(ctx, "skill", []string{"activate", "reviewer"})
if result.Action != ActionActivateSkill {
t.Errorf("action = %d, want %d", result.Action, ActionActivateSkill)
}
if result.Data != "reviewer" {
t.Errorf("data = %q, want %q", result.Data, "reviewer")
}
})
t.Run("deactivate", func(t *testing.T) {
result := r.Execute(ctx, "skill", []string{"deactivate", "coder"})
if result.Action != ActionDeactivateSkill {
t.Errorf("action = %d, want %d", result.Action, ActionDeactivateSkill)
}
if result.Data != "coder" {
t.Errorf("data = %q, want %q", result.Data, "coder")
}
})
t.Run("unknown action errors", func(t *testing.T) {
result := r.Execute(ctx, "skill", []string{"unknown", "foo"})
if result.Error == "" {
t.Error("expected error for unknown skill action")
}
})
t.Run("missing name errors", func(t *testing.T) {
result := r.Execute(ctx, "skill", []string{"activate"})
if result.Error == "" {
t.Error("expected error for missing skill name")
}
})
}
func TestBuiltin_Servers(t *testing.T) {
r := newTestRegistry()
t.Run("no servers", func(t *testing.T) {
result := r.Execute(&Context{ServerNames: nil}, "servers", nil)
if !strings.Contains(result.Text, "No MCP servers") {
t.Errorf("expected no servers message, got %q", result.Text)
}
})
t.Run("with servers", func(t *testing.T) {
ctx := &Context{
ServerNames: []string{"server-a", "server-b"},
ToolCount: 10,
}
result := r.Execute(ctx, "servers", nil)
if !strings.Contains(result.Text, "server-a") {
t.Errorf("expected server-a in output, got %q", result.Text)
}
if !strings.Contains(result.Text, "server-b") {
t.Errorf("expected server-b in output, got %q", result.Text)
}
if !strings.Contains(result.Text, "10") {
t.Errorf("expected tool count in output, got %q", result.Text)
}
})
}
func TestBuiltin_ICE(t *testing.T) {
r := newTestRegistry()
t.Run("disabled", func(t *testing.T) {
result := r.Execute(&Context{ICEEnabled: false}, "ice", nil)
if !strings.Contains(result.Text, "not enabled") {
t.Errorf("expected disabled message, got %q", result.Text)
}
})
t.Run("enabled shows status", func(t *testing.T) {
ctx := &Context{
ICEEnabled: true,
ICEConversations: 5,
ICESessionID: "abc-123",
}
result := r.Execute(ctx, "ice", nil)
if !strings.Contains(result.Text, "enabled") {
t.Errorf("expected enabled status, got %q", result.Text)
}
if !strings.Contains(result.Text, "5") {
t.Errorf("expected conversation count, got %q", result.Text)
}
if !strings.Contains(result.Text, "abc-123") {
t.Errorf("expected session ID, got %q", result.Text)
}
})
}
func TestBuiltin_Exit(t *testing.T) {
r := newTestRegistry()
result := r.Execute(&Context{}, "exit", nil)
if result.Action != ActionQuit {
t.Errorf("exit action = %d, want %d (ActionQuit)", result.Action, ActionQuit)
}
}
+116
View File
@@ -0,0 +1,116 @@
package command
import (
"os"
"path/filepath"
"strings"
)
// CustomCommand represents a user-defined command loaded from a markdown file.
type CustomCommand struct {
Name string
Description string
Template string // prompt template with {{input}} placeholder
}
// LoadCustomCommands reads .md files from the commands directory and returns
// parsed custom commands. Each file should have YAML-like frontmatter:
//
// ---
// name: review
// description: Code review prompt
// ---
// Review this code: {{input}}
func LoadCustomCommands(dir string) []CustomCommand {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var cmds []CustomCommand
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
if cmd, ok := parseCustomCommand(string(data)); ok {
cmds = append(cmds, cmd)
}
}
return cmds
}
// parseCustomCommand parses a markdown file with YAML frontmatter.
func parseCustomCommand(content string) (CustomCommand, bool) {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, "---") {
return CustomCommand{}, false
}
// Find end of frontmatter.
rest := content[3:]
idx := strings.Index(rest, "---")
if idx < 0 {
return CustomCommand{}, false
}
frontmatter := rest[:idx]
body := strings.TrimSpace(rest[idx+3:])
cmd := CustomCommand{Template: body}
// Parse simple key: value pairs from frontmatter.
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
switch key {
case "name":
cmd.Name = val
case "description":
cmd.Description = val
}
}
if cmd.Name == "" || cmd.Template == "" {
return CustomCommand{}, false
}
return cmd, true
}
// RegisterCustomCommands loads and registers custom commands from the given directory.
func RegisterCustomCommands(r *Registry, dir string) {
cmds := LoadCustomCommands(dir)
for _, cc := range cmds {
// Capture for closure.
tmpl := cc.Template
desc := cc.Description
if desc == "" {
desc = "Custom command"
}
r.Register(&Command{
Name: cc.Name,
Description: desc,
Handler: func(_ *Context, args []string) Result {
input := strings.Join(args, " ")
prompt := strings.ReplaceAll(tmpl, "{{input}}", input)
return Result{
Action: ActionSendPrompt,
Data: prompt,
}
},
})
}
}
+148
View File
@@ -0,0 +1,148 @@
package command
import (
"os"
"path/filepath"
"testing"
)
func TestParseCustomCommand(t *testing.T) {
tests := []struct {
name string
content string
wantOK bool
wantCmd CustomCommand
}{
{
name: "valid command",
content: `---
name: review
description: Code review prompt
---
Review this code: {{input}}`,
wantOK: true,
wantCmd: CustomCommand{
Name: "review",
Description: "Code review prompt",
Template: "Review this code: {{input}}",
},
},
{
name: "no description",
content: `---
name: explain
---
Explain this: {{input}}`,
wantOK: true,
wantCmd: CustomCommand{
Name: "explain",
Template: "Explain this: {{input}}",
},
},
{
name: "no frontmatter",
content: "just some text",
wantOK: false,
},
{
name: "no name",
content: `---
description: something
---
body`,
wantOK: false,
},
{
name: "no body",
content: `---
name: empty
---`,
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, ok := parseCustomCommand(tt.content)
if ok != tt.wantOK {
t.Fatalf("parseCustomCommand() ok = %v, want %v", ok, tt.wantOK)
}
if !ok {
return
}
if cmd.Name != tt.wantCmd.Name {
t.Errorf("Name = %q, want %q", cmd.Name, tt.wantCmd.Name)
}
if cmd.Description != tt.wantCmd.Description {
t.Errorf("Description = %q, want %q", cmd.Description, tt.wantCmd.Description)
}
if cmd.Template != tt.wantCmd.Template {
t.Errorf("Template = %q, want %q", cmd.Template, tt.wantCmd.Template)
}
})
}
}
func TestLoadCustomCommands(t *testing.T) {
dir := t.TempDir()
// Write a valid command file.
err := os.WriteFile(filepath.Join(dir, "review.md"), []byte(`---
name: review
description: Review code
---
Review: {{input}}`), 0o644)
if err != nil {
t.Fatal(err)
}
// Write an invalid file (no frontmatter).
err = os.WriteFile(filepath.Join(dir, "invalid.md"), []byte("just text"), 0o644)
if err != nil {
t.Fatal(err)
}
// Write a non-md file (should be ignored).
err = os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a command"), 0o644)
if err != nil {
t.Fatal(err)
}
cmds := LoadCustomCommands(dir)
if len(cmds) != 1 {
t.Fatalf("LoadCustomCommands() returned %d commands, want 1", len(cmds))
}
if cmds[0].Name != "review" {
t.Errorf("Name = %q, want %q", cmds[0].Name, "review")
}
}
func TestLoadCustomCommands_MissingDir(t *testing.T) {
cmds := LoadCustomCommands("/nonexistent/path")
if len(cmds) != 0 {
t.Errorf("expected empty result for missing dir, got %d", len(cmds))
}
}
func TestRegisterCustomCommands(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "test.md"), []byte(`---
name: testcmd
description: A test command
---
Do this: {{input}}`), 0o644)
if err != nil {
t.Fatal(err)
}
reg := NewRegistry()
RegisterCustomCommands(reg, dir)
result := reg.Execute(&Context{}, "testcmd", []string{"hello", "world"})
if result.Action != ActionSendPrompt {
t.Errorf("Action = %v, want ActionSendPrompt", result.Action)
}
if result.Data != "Do this: hello world" {
t.Errorf("Data = %q, want %q", result.Data, "Do this: hello world")
}
}
+129
View File
@@ -0,0 +1,129 @@
package command
import (
"fmt"
"sort"
"strings"
)
// Command represents a slash command.
type Command struct {
Name string
Aliases []string
Description string
Usage string
Handler func(ctx *Context, args []string) Result
}
// Context provides commands with read access to application state.
type Context struct {
Model string
ModelList []string
AgentProfile string
AgentList []string
ToolCount int
ServerCount int
ServerNames []string
Skills []SkillInfo
LoadedFile string
ICEEnabled bool
ICEConversations int
ICESessionID string
// Token stats
SessionEvalTotal int
SessionPromptTotal int
SessionTurnCount int
NumCtx int
CurrentModel string
// File changes
FileChanges map[string]int // path → modification count
}
// SkillInfo is a read-only view of a skill for command display.
type SkillInfo struct {
Name string
Description string
Active bool
}
// Result is returned by command handlers to describe what to do.
type Result struct {
Text string // Display text (shown as system message)
Action Action // Side effect for the TUI to execute
Data string // Optional payload (e.g. file path, model name)
Error string // Error text (takes priority over Text)
}
// Action describes a side effect the TUI should perform.
type Action int
const (
ActionNone Action = iota
ActionShowHelp // Show help overlay
ActionClear // Clear conversation history
ActionQuit // Exit the application
ActionLoadContext // Load markdown context (Data = path)
ActionUnloadContext // Remove loaded context
ActionActivateSkill // Activate skill (Data = name)
ActionDeactivateSkill // Deactivate skill (Data = name)
ActionSwitchModel // Switch model (Data = model name)
ActionSwitchAgent // Switch agent profile (Data = agent name)
ActionShowSessions // Open sessions picker
ActionShowModelPicker // Open model picker overlay
ActionCommit // Generate commit message and commit
ActionSendPrompt // Send Data as a message to the agent
ActionExport // Export conversation (Data = path)
ActionImport // Import conversation (Data = path)
)
// Registry holds all registered slash commands.
type Registry struct {
commands map[string]*Command // name/alias → command
all []*Command // ordered list
}
// NewRegistry creates an empty command registry.
func NewRegistry() *Registry {
return &Registry{
commands: make(map[string]*Command),
}
}
// Register adds a command to the registry.
func (r *Registry) Register(cmd *Command) {
r.all = append(r.all, cmd)
r.commands[cmd.Name] = cmd
for _, alias := range cmd.Aliases {
r.commands[alias] = cmd
}
}
// Execute dispatches a slash command by name and returns the result.
func (r *Registry) Execute(ctx *Context, name string, args []string) Result {
cmd, ok := r.commands[name]
if !ok {
return Result{Error: fmt.Sprintf("unknown command: /%s — type /help for available commands", name)}
}
return cmd.Handler(ctx, args)
}
// All returns all registered commands in registration order.
func (r *Registry) All() []*Command {
return r.all
}
// Match returns commands whose name starts with the given prefix.
func (r *Registry) Match(prefix string) []*Command {
var matches []*Command
seen := make(map[string]bool)
for _, cmd := range r.all {
if strings.HasPrefix(cmd.Name, prefix) && !seen[cmd.Name] {
matches = append(matches, cmd)
seen[cmd.Name] = true
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].Name < matches[j].Name
})
return matches
}
+164
View File
@@ -0,0 +1,164 @@
package command
import "testing"
func TestRegistry_Register(t *testing.T) {
r := NewRegistry()
cmd := &Command{
Name: "test",
Description: "A test command",
Handler: func(_ *Context, _ []string) Result {
return Result{Text: "ok"}
},
}
r.Register(cmd)
all := r.All()
if len(all) != 1 {
t.Fatalf("expected 1 command, got %d", len(all))
}
if all[0].Name != "test" {
t.Errorf("command name = %q, want %q", all[0].Name, "test")
}
// Execute to verify it was registered correctly
result := r.Execute(&Context{}, "test", nil)
if result.Text != "ok" {
t.Errorf("result text = %q, want %q", result.Text, "ok")
}
}
func TestRegistry_Execute(t *testing.T) {
r := NewRegistry()
called := false
r.Register(&Command{
Name: "run",
Handler: func(_ *Context, _ []string) Result {
called = true
return Result{Text: "executed"}
},
})
t.Run("found command executes handler", func(t *testing.T) {
result := r.Execute(&Context{}, "run", nil)
if !called {
t.Error("handler was not called")
}
if result.Text != "executed" {
t.Errorf("result text = %q, want %q", result.Text, "executed")
}
})
t.Run("not found returns error", func(t *testing.T) {
result := r.Execute(&Context{}, "nonexistent", nil)
if result.Error == "" {
t.Error("expected error for unknown command")
}
})
}
func TestRegistry_ExecuteByAlias(t *testing.T) {
r := NewRegistry()
r.Register(&Command{
Name: "mycommand",
Aliases: []string{"mc", "m"},
Handler: func(_ *Context, _ []string) Result {
return Result{Text: "alias works"}
},
})
tests := []struct {
name string
cmdName string
wantOk bool
}{
{name: "by name", cmdName: "mycommand", wantOk: true},
{name: "by alias mc", cmdName: "mc", wantOk: true},
{name: "by alias m", cmdName: "m", wantOk: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := r.Execute(&Context{}, tt.cmdName, nil)
if tt.wantOk && result.Error != "" {
t.Errorf("unexpected error: %s", result.Error)
}
if tt.wantOk && result.Text != "alias works" {
t.Errorf("result text = %q, want %q", result.Text, "alias works")
}
})
}
}
func TestRegistry_All(t *testing.T) {
r := NewRegistry()
names := []string{"alpha", "beta", "gamma"}
for _, name := range names {
n := name // capture
r.Register(&Command{
Name: n,
Handler: func(_ *Context, _ []string) Result { return Result{} },
})
}
all := r.All()
if len(all) != len(names) {
t.Fatalf("expected %d commands, got %d", len(names), len(all))
}
for i, cmd := range all {
if cmd.Name != names[i] {
t.Errorf("All()[%d].Name = %q, want %q", i, cmd.Name, names[i])
}
}
}
func TestRegistry_Match(t *testing.T) {
r := NewRegistry()
r.Register(&Command{
Name: "model",
Aliases: []string{"m"},
Handler: func(_ *Context, _ []string) Result { return Result{} },
})
r.Register(&Command{
Name: "models",
Aliases: []string{"ml"},
Handler: func(_ *Context, _ []string) Result { return Result{} },
})
r.Register(&Command{
Name: "help",
Handler: func(_ *Context, _ []string) Result { return Result{} },
})
tests := []struct {
name string
prefix string
want int
}{
{name: "prefix mo matches model and models", prefix: "mo", want: 2},
{name: "prefix model matches model and models", prefix: "model", want: 2},
{name: "prefix models matches only models", prefix: "models", want: 1},
{name: "prefix h matches help", prefix: "h", want: 1},
{name: "no match", prefix: "z", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := r.Match(tt.prefix)
if len(matches) != tt.want {
t.Errorf("Match(%q) returned %d results, want %d", tt.prefix, len(matches), tt.want)
}
})
}
// Verify no duplicates from aliases
t.Run("aliases dont create dupes", func(t *testing.T) {
matches := r.Match("model")
seen := make(map[string]bool)
for _, m := range matches {
if seen[m.Name] {
t.Errorf("duplicate match for %q", m.Name)
}
seen[m.Name] = true
}
})
}
+366
View File
@@ -0,0 +1,366 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type AgentsDir struct {
Path string
Agents map[string]AgentProfile
MCPServers []ServerConfig
GlobalInstructions string
Skills []SkillDef
}
type AgentProfile struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Model string `yaml:"model" json:"model"`
Skills []string `yaml:"skills" json:"skills"`
MCPServers []string `yaml:"mcp_servers" json:"mcp_servers"`
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
UseCases []string `yaml:"use_cases" json:"use_cases"`
}
type SkillDef struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Path string `yaml:"path" json:"path"`
}
type MCPConfig struct {
Servers []ServerConfig `json:"servers,omitempty"`
}
type ModelsConfig struct {
Models []Model `yaml:"models,omitempty"`
DefaultModel string `yaml:"default_model,omitempty"`
FallbackChain []string `yaml:"fallback_chain,omitempty"`
AutoSelect bool `yaml:"auto_select,omitempty"`
EmbedModel string `yaml:"embed_model,omitempty"`
}
func FindAgentsDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := []string{
filepath.Join(home, ".agents"),
filepath.Join(home, ".config", "agents"),
}
for _, dir := range candidates {
if _, err := os.Stat(dir); err == nil {
return dir
}
}
return ""
}
func FindAgentsDirWithCreate() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get home dir: %w", err)
}
dirs := []string{
filepath.Join(home, ".agents"),
filepath.Join(home, ".config", "agents"),
}
for _, dir := range dirs {
if _, err := os.Stat(dir); err == nil {
return dir, nil
}
}
if err := os.MkdirAll(dirs[0], 0755); err != nil {
return "", fmt.Errorf("create agents dir: %w", err)
}
return dirs[0], nil
}
func LoadAgentsDir(path string) (*AgentsDir, error) {
if path == "" {
path = FindAgentsDir()
if path == "" {
return &AgentsDir{
Path: "",
Agents: make(map[string]AgentProfile),
}, nil
}
}
dir := &AgentsDir{
Path: path,
Agents: make(map[string]AgentProfile),
}
if err := dir.loadAgents(path); err != nil {
return nil, fmt.Errorf("load agents: %w", err)
}
if err := dir.loadMCP(path); err != nil {
return nil, fmt.Errorf("load MCP: %w", err)
}
if err := dir.loadGlobalInstructions(path); err != nil {
return nil, fmt.Errorf("load instructions: %w", err)
}
if err := dir.loadSkills(path); err != nil {
return nil, fmt.Errorf("load skills: %w", err)
}
return dir, nil
}
func (d *AgentsDir) loadAgents(path string) error {
agentsDir := filepath.Join(path, "agents")
entries, err := os.ReadDir(agentsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
agentPath := filepath.Join(agentsDir, entry.Name(), "agent.yaml")
if _, err := os.Stat(agentPath); err != nil {
agentPath = filepath.Join(agentsDir, entry.Name(), "agent.md")
}
if _, err := os.Stat(agentPath); err != nil {
continue
}
data, err := os.ReadFile(agentPath)
if err != nil {
continue
}
var profile AgentProfile
if err := yaml.Unmarshal(data, &profile); err != nil {
continue
}
if profile.Name == "" {
profile.Name = entry.Name()
}
d.Agents[profile.Name] = profile
}
}
return nil
}
func (d *AgentsDir) loadMCP(path string) error {
mcpPath := filepath.Join(path, "mcp.json")
data, err := os.ReadFile(mcpPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var mcpCfg MCPConfig
if err := json.Unmarshal(data, &mcpCfg); err != nil {
return fmt.Errorf("parse mcp.json: %w", err)
}
d.MCPServers = mcpCfg.Servers
return nil
}
func (d *AgentsDir) loadGlobalInstructions(path string) error {
paths := []string{
filepath.Join(path, "agents.md"),
filepath.Join(path, "instructions.md"),
}
for _, p := range paths {
data, err := os.ReadFile(p)
if err == nil {
d.GlobalInstructions = string(data)
return nil
}
}
return nil
}
func (d *AgentsDir) loadSkills(path string) error {
skillsDir := filepath.Join(path, "skills")
entries, err := os.ReadDir(skillsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
skillDir := filepath.Join(skillsDir, entry.Name())
// Try both SKILL.md and skill.md (case insensitive check)
skillPath := ""
for _, name := range []string{"SKILL.md", "skill.md"} {
path := filepath.Join(skillDir, name)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
skillPath = path
break
}
}
if skillPath == "" {
continue
}
data, err := os.ReadFile(skillPath)
if err != nil {
continue
}
d.Skills = append(d.Skills, SkillDef{
Name: entry.Name(),
Description: extractDescription(string(data)),
Path: skillPath,
})
}
return nil
}
func extractDescription(content string) string {
for _, line := range splitLines(content) {
line = trimWhitespace(line)
if line == "" || startsWith(line, "#") {
continue
}
return line
}
return ""
}
func splitLines(s string) []string {
var lines []string
start := 0
for i, r := range s {
if r == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
func trimWhitespace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
func startsWith(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func (d *AgentsDir) GetAgent(name string) *AgentProfile {
if agent, ok := d.Agents[name]; ok {
return &agent
}
return nil
}
func (d *AgentsDir) ListAgents() []AgentProfile {
agents := make([]AgentProfile, 0, len(d.Agents))
for _, agent := range d.Agents {
agents = append(agents, agent)
}
return agents
}
func (d *AgentsDir) GetSkills() []SkillDef {
return d.Skills
}
func (d *AgentsDir) HasMCP() bool {
return len(d.MCPServers) > 0
}
func (d *AgentsDir) GetMCPServers() []ServerConfig {
return d.MCPServers
}
func (d *AgentsDir) GetGlobalInstructions() string {
return d.GlobalInstructions
}
func CreateDefaultAgentsDir() error {
dir, err := FindAgentsDirWithCreate()
if err != nil {
return err
}
subdirs := []string{"agents", "skills", "tasks", "memories"}
for _, sub := range subdirs {
path := filepath.Join(dir, sub)
if _, err := os.Stat(path); err != nil {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", sub, err)
}
}
}
mcpPath := filepath.Join(dir, "mcp.json")
if _, err := os.Stat(mcpPath); err != nil {
defaultMCP := MCPConfig{
Servers: []ServerConfig{},
}
data, _ := json.MarshalIndent(defaultMCP, "", " ")
if err := os.WriteFile(mcpPath, data, 0644); err != nil {
return fmt.Errorf("write mcp.json: %w", err)
}
}
agentsPath := filepath.Join(dir, "agents.md")
if _, err := os.Stat(agentsPath); err != nil {
defaultContent := `# Global Agent Instructions
You are a helpful local AI coding assistant.
## Guidelines
- Be concise and direct
- Explain your reasoning
- Ask for clarification when needed
- Never fabricate information
`
if err := os.WriteFile(agentsPath, []byte(defaultContent), 0644); err != nil {
return fmt.Errorf("write agents.md: %w", err)
}
}
return nil
}
+176
View File
@@ -0,0 +1,176 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestExtractDescription(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "first non-header non-empty line",
content: "# Title\n\nThis is the description.\nMore text.",
want: "This is the description.",
},
{
name: "header only content",
content: "# Title\n## Subtitle\n### Another",
want: "",
},
{
name: "empty content",
content: "",
want: "",
},
{
name: "whitespace around description",
content: "# Title\n\n Indented description \n",
want: "Indented description",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractDescription(tt.content)
if got != tt.want {
t.Errorf("extractDescription() = %q, want %q", got, tt.want)
}
})
}
}
func TestSplitLines(t *testing.T) {
tests := []struct {
name string
s string
want int // expected number of lines
}{
{name: "normal lines", s: "a\nb\nc", want: 3},
{name: "empty string", s: "", want: 1},
{name: "trailing newline", s: "a\nb\n", want: 3},
{name: "single line", s: "hello", want: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitLines(tt.s)
if len(got) != tt.want {
t.Errorf("splitLines(%q) returned %d lines, want %d (lines: %v)", tt.s, len(got), tt.want, got)
}
})
}
}
func TestTrimWhitespace(t *testing.T) {
tests := []struct {
name string
s string
want string
}{
{name: "tabs", s: "\thello\t", want: "hello"},
{name: "spaces", s: " hello ", want: "hello"},
{name: "mixed", s: "\t hello \t", want: "hello"},
{name: "already trimmed", s: "hello", want: "hello"},
{name: "empty", s: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := trimWhitespace(tt.s)
if got != tt.want {
t.Errorf("trimWhitespace(%q) = %q, want %q", tt.s, got, tt.want)
}
})
}
}
func TestStartsWith(t *testing.T) {
tests := []struct {
name string
s string
prefix string
want bool
}{
{name: "match", s: "hello world", prefix: "hello", want: true},
{name: "no match", s: "hello world", prefix: "world", want: false},
{name: "empty prefix", s: "hello", prefix: "", want: true},
{name: "longer prefix", s: "hi", prefix: "hello", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := startsWith(tt.s, tt.prefix)
if got != tt.want {
t.Errorf("startsWith(%q, %q) = %v, want %v", tt.s, tt.prefix, got, tt.want)
}
})
}
}
func TestLoadAgentsDir(t *testing.T) {
t.Run("valid temp structure with agent", func(t *testing.T) {
tmp := t.TempDir()
// Create agents/test-agent/agent.yaml
agentDir := filepath.Join(tmp, "agents", "test-agent")
if err := os.MkdirAll(agentDir, 0755); err != nil {
t.Fatal(err)
}
agentYAML := `name: test-agent
description: A test agent
model: qwen3.5:0.8b
`
if err := os.WriteFile(filepath.Join(agentDir, "agent.yaml"), []byte(agentYAML), 0644); err != nil {
t.Fatal(err)
}
dir, err := LoadAgentsDir(tmp)
if err != nil {
t.Fatalf("LoadAgentsDir() error: %v", err)
}
if dir.Path != tmp {
t.Errorf("Path = %q, want %q", dir.Path, tmp)
}
if len(dir.Agents) != 1 {
t.Errorf("expected 1 agent, got %d", len(dir.Agents))
}
agent, ok := dir.Agents["test-agent"]
if !ok {
t.Fatal("expected agent 'test-agent' to exist")
}
if agent.Description != "A test agent" {
t.Errorf("agent description = %q, want %q", agent.Description, "A test agent")
}
})
t.Run("empty path uses FindAgentsDir", func(t *testing.T) {
dir, err := LoadAgentsDir("")
if err != nil {
t.Fatalf("LoadAgentsDir('') error: %v", err)
}
// Should return a valid AgentsDir (possibly with no agents)
if dir == nil {
t.Fatal("expected non-nil AgentsDir")
}
if dir.Agents == nil {
t.Error("expected Agents map to be initialized")
}
})
t.Run("nonexistent subdirs dont error", func(t *testing.T) {
tmp := t.TempDir()
// Empty temp dir — no agents/, skills/, mcp.json, etc.
dir, err := LoadAgentsDir(tmp)
if err != nil {
t.Fatalf("LoadAgentsDir() error: %v", err)
}
if len(dir.Agents) != 0 {
t.Errorf("expected 0 agents, got %d", len(dir.Agents))
}
})
}
+186
View File
@@ -0,0 +1,186 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"gopkg.in/yaml.v3"
)
type Config struct {
Ollama OllamaConfig `yaml:"ollama"`
Model ModelConfig `yaml:"model,omitempty"`
Agents AgentsConfig `yaml:"agents,omitempty"`
Servers []ServerConfig `yaml:"servers,omitempty"`
SkillsDir string `yaml:"skills_dir,omitempty"`
ICE ICEConfig `yaml:"ice,omitempty"`
AgentProfile string `yaml:"agent_profile,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
}
type AgentsConfig struct {
Dir string `yaml:"dir,omitempty"`
AutoLoad bool `yaml:"auto_load"`
}
type ToolsConfig struct {
Timeout string `yaml:"timeout,omitempty"` // e.g., "30s", "2m"
MaxGrepResults int `yaml:"max_grep_results,omitempty"`
MaxIterations int `yaml:"max_iterations,omitempty"`
}
type ICEConfig struct {
Enabled bool `yaml:"enabled"`
EmbedModel string `yaml:"embed_model,omitempty"`
StorePath string `yaml:"store_path,omitempty"`
}
type OllamaConfig struct {
Model string `yaml:"model"`
BaseURL string `yaml:"base_url"`
NumCtx int `yaml:"num_ctx"`
}
type ServerConfig struct {
Name string `yaml:"name"`
Command string `yaml:"command,omitempty"`
Args []string `yaml:"args,omitempty"`
Env []string `yaml:"env,omitempty"`
Transport string `yaml:"transport,omitempty"`
URL string `yaml:"url,omitempty"`
}
func defaults() Config {
modelCfg := DefaultModelConfig()
return Config{
Ollama: OllamaConfig{
Model: "qwen3.5:2b",
BaseURL: "http://localhost:11434",
NumCtx: 262144,
},
Model: modelCfg,
Agents: AgentsConfig{
Dir: "",
AutoLoad: true,
},
Tools: ToolsConfig{
Timeout: "30s",
MaxGrepResults: 500,
MaxIterations: 10,
},
}
}
func Load() (*Config, error) {
cfg := defaults()
localPath := findConfigFile()
if localPath != "" {
data, err := os.ReadFile(localPath)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", localPath, err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", localPath, err)
}
}
agentsDir := cfg.Agents.Dir
if agentsDir == "" {
agentsDir = FindAgentsDir()
}
var agentsData *AgentsDir
if agentsDir != "" && cfg.Agents.AutoLoad {
var err error
agentsData, err = LoadAgentsDir(agentsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to load .agents directory: %v\n", err)
} else {
if agentsData != nil {
if cfg.Ollama.Model == "" {
cfg.Ollama.Model = cfg.Model.DefaultModel
}
if len(cfg.Servers) == 0 && agentsData.HasMCP() {
cfg.Servers = agentsData.GetMCPServers()
}
}
}
}
applyEnvOverrides(&cfg)
return &cfg, nil
}
func LoadWithAgentsDir() (*Config, *AgentsDir, error) {
cfg, err := Load()
if err != nil {
return nil, nil, err
}
agentsDir := cfg.Agents.Dir
if agentsDir == "" {
agentsDir = FindAgentsDir()
}
var agents *AgentsDir
if agentsDir != "" && cfg.Agents.AutoLoad {
agents, _ = LoadAgentsDir(agentsDir)
}
return cfg, agents, nil
}
func findConfigFile() string {
candidates := []string{
"ai-agent.yaml",
"ai-agent.yml",
"config.yaml",
"config.yml",
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, ".config", "ai-agent", "config.yaml"),
filepath.Join(home, ".config", "ai-agent", "config.yml"),
)
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("OLLAMA_HOST"); v != "" {
cfg.Ollama.BaseURL = v
}
if v := os.Getenv("LOCAL_AGENT_MODEL"); v != "" {
cfg.Ollama.Model = v
}
if v := os.Getenv("LOCAL_AGENT_AGENTS_DIR"); v != "" {
cfg.Agents.Dir = v
}
if v := os.Getenv("LOCAL_AGENT_TOOLS_TIMEOUT"); v != "" {
cfg.Tools.Timeout = v
}
if v := os.Getenv("LOCAL_AGENT_TOOLS_MAX_GREP"); v != "" {
cfg.Tools.MaxGrepResults = parseEnvInt(v, cfg.Tools.MaxGrepResults)
}
if v := os.Getenv("LOCAL_AGENT_TOOLS_MAX_ITER"); v != "" {
cfg.Tools.MaxIterations = parseEnvInt(v, cfg.Tools.MaxIterations)
}
if v := os.Getenv("LOCAL_AGENT_ICE_EMBED_MODEL"); v != "" {
cfg.ICE.EmbedModel = v
}
}
func parseEnvInt(v string, defaultVal int) int {
if i, err := strconv.Atoi(v); err == nil {
return i
}
return defaultVal
}
+82
View File
@@ -0,0 +1,82 @@
package config
import "testing"
func TestDefaults(t *testing.T) {
cfg := defaults()
tests := []struct {
name string
got string
want string
}{
{name: "Ollama.Model", got: cfg.Ollama.Model, want: "qwen3.5:2b"},
{name: "Ollama.BaseURL", got: cfg.Ollama.BaseURL, want: "http://localhost:11434"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want)
}
})
}
if cfg.Ollama.NumCtx != 262144 {
t.Errorf("Ollama.NumCtx = %d, want %d", cfg.Ollama.NumCtx, 262144)
}
if !cfg.Model.AutoSelect {
t.Error("Model.AutoSelect should be true by default")
}
}
func TestApplyEnvOverrides(t *testing.T) {
tests := []struct {
name string
envKey string
envVal string
checkFn func(cfg *Config) string
want string
}{
{
name: "OLLAMA_HOST overrides BaseURL",
envKey: "OLLAMA_HOST",
envVal: "http://custom:1234",
checkFn: func(cfg *Config) string {
return cfg.Ollama.BaseURL
},
want: "http://custom:1234",
},
{
name: "LOCAL_AGENT_MODEL overrides Model",
envKey: "LOCAL_AGENT_MODEL",
envVal: "custom-model",
checkFn: func(cfg *Config) string {
return cfg.Ollama.Model
},
want: "custom-model",
},
{
name: "LOCAL_AGENT_AGENTS_DIR overrides AgentsDir",
envKey: "LOCAL_AGENT_AGENTS_DIR",
envVal: "/custom/agents",
checkFn: func(cfg *Config) string {
return cfg.Agents.Dir
},
want: "/custom/agents",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(tt.envKey, tt.envVal)
cfg := defaults()
applyEnvOverrides(&cfg)
got := tt.checkFn(&cfg)
if got != tt.want {
t.Errorf("after setting %s=%q, got %q, want %q", tt.envKey, tt.envVal, got, tt.want)
}
})
}
}
+103
View File
@@ -0,0 +1,103 @@
package config
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// IgnorePatterns holds parsed .agentignore patterns.
type IgnorePatterns struct {
patterns []string
raw string // original file content for injection into system prompt
}
// LoadIgnoreFile reads and parses an .agentignore file from the given directory.
// Returns nil if no .agentignore file exists (not an error).
func LoadIgnoreFile(dir string) *IgnorePatterns {
path := filepath.Join(dir, ".agentignore")
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var patterns []string
var rawLines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
rawLines = append(rawLines, line)
trimmed := strings.TrimSpace(line)
// Skip empty lines and comments.
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
patterns = append(patterns, trimmed)
}
return &IgnorePatterns{
patterns: patterns,
raw: strings.Join(rawLines, "\n"),
}
}
// Match returns true if the given path should be ignored.
// Returns false if the receiver is nil.
func (ip *IgnorePatterns) Match(path string) bool {
if ip == nil || len(ip.patterns) == 0 {
return false
}
// Normalise the path separators and remove trailing slashes for comparison.
path = filepath.ToSlash(path)
cleanPath := strings.TrimSuffix(path, "/")
for _, pattern := range ip.patterns {
pat := strings.TrimSuffix(pattern, "/")
// Check each component of the path against the pattern.
// e.g. "node_modules" should match "node_modules", "node_modules/foo",
// and "src/node_modules/bar".
parts := strings.Split(cleanPath, "/")
for _, part := range parts {
if matched, _ := filepath.Match(pat, part); matched {
return true
}
}
// Also try matching the full path with the pattern (for glob patterns
// that include path separators like "build/output").
if matched, _ := filepath.Match(pat, cleanPath); matched {
return true
}
// Prefix match: path starts with the pattern directory.
if strings.HasPrefix(cleanPath, pat+"/") || cleanPath == pat {
return true
}
}
return false
}
// Raw returns the raw file content for system prompt injection.
// Returns an empty string if the receiver is nil.
func (ip *IgnorePatterns) Raw() string {
if ip == nil {
return ""
}
return ip.raw
}
// Patterns returns the list of patterns.
// Returns nil if the receiver is nil.
func (ip *IgnorePatterns) Patterns() []string {
if ip == nil {
return nil
}
return ip.patterns
}
+183
View File
@@ -0,0 +1,183 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadIgnoreFile_Valid(t *testing.T) {
dir := t.TempDir()
content := `# Build artifacts
node_modules
*.log
.git
build/
dist/
vendor/
`
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
ip := LoadIgnoreFile(dir)
if ip == nil {
t.Fatal("expected non-nil IgnorePatterns")
}
wantPatterns := []string{"node_modules", "*.log", ".git", "build/", "dist/", "vendor/"}
if len(ip.Patterns()) != len(wantPatterns) {
t.Fatalf("got %d patterns, want %d", len(ip.Patterns()), len(wantPatterns))
}
for i, p := range ip.Patterns() {
if p != wantPatterns[i] {
t.Errorf("pattern[%d] = %q, want %q", i, p, wantPatterns[i])
}
}
if ip.Raw() != content[:len(content)-1] { // raw joins lines without trailing newline from Join
// Just check it contains the comment and patterns
if ip.Raw() == "" {
t.Error("Raw() should not be empty")
}
}
}
func TestLoadIgnoreFile_Missing(t *testing.T) {
dir := t.TempDir()
ip := LoadIgnoreFile(dir)
if ip != nil {
t.Error("expected nil for missing .agentignore")
}
}
func TestLoadIgnoreFile_Empty(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
ip := LoadIgnoreFile(dir)
if ip == nil {
t.Fatal("expected non-nil IgnorePatterns for empty file")
}
if len(ip.Patterns()) != 0 {
t.Errorf("expected 0 patterns, got %d", len(ip.Patterns()))
}
}
func TestLoadIgnoreFile_CommentsOnly(t *testing.T) {
dir := t.TempDir()
content := "# This is a comment\n# Another comment\n\n"
if err := os.WriteFile(filepath.Join(dir, ".agentignore"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
ip := LoadIgnoreFile(dir)
if ip == nil {
t.Fatal("expected non-nil IgnorePatterns")
}
if len(ip.Patterns()) != 0 {
t.Errorf("expected 0 patterns for comments-only file, got %d", len(ip.Patterns()))
}
}
func TestIgnorePatterns_Match_Exact(t *testing.T) {
ip := &IgnorePatterns{
patterns: []string{"node_modules", ".git", "vendor"},
}
tests := []struct {
path string
want bool
}{
{"node_modules", true},
{"node_modules/package/index.js", true},
{".git", true},
{".git/config", true},
{"vendor", true},
{"vendor/lib/foo.go", true},
{"src/main.go", false},
{"README.md", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := ip.Match(tt.path); got != tt.want {
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
func TestIgnorePatterns_Match_Glob(t *testing.T) {
ip := &IgnorePatterns{
patterns: []string{"*.log", "*.tmp"},
}
tests := []struct {
path string
want bool
}{
{"app.log", true},
{"debug.log", true},
{"temp.tmp", true},
{"logs/app.log", true},
{"main.go", false},
{"log.txt", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := ip.Match(tt.path); got != tt.want {
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
func TestIgnorePatterns_Match_DirectoryPattern(t *testing.T) {
ip := &IgnorePatterns{
patterns: []string{"build/", "dist/"},
}
tests := []struct {
path string
want bool
}{
{"build", true},
{"build/output.js", true},
{"dist", true},
{"dist/bundle.js", true},
{"src/build.go", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := ip.Match(tt.path); got != tt.want {
t.Errorf("Match(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
func TestIgnorePatterns_Match_NilReceiver(t *testing.T) {
var ip *IgnorePatterns
if ip.Match("anything") {
t.Error("nil IgnorePatterns should not match anything")
}
}
func TestIgnorePatterns_Raw_NilReceiver(t *testing.T) {
var ip *IgnorePatterns
if ip.Raw() != "" {
t.Error("nil IgnorePatterns Raw() should return empty string")
}
}
func TestIgnorePatterns_Patterns_NilReceiver(t *testing.T) {
var ip *IgnorePatterns
if ip.Patterns() != nil {
t.Error("nil IgnorePatterns Patterns() should return nil")
}
}
+167
View File
@@ -0,0 +1,167 @@
package config
import "fmt"
type ModelFamily string
const (
FamilyQwen3 ModelFamily = "qwen3"
FamilyQwen35 ModelFamily = "qwen3.5"
FamilyLlama ModelFamily = "llama"
FamilyMistral ModelFamily = "mistral"
)
type ModelCapability int
const (
CapabilitySimple ModelCapability = iota
CapabilityMedium
CapabilityComplex
CapabilityAdvanced
)
type Model struct {
Name string `yaml:"name"`
Family ModelFamily `yaml:"family"`
DisplayName string `yaml:"display_name"`
Size string `yaml:"size"`
Parameters string `yaml:"parameters"`
ContextSize int `yaml:"context_size"`
Capability ModelCapability `yaml:"capability"`
Speed float64 `yaml:"speed"` // 1.0 = baseline
UseCases []string `yaml:"use_cases"`
Description string `yaml:"description"`
Default bool `yaml:"default,omitempty"`
}
type ModelConfig struct {
Models []Model `yaml:"models"`
DefaultModel string `yaml:"default_model"`
FallbackChain []string `yaml:"fallback_chain"`
AutoSelect bool `yaml:"auto_select"`
EmbedModel string `yaml:"embed_model,omitempty"`
}
func DefaultModels() []Model {
return []Model{
{
Name: "qwen3.5:0.8b",
Family: FamilyQwen35,
DisplayName: "Qwen 3.5 0.8B",
Size: "0.8B",
Parameters: "0.8 billion",
ContextSize: 262144,
Capability: CapabilitySimple,
Speed: 4.0,
UseCases: []string{"quick_answers", "simple_tools", "single_file_edits"},
Description: "Fast, lightweight model for simple tasks and quick answers",
Default: false,
},
{
Name: "qwen3.5:2b",
Family: FamilyQwen35,
DisplayName: "Qwen 3.5 2B",
Size: "2B",
Parameters: "2 billion",
ContextSize: 262144,
Capability: CapabilityMedium,
Speed: 2.5,
UseCases: []string{"code_completion", "simple_refactoring", "explanations"},
Description: "Balanced model for medium complexity tasks",
Default: true,
},
{
Name: "qwen3.5:4b",
Family: FamilyQwen35,
DisplayName: "Qwen 3.5 4B",
Size: "4B",
Parameters: "4 billion",
ContextSize: 262144,
Capability: CapabilityComplex,
Speed: 1.5,
UseCases: []string{"multi_step_reasoning", "code_review", "debugging", "refactoring"},
Description: "Capable model for complex reasoning and code analysis",
Default: false,
},
{
Name: "qwen3.5:9b",
Family: FamilyQwen35,
DisplayName: "Qwen 3.5 9B",
Size: "9B",
Parameters: "9 billion",
ContextSize: 262144,
Capability: CapabilityAdvanced,
Speed: 1.0,
UseCases: []string{"complex_reasoning", "architecture", "full_stack", "advanced_debugging"},
Description: "Full capability model for advanced tasks",
Default: false,
},
}
}
func DefaultModelConfig() ModelConfig {
models := DefaultModels()
return ModelConfig{
Models: models,
DefaultModel: "qwen3.5:2b",
FallbackChain: []string{"qwen3.5:2b", "qwen3.5:0.8b", "qwen3.5:4b", "qwen3.5:9b"},
AutoSelect: true,
EmbedModel: "nomic-embed-text",
}
}
func (m *Model) IsSimpleTask() bool {
return m.Capability <= CapabilityMedium
}
func (m *Model) IsComplexTask() bool {
return m.Capability >= CapabilityComplex
}
func (mc *ModelConfig) GetModel(name string) (*Model, error) {
for _, m := range mc.Models {
if m.Name == name {
return &m, nil
}
}
return nil, fmt.Errorf("model not found: %s", name)
}
func (mc *ModelConfig) GetDefaultModel() *Model {
for _, m := range mc.Models {
if m.Default {
return &m
}
}
if len(mc.Models) > 0 {
return &mc.Models[len(mc.Models)-1]
}
return nil
}
func (mc *ModelConfig) SelectModelForTask(taskComplexity string) string {
if !mc.AutoSelect {
return mc.DefaultModel
}
switch taskComplexity {
case "simple":
return mc.Models[0].Name
case "medium":
for _, m := range mc.Models {
if m.Capability == CapabilityMedium {
return m.Name
}
}
case "complex":
for _, m := range mc.Models {
if m.Capability == CapabilityComplex {
return m.Name
}
}
case "advanced":
return mc.DefaultModel
}
return mc.DefaultModel
}
+159
View File
@@ -0,0 +1,159 @@
package config
import "testing"
func TestModel_IsSimpleTask(t *testing.T) {
tests := []struct {
name string
capability ModelCapability
want bool
}{
{name: "CapabilitySimple is simple", capability: CapabilitySimple, want: true},
{name: "CapabilityMedium is simple", capability: CapabilityMedium, want: true},
{name: "CapabilityComplex is not simple", capability: CapabilityComplex, want: false},
{name: "CapabilityAdvanced is not simple", capability: CapabilityAdvanced, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Model{Capability: tt.capability}
if got := m.IsSimpleTask(); got != tt.want {
t.Errorf("Model{Capability: %d}.IsSimpleTask() = %v, want %v", tt.capability, got, tt.want)
}
})
}
}
func TestModel_IsComplexTask(t *testing.T) {
tests := []struct {
name string
capability ModelCapability
want bool
}{
{name: "CapabilitySimple is not complex", capability: CapabilitySimple, want: false},
{name: "CapabilityMedium is not complex", capability: CapabilityMedium, want: false},
{name: "CapabilityComplex is complex", capability: CapabilityComplex, want: true},
{name: "CapabilityAdvanced is complex", capability: CapabilityAdvanced, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Model{Capability: tt.capability}
if got := m.IsComplexTask(); got != tt.want {
t.Errorf("Model{Capability: %d}.IsComplexTask() = %v, want %v", tt.capability, got, tt.want)
}
})
}
}
func TestModelConfig_GetModel(t *testing.T) {
cfg := DefaultModelConfig()
tests := []struct {
name string
model string
wantErr bool
}{
{name: "found model", model: "qwen3.5:0.8b", wantErr: false},
{name: "not found", model: "nonexistent", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := cfg.GetModel(tt.model)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if got.Name != tt.model {
t.Errorf("GetModel(%q).Name = %q, want %q", tt.model, got.Name, tt.model)
}
}
})
}
}
func TestModelConfig_GetDefaultModel(t *testing.T) {
tests := []struct {
name string
cfg ModelConfig
want string // empty means nil expected
}{
{
name: "model with Default=true",
cfg: ModelConfig{
Models: []Model{
{Name: "a", Default: false},
{Name: "b", Default: true},
{Name: "c", Default: false},
},
},
want: "b",
},
{
name: "no default returns last",
cfg: ModelConfig{
Models: []Model{
{Name: "a", Default: false},
{Name: "b", Default: false},
},
},
want: "b",
},
{
name: "empty slice returns nil",
cfg: ModelConfig{Models: []Model{}},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.GetDefaultModel()
if tt.want == "" {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
} else {
if got == nil {
t.Fatal("expected non-nil model, got nil")
}
if got.Name != tt.want {
t.Errorf("GetDefaultModel().Name = %q, want %q", got.Name, tt.want)
}
}
})
}
}
func TestModelConfig_SelectModelForTask(t *testing.T) {
cfg := DefaultModelConfig()
tests := []struct {
name string
complexity string
autoSelect bool
want string
}{
{name: "auto simple", complexity: "simple", autoSelect: true, want: "qwen3.5:0.8b"},
{name: "auto medium", complexity: "medium", autoSelect: true, want: "qwen3.5:2b"},
{name: "auto complex", complexity: "complex", autoSelect: true, want: "qwen3.5:4b"},
{name: "auto advanced", complexity: "advanced", autoSelect: true, want: cfg.DefaultModel},
{name: "no autoselect simple", complexity: "simple", autoSelect: false, want: cfg.DefaultModel},
{name: "no autoselect complex", complexity: "complex", autoSelect: false, want: cfg.DefaultModel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg.AutoSelect = tt.autoSelect
got := cfg.SelectModelForTask(tt.complexity)
if got != tt.want {
t.Errorf("SelectModelForTask(%q) = %q, want %q", tt.complexity, got, tt.want)
}
})
}
}
+364
View File
@@ -0,0 +1,364 @@
package config
import (
"context"
"strings"
"sync"
"time"
)
type QwenModelRouter struct {
config *ModelConfig
overrideLog []ModelOverride
modeContext ModeContext
mu sync.RWMutex
}
type ModeContext int
const (
ModeAskContext ModeContext = iota
ModePlanContext
ModeBuildContext
)
type QwenComplexity string
const (
QwenTrivial QwenComplexity = "trivial"
QwenSimple QwenComplexity = "simple"
QwenModerate QwenComplexity = "moderate"
QwenAdvanced QwenComplexity = "advanced"
)
var (
qwenTrivialIndicators = []string{
"what is", "who is", "when is", "where is",
"define", "meaning of", "synonym", "antonym",
"list files", "show me", "display",
"yes", "no", "ok", "thanks",
"hello", "hi", "hey",
}
qwenSimpleIndicators = []string{
"how do i", "explain", "what does", "why does",
"find", "search", "get", "read",
"print", "echo", "cat", "ls", "grep",
"simple", "quick", "fast", "brief",
"check", "verify", "test",
"create file", "write file", "save",
}
qwenModerateIndicators = []string{
"create", "generate", "add", "modify", "update",
"fix", "debug", "refactor", "optimize",
"function", "class", "method", "interface",
"test", "unit test", "integration test",
"script", "command", "pipeline",
"compare", "analyze", "review",
"multiple", "several", "across",
}
qwenAdvancedIndicators = []string{
"architecture", "design pattern", "system design",
"infrastructure", "deployment", "scaling",
"security audit", "performance optimization",
"multi-step", "complex", "comprehensive",
"build a", "implement", "develop", "engineer",
"full stack", "end-to-end", "production",
"migration", "refactor entire", "rewrite",
}
qwenCodePatterns = map[string]QwenComplexity{
"variable": QwenSimple,
"constant": QwenSimple,
"function": QwenSimple,
"loop": QwenSimple,
"condition": QwenSimple,
"array": QwenSimple,
"slice": QwenSimple,
"map": QwenSimple,
"struct": QwenModerate,
"interface": QwenModerate,
"generics": QwenModerate,
"concurrency": QwenModerate,
"goroutine": QwenModerate,
"channel": QwenModerate,
"mutex": QwenModerate,
"architecture": QwenAdvanced,
"pattern": QwenAdvanced,
"microservice": QwenAdvanced,
"distributed": QwenAdvanced,
"kubernetes": QwenAdvanced,
}
)
func NewQwenModelRouter(cfg *ModelConfig) *QwenModelRouter {
return &QwenModelRouter{
config: cfg,
overrideLog: make([]ModelOverride, 0),
modeContext: ModeAskContext,
}
}
func (r *QwenModelRouter) SetModeContext(mode ModeContext) {
r.mu.Lock()
defer r.mu.Unlock()
r.modeContext = mode
}
func (r *QwenModelRouter) ClassifyTaskComplexity(query string) QwenComplexity {
return classifyQwenTask(query, r.modeContext)
}
func (r *QwenModelRouter) SelectModel(query string) string {
complexity := r.ClassifyTaskComplexity(query)
return r.config.SelectModelForTask(string(complexity))
}
func (r *QwenModelRouter) SelectModelForMode(query string, mode ModeContext) string {
switch mode {
case ModeAskContext:
return r.selectAskModel(query)
case ModePlanContext:
return r.selectPlanModel(query)
case ModeBuildContext:
return r.selectBuildModel(query)
}
return r.SelectModel(query)
}
func (r *QwenModelRouter) selectAskModel(query string) string {
complexity := classifyQwenTask(query, ModeAskContext)
switch complexity {
case QwenTrivial, QwenSimple:
if r.isModelAvailable("qwen3.5:0.8b") {
return "qwen3.5:0.8b"
}
return "qwen3.5:2b"
case QwenModerate:
return "qwen3.5:2b"
case QwenAdvanced:
return "qwen3.5:4b"
default:
return "qwen3.5:2b"
}
}
func (r *QwenModelRouter) selectPlanModel(query string) string {
complexity := classifyQwenTask(query, ModePlanContext)
switch complexity {
case QwenTrivial, QwenSimple:
return "qwen3.5:2b"
case QwenModerate:
return "qwen3.5:4b"
case QwenAdvanced:
return "qwen3.5:9b"
default:
return "qwen3.5:4b"
}
}
func (r *QwenModelRouter) selectBuildModel(query string) string {
complexity := classifyQwenTask(query, ModeBuildContext)
switch complexity {
case QwenTrivial, QwenSimple:
return "qwen3.5:2b"
case QwenModerate:
return "qwen3.5:4b"
case QwenAdvanced:
return "qwen3.5:9b"
default:
return "qwen3.5:4b"
}
}
func (r *QwenModelRouter) isModelAvailable(name string) bool {
for _, m := range r.config.Models {
if m.Name == name {
return true
}
}
return false
}
func classifyQwenTask(query string, mode ModeContext) QwenComplexity {
lowerQuery := strings.ToLower(query)
words := strings.Fields(lowerQuery)
wordCount := len(words)
score := 0
for _, indicator := range qwenTrivialIndicators {
if strings.Contains(lowerQuery, indicator) {
score -= 4
}
}
for _, indicator := range qwenSimpleIndicators {
if strings.Contains(lowerQuery, indicator) {
score -= 1
}
}
for _, indicator := range qwenModerateIndicators {
if strings.Contains(lowerQuery, indicator) {
score += 2
}
}
for _, indicator := range qwenAdvancedIndicators {
if strings.Contains(lowerQuery, indicator) {
score += 4
}
}
for pattern, complexity := range qwenCodePatterns {
if strings.Contains(lowerQuery, pattern) {
switch complexity {
case QwenSimple:
score -= 1
case QwenModerate:
score += 2
case QwenAdvanced:
score += 4
}
}
}
if wordCount > 50 {
score += 3
} else if wordCount > 30 {
score += 1
} else if wordCount < 5 && score <= 0 {
score -= 2
}
if strings.Contains(lowerQuery, "why") || strings.Contains(lowerQuery, "reason") {
score += 2
}
if strings.Contains(lowerQuery, "how") && wordCount > 10 {
score += 1
}
if strings.Contains(lowerQuery, "?") && wordCount < 10 {
score -= 1
}
switch mode {
case ModeAskContext:
score -= 1
case ModeBuildContext:
score += 1
}
switch {
case score <= -3:
return QwenTrivial
case score <= 1:
return QwenSimple
case score <= 5:
return QwenModerate
default:
return QwenAdvanced
}
}
func (r *QwenModelRouter) RecordOverride(query, userModel string) {
r.mu.Lock()
defer r.mu.Unlock()
routerModel := r.SelectModel(query)
r.overrideLog = append(r.overrideLog, ModelOverride{
Query: query,
UserModel: userModel,
RouterModel: routerModel,
Timestamp: time.Now(),
})
if len(r.overrideLog) > 100 {
r.overrideLog = r.overrideLog[len(r.overrideLog)-100:]
}
}
func (r *QwenModelRouter) GetLearnedPatterns() map[string]QwenComplexity {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.overrideLog) < 3 {
return nil
}
wordCounts := make(map[string]map[QwenComplexity]int)
for _, o := range r.overrideLog {
if o.Query == "" || o.UserModel == "" {
continue
}
var complexity QwenComplexity
switch {
case strings.Contains(o.UserModel, "0.8b"):
complexity = QwenTrivial
case strings.Contains(o.UserModel, "2b"):
complexity = QwenSimple
case strings.Contains(o.UserModel, "4b"):
complexity = QwenModerate
case strings.Contains(o.UserModel, "9b"):
complexity = QwenAdvanced
default:
continue
}
words := strings.Fields(strings.ToLower(o.Query))
for _, w := range words {
if len(w) < 3 {
continue
}
if _, ok := wordCounts[w]; !ok {
wordCounts[w] = make(map[QwenComplexity]int)
}
wordCounts[w][complexity]++
}
}
wordComplexity := make(map[string]QwenComplexity)
for word, counts := range wordCounts {
var maxCount int
var dominant QwenComplexity
for c, cnt := range counts {
if cnt > maxCount {
maxCount = cnt
dominant = c
}
}
if maxCount >= 2 {
wordComplexity[word] = dominant
}
}
return wordComplexity
}
func (r *QwenModelRouter) SelectAvailableModelForTask(ctx context.Context, pinger ModelPinger, query string) string {
preferred := r.SelectModel(query)
fallbackOrder := []string{
preferred,
"qwen3.5:2b",
"qwen3.5:0.8b",
"qwen3.5:4b",
"qwen3.5:9b",
}
for _, model := range fallbackOrder {
if err := pinger.PingModel(ctx, model); err == nil {
return model
}
}
return r.config.DefaultModel
}
func (r *QwenModelRouter) GetRecommendedModel(query string) (model string, reason string, complexity QwenComplexity) {
r.mu.RLock()
mode := r.modeContext
r.mu.RUnlock()
complexity = classifyQwenTask(query, mode)
switch complexity {
case QwenTrivial:
model = "qwen3.5:0.8b"
reason = "trivial task - ultra-fast response"
case QwenSimple:
model = "qwen3.5:2b"
reason = "simple task - balanced speed/capability"
case QwenModerate:
model = "qwen3.5:4b"
reason = "moderate complexity - multi-step reasoning"
case QwenAdvanced:
model = "qwen3.5:9b"
reason = "advanced task - complex reasoning required"
}
switch mode {
case ModeAskContext:
reason += " (ASK mode - prefer speed)"
case ModePlanContext:
reason += " (PLAN mode - prefer reasoning)"
case ModeBuildContext:
reason += " (BUILD mode - prefer capability)"
}
return model, reason, complexity
}
+254
View File
@@ -0,0 +1,254 @@
package config
import (
"testing"
)
func TestQwenRouter_ClassifyTrivial(t *testing.T) {
tests := []struct {
name string
query string
maxComplexity QwenComplexity
}{
{"simple what", "what is go", QwenTrivial},
{"simple who", "who created go", QwenSimple},
{"simple define", "define interface", QwenTrivial},
{"simple greeting", "hello", QwenTrivial},
{"simple thanks", "thanks", QwenTrivial},
{"simple list", "list files", QwenTrivial},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyQwenTask(tt.query, ModeAskContext)
if got > tt.maxComplexity {
t.Errorf("classifyQwenTask(%q) = %v, want <= %v", tt.query, got, tt.maxComplexity)
}
})
}
}
func TestQwenRouter_ClassifySimple(t *testing.T) {
tests := []struct {
name string
query string
}{
{"simple how", "how do i create a file"},
{"simple explain", "explain this code"},
{"simple find", "find all go files"},
{"simple check", "check if file exists"},
{"simple read", "read config file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyQwenTask(tt.query, ModeAskContext)
t.Logf("%s: %v", tt.query, got)
})
}
}
func TestQwenRouter_ClassifyModerate(t *testing.T) {
tests := []struct {
name string
query string
}{
{"create function", "create a function to parse json"},
{"debug issue", "debug this nil pointer error"},
{"refactor code", "refactor this function"},
{"add test", "add unit tests for handler"},
{"optimize query", "optimize this database query"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyQwenTask(tt.query, ModeBuildContext)
t.Logf("%s: %v", tt.query, got)
})
}
}
func TestQwenRouter_ClassifyAdvanced(t *testing.T) {
tests := []struct {
name string
query string
}{
{"architecture", "design microservice architecture"},
{"system design", "system design for high traffic"},
{"security audit", "security audit of api"},
{"full stack", "build a full stack application"},
{"migration", "migration from mysql to postgres"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyQwenTask(tt.query, ModeBuildContext)
t.Logf("%s: %v", tt.query, got)
})
}
}
func TestQwenRouter_ModeAffectsClassification(t *testing.T) {
query := "how do i fix this bug"
ask := classifyQwenTask(query, ModeAskContext)
build := classifyQwenTask(query, ModeBuildContext)
// BUILD mode should generally prefer equal or larger models than ASK
// Note: This is a soft requirement - the mode adjustment is subtle
t.Logf("ASK mode: %v, BUILD mode: %v", ask, build)
}
func TestQwenRouter_WordCountAffectsClassification(t *testing.T) {
short := "what is go"
long := "what is the go programming language and how does it compare to rust and what are its main features and use cases in modern software development"
shortComplexity := classifyQwenTask(short, ModeAskContext)
longComplexity := classifyQwenTask(long, ModeAskContext)
// Long query should ideally be more complex, but at minimum not less
// Note: This test documents the behavior - word count does affect scoring
t.Logf("short (%d chars): %v, long (%d chars): %v", len(short), shortComplexity, len(long), longComplexity)
}
func TestQwenRouter_CodePatterns(t *testing.T) {
tests := []struct {
name string
query string
maxComplexity QwenComplexity
}{
{"simple variable", "declare a variable", QwenModerate},
{"simple function", "write a function", QwenAdvanced},
{"moderate struct", "define a struct", QwenAdvanced},
{"moderate interface", "implement an interface", QwenAdvanced},
{"moderate concurrency", "add concurrency with goroutines", QwenAdvanced},
{"advanced architecture", "design the architecture", QwenAdvanced},
{"advanced distributed", "distributed system design", QwenAdvanced},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyQwenTask(tt.query, ModeBuildContext)
// All code patterns should classify as something (not panic)
t.Logf("%s: %v", tt.query, got)
})
}
}
func TestQwenRouter_SelectAskModel(t *testing.T) {
cfg := DefaultModelConfig()
router := NewQwenModelRouter(&cfg)
router.SetModeContext(ModeAskContext)
// Simple question should get small model
model := router.SelectModelForMode("what is go", ModeAskContext)
if model != "qwen3.5:0.8b" && model != "qwen3.5:2b" {
t.Errorf("ASK mode simple query should get small model, got %s", model)
}
// Complex question should get capable model (2B or higher)
model = router.SelectModelForMode("design a distributed system", ModeAskContext)
if model == "qwen3.5:0.8b" {
t.Errorf("ASK mode complex query should not get 0.8B model, got %s", model)
}
}
func TestQwenRouter_SelectPlanModel(t *testing.T) {
cfg := DefaultModelConfig()
router := NewQwenModelRouter(&cfg)
router.SetModeContext(ModePlanContext)
// Planning should prefer 4B for reasoning
model := router.SelectModelForMode("plan the architecture", ModePlanContext)
if model != "qwen3.5:4b" && model != "qwen3.5:9b" {
t.Errorf("PLAN mode should prefer 4B or 9B, got %s", model)
}
}
func TestQwenRouter_SelectBuildModel(t *testing.T) {
cfg := DefaultModelConfig()
router := NewQwenModelRouter(&cfg)
router.SetModeContext(ModeBuildContext)
// Building should prefer capable models
model := router.SelectModelForMode("implement the feature", ModeBuildContext)
if model != "qwen3.5:4b" && model != "qwen3.5:9b" {
t.Errorf("BUILD mode should prefer 4B or 9B, got %s", model)
}
}
func TestQwenRouter_GetRecommendedModel(t *testing.T) {
cfg := DefaultModelConfig()
router := NewQwenModelRouter(&cfg)
model, reason, complexity := router.GetRecommendedModel("what is go")
if model == "" {
t.Error("GetRecommendedModel should return a model")
}
if reason == "" {
t.Error("GetRecommendedModel should return a reason")
}
if complexity == "" {
t.Error("GetRecommendedModel should return a complexity")
}
}
func TestQwenRouter_QuestionMarkHandling(t *testing.T) {
// Short questions with ? should be simpler
short := "what is go?"
long := "can you explain what the go programming language is and how it works?"
shortComplexity := classifyQwenTask(short, ModeAskContext)
longComplexity := classifyQwenTask(long, ModeAskContext)
if shortComplexity >= longComplexity {
t.Logf("Note: short question complexity (%v) vs long (%v)", shortComplexity, longComplexity)
}
}
func TestQwenRouter_WhyQuestions(t *testing.T) {
// Why questions need reasoning
why := "why does this code fail"
what := "what does this code do"
whyComplexity := classifyQwenTask(why, ModeAskContext)
whatComplexity := classifyQwenTask(what, ModeAskContext)
if whyComplexity < whatComplexity {
t.Errorf("why questions should be more complex: why=%v, what=%v", whyComplexity, whatComplexity)
}
}
func BenchmarkQwenRouter_ClassifyTask(b *testing.B) {
queries := []string{
"what is go",
"how do i create a file",
"debug this nil pointer error",
"design microservice architecture",
}
for i := 0; i < b.N; i++ {
for _, q := range queries {
_ = classifyQwenTask(q, ModeAskContext)
}
}
}
func BenchmarkQwenRouter_SelectModel(b *testing.B) {
cfg := DefaultModelConfig()
router := NewQwenModelRouter(&cfg)
queries := []string{
"what is go",
"how do i create a file",
"debug this nil pointer error",
"design microservice architecture",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, q := range queries {
_ = router.SelectModel(q)
}
}
}
+318
View File
@@ -0,0 +1,318 @@
package config
import (
"context"
"strings"
"sync"
"time"
)
type TaskComplexity string
const (
ComplexitySimple TaskComplexity = "simple"
ComplexityMedium TaskComplexity = "medium"
ComplexityComplex TaskComplexity = "complex"
ComplexityAdvanced TaskComplexity = "advanced"
)
var simpleIndicators = []string{
"what is", "how do i", "explain", "what does",
"find", "search", "list", "show", "get",
"print", "echo", "read", "cat", "ls",
"simple", "quick", "fast",
}
var mediumIndicators = []string{
"create", "write", "generate", "add", "modify",
"change", "update", "fix", "refactor",
"function", "class", "variable", "test",
"script", "command", "file", "directory",
}
var complexIndicators = []string{
"debug", "error", "bug", "issue", "problem",
"refactor", "architecture", "design", "review",
"multiple", "several", "across", "migrate",
"optimize", "performance", "security",
"explain why", "analyze", "compare",
}
var advancedIndicators = []string{
"build a", "create a", "implement", "develop",
"full stack", "system", "infrastructure",
"multi-step", "complex", "comprehensive",
"security audit", "architecture design",
}
// ModelPinger is an interface for checking if a model is available.
type ModelPinger interface {
PingModel(ctx context.Context, model string) error
}
// ModelOverride records when a user explicitly selects a model.
type ModelOverride struct {
Query string
UserModel string
RouterModel string
Timestamp time.Time
}
type Router struct {
config *ModelConfig
overrideLog []ModelOverride
mu sync.RWMutex
}
func NewRouter(cfg *ModelConfig) *Router {
return &Router{
config: cfg,
overrideLog: make([]ModelOverride, 0),
}
}
func (r *Router) ClassifyTaskComplexity(query string) TaskComplexity {
return ClassifyTask(query)
}
func (r *Router) SelectModel(query string) string {
complexity := r.ClassifyTaskComplexity(query)
// Check learned patterns if we have enough data
wordComplexity := r.getLearnedPatterns()
if len(wordComplexity) > 0 {
words := strings.Fields(strings.ToLower(query))
// Count votes from learned patterns
complexityVotes := make(map[TaskComplexity]int)
for _, w := range words {
if len(w) >= 3 { // Skip short words
if c, ok := wordComplexity[w]; ok {
complexityVotes[c]++
}
}
}
// If strong learned signal (>30% words match a pattern), use it
if len(words) > 0 {
matchRatio := float64(complexityVotes[ComplexitySimple]+complexityVotes[ComplexityAdvanced]) / float64(len(words))
if matchRatio > 0.3 {
if complexityVotes[ComplexityAdvanced] > complexityVotes[ComplexitySimple] {
complexity = ComplexityAdvanced
} else if complexityVotes[ComplexitySimple] > complexityVotes[ComplexityAdvanced] {
complexity = ComplexitySimple
}
}
}
}
return r.config.SelectModelForTask(string(complexity))
}
// RecordOverride logs when a user explicitly selects a model.
// This helps the router learn from user preferences.
func (r *Router) RecordOverride(query, userModel string) {
r.mu.Lock()
defer r.mu.Unlock()
routerModel := r.SelectModel(query)
r.overrideLog = append(r.overrideLog, ModelOverride{
Query: query,
UserModel: userModel,
RouterModel: routerModel,
Timestamp: time.Now(),
})
// Keep last 100 overrides
if len(r.overrideLog) > 100 {
r.overrideLog = r.overrideLog[len(r.overrideLog)-100:]
}
}
// getLearnedPatterns analyzes override history to find word->complexity mappings.
func (r *Router) getLearnedPatterns() map[string]TaskComplexity {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.overrideLog) < 3 {
return nil // Not enough data
}
wordCounts := make(map[string]map[TaskComplexity]int)
for _, o := range r.overrideLog {
if o.Query == "" || o.UserModel == "" {
continue
}
// Determine complexity from user-selected model
var complexity TaskComplexity
switch {
case strings.Contains(o.UserModel, "0.8") || strings.Contains(o.UserModel, "2b"):
complexity = ComplexitySimple
case strings.Contains(o.UserModel, "4b"):
complexity = ComplexityMedium
case strings.Contains(o.UserModel, "9b"):
complexity = ComplexityAdvanced
default:
continue
}
words := strings.Fields(strings.ToLower(o.Query))
for _, w := range words {
if len(w) < 3 {
continue // Skip short words
}
if _, ok := wordCounts[w]; !ok {
wordCounts[w] = make(map[TaskComplexity]int)
}
wordCounts[w][complexity]++
}
}
// For each word, find dominant complexity
wordComplexity := make(map[string]TaskComplexity)
for word, counts := range wordCounts {
var maxCount int
var dominant TaskComplexity
for c, cnt := range counts {
if cnt > maxCount {
maxCount = cnt
dominant = c
}
}
// Only use if we have enough samples (at least 2 overrides)
if maxCount >= 2 {
wordComplexity[word] = dominant
}
}
return wordComplexity
}
func (r *Router) GetFallbackChain(currentModel string) []string {
chain := r.config.FallbackChain
for i, model := range chain {
if model == currentModel {
return chain[i:]
}
}
return chain
}
func (r *Router) GetModelForCapability(capability ModelCapability) string {
for _, m := range r.config.Models {
if m.Capability == capability {
return m.Name
}
}
return r.config.DefaultModel
}
// SelectAvailableModel returns the first available model from the fallback chain.
// It checks each model in order and returns the first one that responds to a ping.
// If no models are available, returns the default model.
func (r *Router) SelectAvailableModel(ctx context.Context, pinger ModelPinger) string {
chain := r.config.FallbackChain
for _, model := range chain {
if err := pinger.PingModel(ctx, model); err == nil {
return model
}
}
// Fallback to default if none available
return r.config.DefaultModel
}
// SelectAvailableModelForTask returns the first available model for the given task complexity.
// It prioritizes models appropriate for the task, then falls back to larger models if unavailable.
func (r *Router) SelectAvailableModelForTask(ctx context.Context, pinger ModelPinger, query string) string {
// First, get the preferred model for this task
preferred := r.SelectModel(query)
// Check if preferred model is available
if err := pinger.PingModel(ctx, preferred); err == nil {
return preferred
}
// Try fallback chain
chain := r.GetFallbackChain(preferred)
for _, model := range chain {
if err := pinger.PingModel(ctx, model); err == nil {
return model
}
}
// Last resort: default model
return r.config.DefaultModel
}
func (r *Router) ForceModel(name string) (*Model, error) {
return r.config.GetModel(name)
}
func (r *Router) ListModels() []Model {
return r.config.Models
}
func (r *Router) GetDefaultModel() string {
return r.config.DefaultModel
}
func ClassifyTask(query string) TaskComplexity {
lowerQuery := strings.ToLower(query)
wordCount := len(strings.Fields(query))
score := 0
for _, indicator := range simpleIndicators {
if strings.Contains(lowerQuery, indicator) {
score -= 2
}
}
for _, indicator := range mediumIndicators {
if strings.Contains(lowerQuery, indicator) {
score += 1
}
}
for _, indicator := range complexIndicators {
if strings.Contains(lowerQuery, indicator) {
score += 2
}
}
for _, indicator := range advancedIndicators {
if strings.Contains(lowerQuery, indicator) {
score += 3
}
}
if wordCount > 50 {
score += 2
}
if strings.Contains(lowerQuery, "why") || strings.Contains(lowerQuery, "reason") {
score += 1
}
if strings.Contains(lowerQuery, "how") && wordCount > 10 {
score += 1
}
switch {
case score <= -2:
return ComplexitySimple
case score <= 1:
return ComplexityMedium
case score <= 4:
return ComplexityComplex
default:
return ComplexityAdvanced
}
}
+166
View File
@@ -0,0 +1,166 @@
package config
import (
"strings"
"testing"
)
func TestClassifyTask(t *testing.T) {
tests := []struct {
name string
query string
want TaskComplexity
}{
{name: "empty query", query: "", want: ComplexityMedium},
{name: "simple what is", query: "what is Go", want: ComplexitySimple},
// "create a function": medium "create" +1, "function" +1, advanced "create a" +3 = 5 → advanced
{name: "create a function is advanced due to overlaps", query: "create a function", want: ComplexityAdvanced},
// "debug this error across multiple files": complex "debug" +2, "error" +2, "bug" +2 (substring of debug),
// "multiple" +2, "across" +2 = 10, medium "file" +1 = 11 → advanced
{name: "debug across files is advanced", query: "debug this error across multiple files", want: ComplexityAdvanced},
// "implement a full stack system with infrastructure": advanced "implement" +3, "full stack" +3, "system" +3,
// "infrastructure" +3 = 12 → advanced
{name: "advanced full stack system", query: "implement a full stack system with infrastructure", want: ComplexityAdvanced},
// Boundary: "explain" → simple -2 → score -2 → simple
{name: "boundary simple score -2", query: "explain", want: ComplexitySimple},
// No indicators → score 0 → medium
{name: "boundary medium score 0", query: "hello world", want: ComplexityMedium},
// "create" → medium +1, but also matches advanced "create a"? No, "create" doesn't contain "create a".
// So just +1 → medium
{name: "boundary medium score 1", query: "create", want: ComplexityMedium},
// "debug" alone: complex "debug" +2, "bug" +2 (substring) = 4 → complex
{name: "debug alone is complex", query: "debug", want: ComplexityComplex},
// "debug error": "debug" +2, "error" +2, "bug" +2 (substring of debug) = 6 → advanced
{name: "debug error is advanced", query: "debug error", want: ComplexityAdvanced},
// Word count >50 bonus (+2) with "debug": "debug" +2, "bug" +2 = 4, +2 word bonus = 6 → advanced
{
name: "word count bonus over 50 with debug",
query: strings.Repeat("word ", 51) + "debug",
want: ComplexityAdvanced,
},
// "why does this happen": "why" +1 = 1 → medium
{name: "why bonus", query: "why does this happen", want: ComplexityMedium},
// "reason for the crash": "reason" +1 = 1 → medium
{name: "reason bonus", query: "reason for the crash", want: ComplexityMedium},
// "how about we think...": no indicators, "how" + >10 words +1 = 1 → medium
{name: "how with many words", query: "how about we think about the things that are happening right now in the code base", want: ComplexityMedium},
// Case insensitivity
{name: "case insensitive WHAT IS", query: "WHAT IS Go", want: ComplexitySimple},
{name: "case insensitive EXPLAIN", query: "EXPLAIN this code", want: ComplexitySimple},
// Pure simple: multiple simple indicators
{name: "multiple simple indicators", query: "what is this simple quick search", want: ComplexitySimple},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ClassifyTask(tt.query)
if got != tt.want {
t.Errorf("ClassifyTask(%q) = %q, want %q", tt.query, got, tt.want)
}
})
}
}
func TestRouter_GetFallbackChain(t *testing.T) {
cfg := &ModelConfig{
FallbackChain: []string{"a", "b", "c", "d"},
}
r := NewRouter(cfg)
tests := []struct {
name string
model string
wantLen int
wantAll bool // true means expect full chain
}{
{name: "found at start", model: "a", wantLen: 4},
{name: "found in middle", model: "c", wantLen: 2},
{name: "found at end", model: "d", wantLen: 1},
{name: "not found returns full chain", model: "unknown", wantLen: 4, wantAll: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := r.GetFallbackChain(tt.model)
if len(got) != tt.wantLen {
t.Errorf("GetFallbackChain(%q) returned %d items, want %d", tt.model, len(got), tt.wantLen)
}
if tt.wantAll && got[0] != "a" {
t.Errorf("GetFallbackChain(%q) first element = %q, want %q", tt.model, got[0], "a")
}
})
}
}
func TestRouter_GetModelForCapability(t *testing.T) {
cfg := &ModelConfig{
Models: []Model{
{Name: "fast", Capability: CapabilitySimple},
{Name: "mid", Capability: CapabilityMedium},
{Name: "big", Capability: CapabilityComplex},
},
DefaultModel: "fallback",
}
r := NewRouter(cfg)
tests := []struct {
name string
capability ModelCapability
want string
}{
{name: "match simple", capability: CapabilitySimple, want: "fast"},
{name: "match medium", capability: CapabilityMedium, want: "mid"},
{name: "match complex", capability: CapabilityComplex, want: "big"},
{name: "no match returns default", capability: CapabilityAdvanced, want: "fallback"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := r.GetModelForCapability(tt.capability)
if got != tt.want {
t.Errorf("GetModelForCapability(%d) = %q, want %q", tt.capability, got, tt.want)
}
})
}
}
func TestRouter_SelectModel(t *testing.T) {
cfg := DefaultModelConfig()
r := NewRouter(&cfg)
tests := []struct {
name string
query string
want string
}{
// "what is Go" → simple → first model
{name: "simple query selects first model", query: "what is Go", want: cfg.Models[0].Name},
// "debug" → complex → complex-capable model
{name: "complex query selects complex model", query: "debug", want: "qwen3.5:4b"},
// "implement a system" → advanced → DefaultModel
{name: "advanced query selects default model", query: "implement a full stack system", want: cfg.DefaultModel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := r.SelectModel(tt.query)
if got != tt.want {
t.Errorf("SelectModel(%q) = %q, want %q", tt.query, got, tt.want)
}
})
}
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+58
View File
@@ -0,0 +1,58 @@
-- Sessions table: replaces noted CLI dependency for session persistence.
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'BUILD',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Session messages: individual chat entries within a session.
CREATE TABLE IF NOT EXISTS session_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'user', 'assistant', 'tool', 'system', 'error'
content TEXT NOT NULL DEFAULT '',
tool_name TEXT NOT NULL DEFAULT '',
tool_args TEXT NOT NULL DEFAULT '',
is_error INTEGER NOT NULL DEFAULT 0,
thinking TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_session_messages_session_id ON session_messages(session_id);
-- Tool permissions: per-tool allow/deny/always-allow.
CREATE TABLE IF NOT EXISTS tool_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_name TEXT NOT NULL UNIQUE,
policy TEXT NOT NULL DEFAULT 'ask', -- 'allow', 'deny', 'ask'
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Token usage stats: per-turn tracking.
CREATE TABLE IF NOT EXISTS token_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
turn INTEGER NOT NULL DEFAULT 0,
eval_count INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_token_stats_session_id ON token_stats(session_id);
-- File changes: files modified by agent during a session.
CREATE TABLE IF NOT EXISTS file_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
tool_name TEXT NOT NULL DEFAULT '',
added INTEGER NOT NULL DEFAULT 0,
removed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_file_changes_session_id ON file_changes(session_id);
+53
View File
@@ -0,0 +1,53 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
type FileChange struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
FilePath string `json:"file_path"`
ToolName string `json:"tool_name"`
Added int64 `json:"added"`
Removed int64 `json:"removed"`
CreatedAt string `json:"created_at"`
}
type Session struct {
ID int64 `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Mode string `json:"mode"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SessionMessage struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
ToolName string `json:"tool_name"`
ToolArgs string `json:"tool_args"`
IsError int64 `json:"is_error"`
Thinking string `json:"thinking"`
CreatedAt string `json:"created_at"`
}
type TokenStat struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
Turn int64 `json:"turn"`
EvalCount int64 `json:"eval_count"`
PromptTokens int64 `json:"prompt_tokens"`
Model string `json:"model"`
CreatedAt string `json:"created_at"`
}
type ToolPermission struct {
ID int64 `json:"id"`
ToolName string `json:"tool_name"`
Policy string `json:"policy"`
UpdatedAt string `json:"updated_at"`
}
+100
View File
@@ -0,0 +1,100 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: permissions.sql
package db
import (
"context"
)
const deleteToolPermission = `-- name: DeleteToolPermission :exec
DELETE FROM tool_permissions WHERE tool_name = ?
`
func (q *Queries) DeleteToolPermission(ctx context.Context, toolName string) error {
_, err := q.db.ExecContext(ctx, deleteToolPermission, toolName)
return err
}
const getToolPermission = `-- name: GetToolPermission :one
SELECT id, tool_name, policy, updated_at FROM tool_permissions WHERE tool_name = ?
`
func (q *Queries) GetToolPermission(ctx context.Context, toolName string) (ToolPermission, error) {
row := q.db.QueryRowContext(ctx, getToolPermission, toolName)
var i ToolPermission
err := row.Scan(
&i.ID,
&i.ToolName,
&i.Policy,
&i.UpdatedAt,
)
return i, err
}
const listToolPermissions = `-- name: ListToolPermissions :many
SELECT id, tool_name, policy, updated_at FROM tool_permissions ORDER BY tool_name ASC
`
func (q *Queries) ListToolPermissions(ctx context.Context) ([]ToolPermission, error) {
rows, err := q.db.QueryContext(ctx, listToolPermissions)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ToolPermission{}
for rows.Next() {
var i ToolPermission
if err := rows.Scan(
&i.ID,
&i.ToolName,
&i.Policy,
&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 resetToolPermissions = `-- name: ResetToolPermissions :exec
DELETE FROM tool_permissions
`
func (q *Queries) ResetToolPermissions(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, resetToolPermissions)
return err
}
const upsertToolPermission = `-- name: UpsertToolPermission :one
INSERT INTO tool_permissions (tool_name, policy)
VALUES (?, ?)
ON CONFLICT(tool_name) DO UPDATE SET policy = excluded.policy, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
RETURNING id, tool_name, policy, updated_at
`
type UpsertToolPermissionParams struct {
ToolName string `json:"tool_name"`
Policy string `json:"policy"`
}
func (q *Queries) UpsertToolPermission(ctx context.Context, arg UpsertToolPermissionParams) (ToolPermission, error) {
row := q.db.QueryRowContext(ctx, upsertToolPermission, arg.ToolName, arg.Policy)
var i ToolPermission
err := row.Scan(
&i.ID,
&i.ToolName,
&i.Policy,
&i.UpdatedAt,
)
return i, err
}
+17
View File
@@ -0,0 +1,17 @@
-- name: GetToolPermission :one
SELECT * FROM tool_permissions WHERE tool_name = ?;
-- name: UpsertToolPermission :one
INSERT INTO tool_permissions (tool_name, policy)
VALUES (?, ?)
ON CONFLICT(tool_name) DO UPDATE SET policy = excluded.policy, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
RETURNING *;
-- name: ListToolPermissions :many
SELECT * FROM tool_permissions ORDER BY tool_name ASC;
-- name: DeleteToolPermission :exec
DELETE FROM tool_permissions WHERE tool_name = ?;
-- name: ResetToolPermissions :exec
DELETE FROM tool_permissions;
+27
View File
@@ -0,0 +1,27 @@
-- name: CreateSession :one
INSERT INTO sessions (title, model, mode) VALUES (?, ?, ?) RETURNING *;
-- name: GetSession :one
SELECT * FROM sessions WHERE id = ?;
-- name: ListSessions :many
SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?;
-- name: UpdateSessionTitle :exec
UPDATE sessions SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?;
-- name: UpdateSessionTimestamp :exec
UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?;
-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = ?;
-- name: CreateSessionMessage :one
INSERT INTO session_messages (session_id, role, content, tool_name, tool_args, is_error, thinking)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *;
-- name: GetSessionMessages :many
SELECT * FROM session_messages WHERE session_id = ? ORDER BY id ASC;
-- name: CountSessions :one
SELECT COUNT(*) FROM sessions;
+31
View File
@@ -0,0 +1,31 @@
-- name: RecordTokenUsage :one
INSERT INTO token_stats (session_id, turn, eval_count, prompt_tokens, model)
VALUES (?, ?, ?, ?, ?) RETURNING *;
-- name: GetSessionTokenStats :many
SELECT * FROM token_stats WHERE session_id = ? ORDER BY turn ASC;
-- name: GetSessionTotalTokens :one
SELECT
CAST(COALESCE(SUM(eval_count), 0) AS INTEGER) AS total_eval,
CAST(COALESCE(SUM(prompt_tokens), 0) AS INTEGER) AS total_prompt,
CAST(COUNT(*) AS INTEGER) AS turn_count
FROM token_stats WHERE session_id = ?;
-- name: RecordFileChange :one
INSERT INTO file_changes (session_id, file_path, tool_name, added, removed)
VALUES (?, ?, ?, ?, ?) RETURNING *;
-- name: GetSessionFileChanges :many
SELECT * FROM file_changes WHERE session_id = ? ORDER BY created_at ASC;
-- name: GetSessionFileChangeSummary :many
SELECT
file_path,
CAST(COALESCE(SUM(added), 0) AS INTEGER) AS total_added,
CAST(COALESCE(SUM(removed), 0) AS INTEGER) AS total_removed,
CAST(COUNT(*) AS INTEGER) AS change_count
FROM file_changes
WHERE session_id = ?
GROUP BY file_path
ORDER BY file_path ASC;
+206
View File
@@ -0,0 +1,206 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sessions.sql
package db
import (
"context"
)
const countSessions = `-- name: CountSessions :one
SELECT COUNT(*) FROM sessions
`
func (q *Queries) CountSessions(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countSessions)
var count int64
err := row.Scan(&count)
return count, err
}
const createSession = `-- name: CreateSession :one
INSERT INTO sessions (title, model, mode) VALUES (?, ?, ?) RETURNING id, title, model, mode, created_at, updated_at
`
type CreateSessionParams struct {
Title string `json:"title"`
Model string `json:"model"`
Mode string `json:"mode"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession, arg.Title, arg.Model, arg.Mode)
var i Session
err := row.Scan(
&i.ID,
&i.Title,
&i.Model,
&i.Mode,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createSessionMessage = `-- name: CreateSessionMessage :one
INSERT INTO session_messages (session_id, role, content, tool_name, tool_args, is_error, thinking)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, session_id, role, content, tool_name, tool_args, is_error, thinking, created_at
`
type CreateSessionMessageParams struct {
SessionID int64 `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
ToolName string `json:"tool_name"`
ToolArgs string `json:"tool_args"`
IsError int64 `json:"is_error"`
Thinking string `json:"thinking"`
}
func (q *Queries) CreateSessionMessage(ctx context.Context, arg CreateSessionMessageParams) (SessionMessage, error) {
row := q.db.QueryRowContext(ctx, createSessionMessage,
arg.SessionID,
arg.Role,
arg.Content,
arg.ToolName,
arg.ToolArgs,
arg.IsError,
arg.Thinking,
)
var i SessionMessage
err := row.Scan(
&i.ID,
&i.SessionID,
&i.Role,
&i.Content,
&i.ToolName,
&i.ToolArgs,
&i.IsError,
&i.Thinking,
&i.CreatedAt,
)
return i, err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = ?
`
func (q *Queries) DeleteSession(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteSession, id)
return err
}
const getSession = `-- name: GetSession :one
SELECT id, title, model, mode, created_at, updated_at FROM sessions WHERE id = ?
`
func (q *Queries) GetSession(ctx context.Context, id int64) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, id)
var i Session
err := row.Scan(
&i.ID,
&i.Title,
&i.Model,
&i.Mode,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSessionMessages = `-- name: GetSessionMessages :many
SELECT id, session_id, role, content, tool_name, tool_args, is_error, thinking, created_at FROM session_messages WHERE session_id = ? ORDER BY id ASC
`
func (q *Queries) GetSessionMessages(ctx context.Context, sessionID int64) ([]SessionMessage, error) {
rows, err := q.db.QueryContext(ctx, getSessionMessages, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SessionMessage{}
for rows.Next() {
var i SessionMessage
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Role,
&i.Content,
&i.ToolName,
&i.ToolArgs,
&i.IsError,
&i.Thinking,
&i.CreatedAt,
); 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 listSessions = `-- name: ListSessions :many
SELECT id, title, model, mode, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?
`
func (q *Queries) ListSessions(ctx context.Context, limit int64) ([]Session, error) {
rows, err := q.db.QueryContext(ctx, listSessions, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Session{}
for rows.Next() {
var i Session
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Model,
&i.Mode,
&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 updateSessionTimestamp = `-- name: UpdateSessionTimestamp :exec
UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?
`
func (q *Queries) UpdateSessionTimestamp(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, updateSessionTimestamp, id)
return err
}
const updateSessionTitle = `-- name: UpdateSessionTitle :exec
UPDATE sessions SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?
`
type UpdateSessionTitleParams struct {
Title string `json:"title"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSessionTitle(ctx context.Context, arg UpdateSessionTitleParams) error {
_, err := q.db.ExecContext(ctx, updateSessionTitle, arg.Title, arg.ID)
return err
}
+11
View File
@@ -0,0 +1,11 @@
version: "2"
sql:
- engine: "sqlite"
queries: "queries"
schema: "migrations"
gen:
go:
package: "db"
out: "."
emit_json_tags: true
emit_empty_slices: true
+216
View File
@@ -0,0 +1,216 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: stats.sql
package db
import (
"context"
)
const getSessionFileChangeSummary = `-- name: GetSessionFileChangeSummary :many
SELECT
file_path,
CAST(COALESCE(SUM(added), 0) AS INTEGER) AS total_added,
CAST(COALESCE(SUM(removed), 0) AS INTEGER) AS total_removed,
CAST(COUNT(*) AS INTEGER) AS change_count
FROM file_changes
WHERE session_id = ?
GROUP BY file_path
ORDER BY file_path ASC
`
type GetSessionFileChangeSummaryRow struct {
FilePath string `json:"file_path"`
TotalAdded int64 `json:"total_added"`
TotalRemoved int64 `json:"total_removed"`
ChangeCount int64 `json:"change_count"`
}
func (q *Queries) GetSessionFileChangeSummary(ctx context.Context, sessionID int64) ([]GetSessionFileChangeSummaryRow, error) {
rows, err := q.db.QueryContext(ctx, getSessionFileChangeSummary, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetSessionFileChangeSummaryRow{}
for rows.Next() {
var i GetSessionFileChangeSummaryRow
if err := rows.Scan(
&i.FilePath,
&i.TotalAdded,
&i.TotalRemoved,
&i.ChangeCount,
); 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 getSessionFileChanges = `-- name: GetSessionFileChanges :many
SELECT id, session_id, file_path, tool_name, added, removed, created_at FROM file_changes WHERE session_id = ? ORDER BY created_at ASC
`
func (q *Queries) GetSessionFileChanges(ctx context.Context, sessionID int64) ([]FileChange, error) {
rows, err := q.db.QueryContext(ctx, getSessionFileChanges, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []FileChange{}
for rows.Next() {
var i FileChange
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.FilePath,
&i.ToolName,
&i.Added,
&i.Removed,
&i.CreatedAt,
); 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 getSessionTokenStats = `-- name: GetSessionTokenStats :many
SELECT id, session_id, turn, eval_count, prompt_tokens, model, created_at FROM token_stats WHERE session_id = ? ORDER BY turn ASC
`
func (q *Queries) GetSessionTokenStats(ctx context.Context, sessionID int64) ([]TokenStat, error) {
rows, err := q.db.QueryContext(ctx, getSessionTokenStats, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TokenStat{}
for rows.Next() {
var i TokenStat
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Turn,
&i.EvalCount,
&i.PromptTokens,
&i.Model,
&i.CreatedAt,
); 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 getSessionTotalTokens = `-- name: GetSessionTotalTokens :one
SELECT
CAST(COALESCE(SUM(eval_count), 0) AS INTEGER) AS total_eval,
CAST(COALESCE(SUM(prompt_tokens), 0) AS INTEGER) AS total_prompt,
CAST(COUNT(*) AS INTEGER) AS turn_count
FROM token_stats WHERE session_id = ?
`
type GetSessionTotalTokensRow struct {
TotalEval int64 `json:"total_eval"`
TotalPrompt int64 `json:"total_prompt"`
TurnCount int64 `json:"turn_count"`
}
func (q *Queries) GetSessionTotalTokens(ctx context.Context, sessionID int64) (GetSessionTotalTokensRow, error) {
row := q.db.QueryRowContext(ctx, getSessionTotalTokens, sessionID)
var i GetSessionTotalTokensRow
err := row.Scan(&i.TotalEval, &i.TotalPrompt, &i.TurnCount)
return i, err
}
const recordFileChange = `-- name: RecordFileChange :one
INSERT INTO file_changes (session_id, file_path, tool_name, added, removed)
VALUES (?, ?, ?, ?, ?) RETURNING id, session_id, file_path, tool_name, added, removed, created_at
`
type RecordFileChangeParams struct {
SessionID int64 `json:"session_id"`
FilePath string `json:"file_path"`
ToolName string `json:"tool_name"`
Added int64 `json:"added"`
Removed int64 `json:"removed"`
}
func (q *Queries) RecordFileChange(ctx context.Context, arg RecordFileChangeParams) (FileChange, error) {
row := q.db.QueryRowContext(ctx, recordFileChange,
arg.SessionID,
arg.FilePath,
arg.ToolName,
arg.Added,
arg.Removed,
)
var i FileChange
err := row.Scan(
&i.ID,
&i.SessionID,
&i.FilePath,
&i.ToolName,
&i.Added,
&i.Removed,
&i.CreatedAt,
)
return i, err
}
const recordTokenUsage = `-- name: RecordTokenUsage :one
INSERT INTO token_stats (session_id, turn, eval_count, prompt_tokens, model)
VALUES (?, ?, ?, ?, ?) RETURNING id, session_id, turn, eval_count, prompt_tokens, model, created_at
`
type RecordTokenUsageParams struct {
SessionID int64 `json:"session_id"`
Turn int64 `json:"turn"`
EvalCount int64 `json:"eval_count"`
PromptTokens int64 `json:"prompt_tokens"`
Model string `json:"model"`
}
func (q *Queries) RecordTokenUsage(ctx context.Context, arg RecordTokenUsageParams) (TokenStat, error) {
row := q.db.QueryRowContext(ctx, recordTokenUsage,
arg.SessionID,
arg.Turn,
arg.EvalCount,
arg.PromptTokens,
arg.Model,
)
var i TokenStat
err := row.Scan(
&i.ID,
&i.SessionID,
&i.Turn,
&i.EvalCount,
&i.PromptTokens,
&i.Model,
&i.CreatedAt,
)
return i, err
}
+71
View File
@@ -0,0 +1,71 @@
package db
import (
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrations embed.FS
type Store struct {
*Queries
db *sql.DB
}
func Open() (*Store, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("home dir: %w", err)
}
dir := filepath.Join(home, ".config", "ai-agent")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
return OpenPath(filepath.Join(dir, "ai-agent.db"))
}
func OpenPath(path string) (*Store, error) {
conn, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := runMigrations(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("migrations: %w", err)
}
return &Store{Queries: New(conn), db: conn}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) DB() *sql.DB {
return s.db
}
func runMigrations(conn *sql.DB) error {
entries, err := migrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := migrations.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
if _, err := conn.Exec(string(data)); err != nil {
return fmt.Errorf("exec migration %s: %w", entry.Name(), err)
}
}
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package db
import (
"context"
"os"
"path/filepath"
"testing"
)
func testStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
s, err := OpenPath(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenAndMigrate(t *testing.T) {
s := testStore(t)
// Verify the store is functional by counting sessions.
ctx := context.Background()
count, err := s.CountSessions(ctx)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 sessions, got %d", count)
}
}
func TestSessionCRUD(t *testing.T) {
s := testStore(t)
ctx := context.Background()
// Create.
sess, err := s.CreateSession(ctx, CreateSessionParams{
Title: "Test Session",
Model: "qwen3.5:4b",
Mode: "BUILD",
})
if err != nil {
t.Fatalf("create session: %v", err)
}
if sess.Title != "Test Session" {
t.Fatalf("expected title 'Test Session', got %q", sess.Title)
}
// Read.
got, err := s.GetSession(ctx, sess.ID)
if err != nil {
t.Fatalf("get session: %v", err)
}
if got.Model != "qwen3.5:4b" {
t.Fatalf("expected model 'qwen3.5:4b', got %q", got.Model)
}
// Create message.
msg, err := s.CreateSessionMessage(ctx, CreateSessionMessageParams{
SessionID: sess.ID,
Role: "user",
Content: "Hello world",
})
if err != nil {
t.Fatalf("create message: %v", err)
}
if msg.Content != "Hello world" {
t.Fatalf("expected content 'Hello world', got %q", msg.Content)
}
// List messages.
msgs, err := s.GetSessionMessages(ctx, sess.ID)
if err != nil {
t.Fatalf("get messages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
// Delete.
if err := s.DeleteSession(ctx, sess.ID); err != nil {
t.Fatalf("delete session: %v", err)
}
count, err := s.CountSessions(ctx)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 after delete, got %d", count)
}
}
func TestToolPermissions(t *testing.T) {
s := testStore(t)
ctx := context.Background()
// Upsert.
perm, err := s.UpsertToolPermission(ctx, UpsertToolPermissionParams{
ToolName: "bash",
Policy: "allow",
})
if err != nil {
t.Fatalf("upsert permission: %v", err)
}
if perm.Policy != "allow" {
t.Fatalf("expected policy 'allow', got %q", perm.Policy)
}
// Update via upsert.
perm2, err := s.UpsertToolPermission(ctx, UpsertToolPermissionParams{
ToolName: "bash",
Policy: "deny",
})
if err != nil {
t.Fatalf("upsert update: %v", err)
}
if perm2.Policy != "deny" {
t.Fatalf("expected policy 'deny', got %q", perm2.Policy)
}
// List.
perms, err := s.ListToolPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
if len(perms) != 1 {
t.Fatalf("expected 1 permission, got %d", len(perms))
}
// Reset.
if err := s.ResetToolPermissions(ctx); err != nil {
t.Fatalf("reset: %v", err)
}
perms, err = s.ListToolPermissions(ctx)
if err != nil {
t.Fatalf("list after reset: %v", err)
}
if len(perms) != 0 {
t.Fatalf("expected 0 after reset, got %d", len(perms))
}
}
func TestTokenStats(t *testing.T) {
s := testStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, CreateSessionParams{
Title: "Stats Test",
Model: "qwen3.5:4b",
Mode: "ASK",
})
if err != nil {
t.Fatalf("create session: %v", err)
}
// Record usage.
_, err = s.RecordTokenUsage(ctx, RecordTokenUsageParams{
SessionID: sess.ID,
Turn: 1,
EvalCount: 100,
PromptTokens: 500,
Model: "qwen3.5:4b",
})
if err != nil {
t.Fatalf("record usage: %v", err)
}
_, err = s.RecordTokenUsage(ctx, RecordTokenUsageParams{
SessionID: sess.ID,
Turn: 2,
EvalCount: 200,
PromptTokens: 600,
Model: "qwen3.5:4b",
})
if err != nil {
t.Fatalf("record usage 2: %v", err)
}
// Get totals.
totals, err := s.GetSessionTotalTokens(ctx, sess.ID)
if err != nil {
t.Fatalf("get totals: %v", err)
}
if totals.TotalEval != 300 {
t.Fatalf("expected total_eval 300, got %v", totals.TotalEval)
}
if totals.TotalPrompt != 1100 {
t.Fatalf("expected total_prompt 1100, got %v", totals.TotalPrompt)
}
if totals.TurnCount != 2 {
t.Fatalf("expected turn_count 2, got %v", totals.TurnCount)
}
}
func TestFileChanges(t *testing.T) {
s := testStore(t)
ctx := context.Background()
sess, err := s.CreateSession(ctx, CreateSessionParams{
Title: "Changes Test",
Model: "qwen3.5:4b",
Mode: "BUILD",
})
if err != nil {
t.Fatalf("create session: %v", err)
}
_, err = s.RecordFileChange(ctx, RecordFileChangeParams{
SessionID: sess.ID,
FilePath: "main.go",
ToolName: "write_file",
Added: 10,
Removed: 3,
})
if err != nil {
t.Fatalf("record change: %v", err)
}
_, err = s.RecordFileChange(ctx, RecordFileChangeParams{
SessionID: sess.ID,
FilePath: "main.go",
ToolName: "write_file",
Added: 5,
Removed: 2,
})
if err != nil {
t.Fatalf("record change 2: %v", err)
}
summary, err := s.GetSessionFileChangeSummary(ctx, sess.ID)
if err != nil {
t.Fatalf("get summary: %v", err)
}
if len(summary) != 1 {
t.Fatalf("expected 1 file, got %d", len(summary))
}
if summary[0].TotalAdded != 15 {
t.Fatalf("expected 15 added, got %d", summary[0].TotalAdded)
}
}
func TestDoubleOpen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
s1, err := OpenPath(path)
if err != nil {
t.Fatalf("first open: %v", err)
}
defer s1.Close()
// Running migrations again should be idempotent (IF NOT EXISTS).
s2, err := OpenPath(path)
if err != nil {
t.Fatalf("second open: %v", err)
}
defer s2.Close()
}
func TestOpenDefault(t *testing.T) {
if os.Getenv("CI") != "" {
t.Skip("skip in CI to avoid side effects")
}
s, err := Open()
if err != nil {
t.Fatalf("open default: %v", err)
}
s.Close()
}
+126
View File
@@ -0,0 +1,126 @@
package ice
import (
"context"
"fmt"
"strings"
"sync"
"ai-agent/internal/memory"
)
type Assembler struct {
embedder *Embedder
convStore *Store
memStore *memory.Store
budgetCfg BudgetConfig
sessionID string
}
func (a *Assembler) Assemble(ctx context.Context, query string) (string, error) {
budget := a.budgetCfg.Calculate(0)
type convResult struct {
chunks []ContextChunk
err error
}
type memResult struct {
chunks []ContextChunk
}
convCh := make(chan convResult, 1)
memCh := make(chan memResult, 1)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
chunks, err := a.retrieveConversations(ctx, query, budget.Conversation)
convCh <- convResult{chunks: chunks, err: err}
}()
go func() {
defer wg.Done()
chunks := a.retrieveMemories(query, budget.Memory)
memCh <- memResult{chunks: chunks}
}()
wg.Wait()
close(convCh)
close(memCh)
cr := <-convCh
mr := <-memCh
if cr.err != nil {
return "", fmt.Errorf("conversation retrieval: %w", cr.err)
}
return formatContext(cr.chunks, mr.chunks), nil
}
func (a *Assembler) retrieveConversations(ctx context.Context, query string, tokenBudget int) ([]ContextChunk, error) {
if tokenBudget <= 0 {
return nil, nil
}
queryEmb, err := a.embedder.Embed(ctx, query)
if err != nil {
return nil, err
}
results := a.convStore.Search(queryEmb, a.sessionID, 20)
var chunks []ContextChunk
usedTokens := 0
for _, r := range results {
tokens := estimateTokens(r.Entry.Content)
if usedTokens+tokens > tokenBudget {
continue
}
chunks = append(chunks, ContextChunk{
Source: SourceConversation,
Content: r.Entry.Content,
Score: r.Score,
Tokens: tokens,
})
usedTokens += tokens
}
return chunks, nil
}
func (a *Assembler) retrieveMemories(query string, tokenBudget int) []ContextChunk {
if a.memStore == nil || tokenBudget <= 0 {
return nil
}
memories := a.memStore.Recall(query, 10)
var chunks []ContextChunk
usedTokens := 0
for _, m := range memories {
tokens := estimateTokens(m.Content)
if usedTokens+tokens > tokenBudget {
continue
}
content := m.Content
if len(m.Tags) > 0 {
content += " [" + strings.Join(m.Tags, ", ") + "]"
}
chunks = append(chunks, ContextChunk{
Source: SourceMemory,
Content: content,
Tokens: tokens,
})
usedTokens += tokens
}
return chunks
}
func formatContext(convChunks, memChunks []ContextChunk) string {
var sb strings.Builder
if len(convChunks) > 0 {
sb.WriteString("\n## Relevant Past Conversations\n\n")
for _, c := range convChunks {
sb.WriteString("- ")
sb.WriteString(c.Content)
sb.WriteString("\n")
}
}
if len(memChunks) > 0 {
sb.WriteString("\n## Remembered Facts\n\n")
for _, c := range memChunks {
sb.WriteString("- ")
sb.WriteString(c.Content)
sb.WriteString("\n")
}
}
return sb.String()
}
+88
View File
@@ -0,0 +1,88 @@
package ice
import (
"strings"
"testing"
)
func TestFormatContext(t *testing.T) {
tests := []struct {
name string
convChunks []ContextChunk
memChunks []ContextChunk
wantConv bool // should contain "Relevant Past Conversations"
wantMem bool // should contain "Remembered Facts"
wantEmpty bool
}{
{
name: "both conversation and memory chunks",
convChunks: []ContextChunk{
{Source: SourceConversation, Content: "past chat about Go"},
},
memChunks: []ContextChunk{
{Source: SourceMemory, Content: "user prefers dark mode"},
},
wantConv: true,
wantMem: true,
},
{
name: "conversations only",
convChunks: []ContextChunk{
{Source: SourceConversation, Content: "previous discussion"},
},
memChunks: nil,
wantConv: true,
wantMem: false,
},
{
name: "memories only",
convChunks: nil,
memChunks: []ContextChunk{
{Source: SourceMemory, Content: "user name is Alice"},
},
wantConv: false,
wantMem: true,
},
{
name: "both empty",
convChunks: nil,
memChunks: nil,
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatContext(tt.convChunks, tt.memChunks)
if tt.wantEmpty {
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
return
}
hasConv := strings.Contains(got, "Relevant Past Conversations")
hasMem := strings.Contains(got, "Remembered Facts")
if hasConv != tt.wantConv {
t.Errorf("has conversations section = %v, want %v", hasConv, tt.wantConv)
}
if hasMem != tt.wantMem {
t.Errorf("has memories section = %v, want %v", hasMem, tt.wantMem)
}
// Verify content is present in output.
for _, c := range tt.convChunks {
if !strings.Contains(got, c.Content) {
t.Errorf("output missing conversation content %q", c.Content)
}
}
for _, c := range tt.memChunks {
if !strings.Contains(got, c.Content) {
t.Errorf("output missing memory content %q", c.Content)
}
}
})
}
}
+76
View File
@@ -0,0 +1,76 @@
package ice
import (
"context"
"fmt"
"strings"
"ai-agent/internal/llm"
"ai-agent/internal/memory"
)
var autoMemorySystemPrompt = "Extract any important facts, user preferences, decisions, or action items from this exchange.\n" +
"Output one item per line in the format: TYPE: content\n" +
"Where TYPE is one of: FACT, DECISION, PREFERENCE, TODO\n" +
"If there is nothing worth remembering, output exactly: NONE"
var autoMemoryUserTemplate = "User: %s\nAssistant: %s"
type AutoMemory struct {
client llm.Client
memStore *memory.Store
}
func (am *AutoMemory) Detect(ctx context.Context, userMsg, assistantMsg string) error {
if am.memStore == nil {
return nil
}
if len(userMsg) < 20 && len(assistantMsg) < 50 {
return nil
}
prompt := fmt.Sprintf(autoMemoryUserTemplate, userMsg, assistantMsg)
var response strings.Builder
err := am.client.ChatStream(ctx, llm.ChatOptions{
System: autoMemorySystemPrompt,
Messages: []llm.Message{
{Role: "user", Content: prompt},
},
}, func(chunk llm.StreamChunk) error {
response.WriteString(chunk.Text)
return nil
})
if err != nil {
return fmt.Errorf("auto-memory LLM call: %w", err)
}
return am.parseAndSave(response.String())
}
func (am *AutoMemory) parseAndSave(response string) error {
lines := strings.Split(strings.TrimSpace(response), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.EqualFold(line, "NONE") {
continue
}
parts := strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
continue
}
typeName := strings.TrimSpace(parts[0])
content := strings.TrimSpace(parts[1])
if content == "" {
continue
}
tag := strings.ToLower(typeName)
switch tag {
case "fact", "decision", "preference", "todo":
// Valid type.
default:
continue
}
if _, err := am.memStore.Save(content, []string{tag, "auto"}); err != nil {
return fmt.Errorf("save auto-memory: %w", err)
}
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package ice
import (
"path/filepath"
"testing"
"ai-agent/internal/memory"
)
func TestParseAndSave(t *testing.T) {
tests := []struct {
name string
input string
wantCount int
wantTags [][]string
}{
{
name: "valid FACT and DECISION lines",
input: "FACT: user likes Go\nDECISION: use postgres",
wantCount: 2,
wantTags: [][]string{{"fact", "auto"}, {"decision", "auto"}},
},
{
name: "NONE saves nothing",
input: "NONE",
wantCount: 0,
},
{
name: "empty lines are skipped",
input: "\n\n\n",
wantCount: 0,
},
{
name: "invalid type is skipped",
input: "UNKNOWN: something",
wantCount: 0,
},
{
name: "missing colon format is skipped",
input: "this has no colon",
wantCount: 0,
},
{
name: "PREFERENCE type",
input: "PREFERENCE: dark mode",
wantCount: 1,
wantTags: [][]string{{"preference", "auto"}},
},
{
name: "TODO type",
input: "TODO: fix the bug",
wantCount: 1,
wantTags: [][]string{{"todo", "auto"}},
},
{
name: "mixed valid and invalid",
input: "FACT: real fact\nBAD: not valid\nTODO: real todo",
wantCount: 2,
wantTags: [][]string{{"fact", "auto"}, {"todo", "auto"}},
},
{
name: "empty content after type is skipped",
input: "FACT: ",
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
memPath := filepath.Join(dir, "memories.json")
ms := memory.NewStore(memPath)
am := &AutoMemory{memStore: ms}
err := am.parseAndSave(tt.input)
if err != nil {
t.Fatalf("parseAndSave returned error: %v", err)
}
if ms.Count() != tt.wantCount {
t.Errorf("memory count = %d, want %d", ms.Count(), tt.wantCount)
}
if tt.wantTags != nil {
recent := ms.Recent(tt.wantCount)
for i, j := 0, len(recent)-1; i < j; i, j = i+1, j-1 {
recent[i], recent[j] = recent[j], recent[i]
}
for i, wantTags := range tt.wantTags {
if i >= len(recent) {
t.Errorf("missing memory at index %d", i)
continue
}
got := recent[i].Tags
if len(got) != len(wantTags) {
t.Errorf("memory[%d] tags = %v, want %v", i, got, wantTags)
continue
}
for j := range wantTags {
if got[j] != wantTags[j] {
t.Errorf("memory[%d] tag[%d] = %q, want %q", i, j, got[j], wantTags[j])
}
}
}
}
})
}
}
+54
View File
@@ -0,0 +1,54 @@
package ice
// BudgetConfig controls how the context window is divided among sources.
type BudgetConfig struct {
NumCtx int
SystemReserve int // tokens reserved for system prompt
RecentReserve int // tokens reserved for recent conversation
ConversationPct float64 // fraction of remaining budget for past conversations
MemoryPct float64 // fraction of remaining budget for memories
CodePct float64 // fraction of remaining budget for code context
}
// DefaultBudgetConfig returns sensible defaults for a given context window.
func DefaultBudgetConfig(numCtx int) BudgetConfig {
return BudgetConfig{
NumCtx: numCtx,
SystemReserve: 1500,
RecentReserve: 2000,
ConversationPct: 0.40,
MemoryPct: 0.20,
CodePct: 0.40,
}
}
// Calculate allocates token budgets given how many tokens the current prompt uses.
func (bc BudgetConfig) Calculate(promptTokens int) Budget {
// Use 75% of numCtx as total available.
available := int(float64(bc.NumCtx) * 0.75)
available -= bc.SystemReserve
available -= bc.RecentReserve
available -= promptTokens
if available < 0 {
available = 0
}
return Budget{
Total: available,
System: bc.SystemReserve,
Recent: bc.RecentReserve,
Conversation: int(float64(available) * bc.ConversationPct),
Memory: int(float64(available) * bc.MemoryPct),
Code: int(float64(available) * bc.CodePct),
}
}
// estimateTokens returns a rough token count for a string (chars / 4).
func estimateTokens(s string) int {
n := len(s) / 4
if n == 0 && len(s) > 0 {
n = 1
}
return n
}
+133
View File
@@ -0,0 +1,133 @@
package ice
import "testing"
func TestBudgetConfig_Calculate(t *testing.T) {
tests := []struct {
name string
cfg BudgetConfig
promptTokens int
wantTotal int
wantConv int
wantMemory int
wantCode int
}{
{
name: "normal allocation",
cfg: BudgetConfig{
NumCtx: 8192,
SystemReserve: 1500,
RecentReserve: 2000,
ConversationPct: 0.40,
MemoryPct: 0.20,
CodePct: 0.40,
},
promptTokens: 500,
// available = int(8192*0.75) - 1500 - 2000 - 500 = 6144 - 4000 = 2144
wantTotal: 2144,
wantConv: 857, // int(2144 * 0.40) = 857
wantMemory: 428, // int(2144 * 0.20) = 428
wantCode: 857, // int(2144 * 0.40) = 857
},
{
name: "large prompt clamps to zero",
cfg: BudgetConfig{
NumCtx: 8192,
SystemReserve: 1500,
RecentReserve: 2000,
ConversationPct: 0.40,
MemoryPct: 0.20,
CodePct: 0.40,
},
promptTokens: 99999,
wantTotal: 0,
wantConv: 0,
wantMemory: 0,
wantCode: 0,
},
{
name: "exact boundary available is zero",
cfg: BudgetConfig{
NumCtx: 8192,
SystemReserve: 1500,
RecentReserve: 2000,
ConversationPct: 0.40,
MemoryPct: 0.20,
CodePct: 0.40,
},
// int(8192*0.75) - 1500 - 2000 = 2644
promptTokens: 2644,
wantTotal: 0,
wantConv: 0,
wantMemory: 0,
wantCode: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := tt.cfg.Calculate(tt.promptTokens)
if b.Total != tt.wantTotal {
t.Errorf("Total = %d, want %d", b.Total, tt.wantTotal)
}
if b.Conversation != tt.wantConv {
t.Errorf("Conversation = %d, want %d", b.Conversation, tt.wantConv)
}
if b.Memory != tt.wantMemory {
t.Errorf("Memory = %d, want %d", b.Memory, tt.wantMemory)
}
if b.Code != tt.wantCode {
t.Errorf("Code = %d, want %d", b.Code, tt.wantCode)
}
if b.System != tt.cfg.SystemReserve {
t.Errorf("System = %d, want %d", b.System, tt.cfg.SystemReserve)
}
if b.Recent != tt.cfg.RecentReserve {
t.Errorf("Recent = %d, want %d", b.Recent, tt.cfg.RecentReserve)
}
})
}
}
func TestEstimateTokens(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{
name: "len/4 heuristic",
input: "hello world",
want: 2, // 11/4 = 2
},
{
name: "single char clamps to 1",
input: "a",
want: 1, // 1/4 = 0, clamp to 1
},
{
name: "empty string",
input: "",
want: 0,
},
{
name: "exactly 4 chars",
input: "abcd",
want: 1, // 4/4 = 1
},
{
name: "three chars clamps to 1",
input: "abc",
want: 1, // 3/4 = 0, clamp to 1
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := estimateTokens(tt.input)
if got != tt.want {
t.Errorf("estimateTokens(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package ice
import (
"context"
"fmt"
"ai-agent/internal/llm"
)
const (
defaultEmbedModel = "nomic-embed-text"
maxBatchSize = 32
)
type Embedder struct {
client llm.Client
model string
}
func NewEmbedder(client llm.Client, model string) *Embedder {
if model == "" {
model = defaultEmbedModel
}
return &Embedder{client: client, model: model}
}
func (e *Embedder) Embed(ctx context.Context, text string) ([]float32, error) {
vecs, err := e.EmbedBatch(ctx, []string{text})
if err != nil {
return nil, err
}
if len(vecs) == 0 {
return nil, fmt.Errorf("empty embedding response")
}
return vecs[0], nil
}
func (e *Embedder) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
var all [][]float32
for i := 0; i < len(texts); i += maxBatchSize {
end := i + maxBatchSize
if end > len(texts) {
end = len(texts)
}
batch := texts[i:end]
vecs, err := e.client.Embed(ctx, e.model, batch)
if err != nil {
return nil, fmt.Errorf("embed batch [%d:%d]: %w", i, end, err)
}
all = append(all, vecs...)
}
return all, nil
}
+113
View File
@@ -0,0 +1,113 @@
package ice
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"ai-agent/internal/llm"
"ai-agent/internal/memory"
)
type EngineConfig struct {
EmbedModel string
StorePath string
NumCtx int
}
type Engine struct {
embedder *Embedder
store *Store
memStore *memory.Store
budgetCfg BudgetConfig
sessionID string
turnIndex int
autoMemory *AutoMemory
}
func NewEngine(client llm.Client, memStore *memory.Store, cfg EngineConfig) (*Engine, error) {
storePath := cfg.StorePath
if storePath == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("determine home dir: %w", err)
}
storePath = filepath.Join(home, ".config", "ai-agent", "conversations.json")
}
embedModel := cfg.EmbedModel
if embedModel == "" {
embedModel = defaultEmbedModel
}
sessionID := fmt.Sprintf("s_%d", time.Now().UnixNano())
return &Engine{
embedder: NewEmbedder(client, embedModel),
store: NewStore(storePath),
memStore: memStore,
budgetCfg: DefaultBudgetConfig(cfg.NumCtx),
sessionID: sessionID,
autoMemory: &AutoMemory{client: client, memStore: memStore},
}, nil
}
func (e *Engine) AssembleContext(ctx context.Context, query string) (string, error) {
a := &Assembler{
embedder: e.embedder,
convStore: e.store,
memStore: e.memStore,
budgetCfg: e.budgetCfg,
sessionID: e.sessionID,
}
return a.Assemble(ctx, query)
}
func (e *Engine) IndexMessage(ctx context.Context, role, content string) error {
if content == "" {
return nil
}
text := content
if len(text) > 2000 {
text = text[:2000]
}
emb, err := e.embedder.Embed(ctx, text)
if err != nil {
return fmt.Errorf("embed message: %w", err)
}
e.turnIndex++
_, err = e.store.Add(e.sessionID, role, text, emb, e.turnIndex)
return err
}
func (e *Engine) IndexSummary(ctx context.Context, summary string) error {
if summary == "" {
return nil
}
emb, err := e.embedder.Embed(ctx, summary)
if err != nil {
return fmt.Errorf("embed summary: %w", err)
}
_, err = e.store.Add(e.sessionID, "summary", summary, emb, e.turnIndex)
return err
}
func (e *Engine) DetectAutoMemory(ctx context.Context, userMsg, assistantMsg string) {
if e.autoMemory == nil {
return
}
go func() {
_ = e.autoMemory.Detect(ctx, userMsg, assistantMsg)
}()
}
func (e *Engine) Flush() error {
return e.store.Flush()
}
func (e *Engine) Store() *Store {
return e.store
}
func (e *Engine) SessionID() string {
return e.sessionID
}
+73
View File
@@ -0,0 +1,73 @@
package ice
import (
"testing"
)
func TestEngineConfigDefaults(t *testing.T) {
// Test embed model default
embedModel := ""
if embedModel == "" {
embedModel = defaultEmbedModel
}
if embedModel != defaultEmbedModel {
t.Errorf("embedModel = %q, want %q", embedModel, defaultEmbedModel)
}
// Test custom embed model
cfg := EngineConfig{
EmbedModel: "custom-model",
}
if cfg.EmbedModel != "custom-model" {
t.Errorf("EmbedModel = %q, want %q", cfg.EmbedModel, "custom-model")
}
}
func TestBudgetConfigCalculate(t *testing.T) {
cfg := DefaultBudgetConfig(16384)
budget := cfg.Calculate(100)
// 16384 * 0.75 = 12288
// 12288 - 1500 - 2000 - 100 = 8688
if budget.Total != 8688 {
t.Errorf("Total = %d, want %d", budget.Total, 8688)
}
if budget.System != 1500 {
t.Errorf("System = %d, want %d", budget.System, 1500)
}
if budget.Recent != 2000 {
t.Errorf("Recent = %d, want %d", budget.Recent, 2000)
}
}
func TestBudgetConfigCalculateNegative(t *testing.T) {
// With small context, should not panic and return zeros
cfg := DefaultBudgetConfig(1000)
budget := cfg.Calculate(500)
// 1000 * 0.75 = 750
// 750 - 1500 - 2000 - 500 = -3250 -> clamped to 0
if budget.Total != 0 {
t.Errorf("Total should be 0 when budget is negative, got %d", budget.Total)
}
}
func TestBudgetConfigPercentages(t *testing.T) {
cfg := DefaultBudgetConfig(16384)
budget := cfg.Calculate(100)
// Check percentages: ConversationPct=0.40, MemoryPct=0.20, CodePct=0.40
// available = 12288 - 1500 - 2000 - 100 = 8688
// Conversation = 8688 * 0.40 = 3475
// Memory = 8688 * 0.20 = 1737
// Code = 8688 * 0.40 = 3475
if budget.Conversation != 3475 {
t.Errorf("Conversation = %d, want %d", budget.Conversation, 3475)
}
if budget.Memory != 1737 {
t.Errorf("Memory = %d, want %d", budget.Memory, 1737)
}
if budget.Code != 3475 {
t.Errorf("Code = %d, want %d", budget.Code, 3475)
}
}
+167
View File
@@ -0,0 +1,167 @@
package ice
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// timeNow is a variable for testing.
var timeNow = time.Now
const minSimilarityThreshold = 0.3
// Store is a flat-file vector store for conversation history.
// It holds all entries in memory and persists to a JSON file.
type Store struct {
mu sync.Mutex
path string
entries []ConversationEntry
nextID int
dirty bool
}
// NewStore loads an existing store from path or creates an empty one.
func NewStore(path string) *Store {
s := &Store{path: path}
s.load()
return s
}
// Add appends a new conversation entry and returns its ID.
func (s *Store) Add(sessionID, role, content string, embedding []float32, turnIndex int) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
entry := ConversationEntry{
ID: s.nextID,
SessionID: sessionID,
Role: role,
Content: content,
Embedding: embedding,
TurnIndex: turnIndex,
}
// Use a zero-value check to set CreatedAt (avoids importing time in every call site).
entry.CreatedAt = timeNow()
s.entries = append(s.entries, entry)
s.dirty = true
return s.nextID, nil
}
// Search returns the top-K entries most similar to queryEmbedding.
// Entries from excludeSession are skipped. Results are sorted by score descending.
func (s *Store) Search(queryEmbedding []float32, excludeSession string, topK int) []ScoredEntry {
s.mu.Lock()
defer s.mu.Unlock()
if len(queryEmbedding) == 0 || len(s.entries) == 0 {
return nil
}
var scored []ScoredEntry
for _, e := range s.entries {
if e.SessionID == excludeSession {
continue
}
if len(e.Embedding) == 0 {
continue
}
sim := cosineSimilarity(queryEmbedding, e.Embedding)
if sim >= minSimilarityThreshold {
scored = append(scored, ScoredEntry{Entry: e, Score: sim})
}
}
sort.Slice(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
if len(scored) > topK {
scored = scored[:topK]
}
return scored
}
// Flush persists any pending changes to disk.
func (s *Store) Flush() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.dirty {
return nil
}
return s.persist()
}
// Count returns the total number of stored entries.
func (s *Store) Count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.entries)
}
// load reads entries from the JSON file.
func (s *Store) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return // File doesn't exist yet.
}
var entries []ConversationEntry
if err := json.Unmarshal(data, &entries); err != nil {
return // Corrupt file, start empty.
}
s.entries = entries
for _, e := range s.entries {
if e.ID > s.nextID {
s.nextID = e.ID
}
}
}
// persist writes all entries to the JSON file.
func (s *Store) persist() error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create ice store dir: %w", err)
}
data, err := json.Marshal(s.entries)
if err != nil {
return fmt.Errorf("marshal ice store: %w", err)
}
if err := os.WriteFile(s.path, data, 0o644); err != nil {
return fmt.Errorf("write ice store: %w", err)
}
s.dirty = false
return nil
}
// cosineSimilarity computes the cosine similarity between two vectors.
func cosineSimilarity(a, b []float32) float32 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
denom := math.Sqrt(normA) * math.Sqrt(normB)
if denom == 0 {
return 0
}
return float32(dot / denom)
}
+232
View File
@@ -0,0 +1,232 @@
package ice
import (
"math"
"os"
"path/filepath"
"testing"
)
func TestCosineSimilarity(t *testing.T) {
tests := []struct {
name string
a, b []float32
want float32
tol float32
}{
{
name: "identical vectors",
a: []float32{1, 2, 3},
b: []float32{1, 2, 3},
want: 1.0,
tol: 1e-6,
},
{
name: "orthogonal vectors",
a: []float32{1, 0},
b: []float32{0, 1},
want: 0.0,
tol: 1e-6,
},
{
name: "opposite vectors",
a: []float32{1, 0},
b: []float32{-1, 0},
want: -1.0,
tol: 1e-6,
},
{
name: "different lengths returns 0",
a: []float32{1, 0},
b: []float32{1, 0, 0},
want: 0,
tol: 0,
},
{
name: "zero vector returns 0",
a: []float32{0, 0},
b: []float32{1, 1},
want: 0,
tol: 0,
},
{
name: "known value with tolerance",
a: []float32{1, 1},
b: []float32{1, 0},
// 1/(sqrt(2)*1) ≈ 0.7071
want: float32(1.0 / math.Sqrt(2)),
tol: 1e-4,
},
{
name: "empty vectors",
a: []float32{},
b: []float32{},
want: 0,
tol: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cosineSimilarity(tt.a, tt.b)
diff := got - tt.want
if diff < 0 {
diff = -diff
}
if diff > tt.tol {
t.Errorf("cosineSimilarity(%v, %v) = %f, want %f (±%f)",
tt.a, tt.b, got, tt.want, tt.tol)
}
})
}
}
func TestStore_Add_And_Count(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
s := NewStore(path)
if s.Count() != 0 {
t.Fatalf("new store Count = %d, want 0", s.Count())
}
id1, err := s.Add("sess1", "user", "hello", []float32{1, 0, 0}, 0)
if err != nil {
t.Fatalf("Add returned error: %v", err)
}
if id1 != 1 {
t.Errorf("first Add returned id=%d, want 1", id1)
}
if s.Count() != 1 {
t.Errorf("Count after first Add = %d, want 1", s.Count())
}
id2, err := s.Add("sess1", "assistant", "world", []float32{0, 1, 0}, 1)
if err != nil {
t.Fatalf("Add returned error: %v", err)
}
if id2 != 2 {
t.Errorf("second Add returned id=%d, want 2", id2)
}
if s.Count() != 2 {
t.Errorf("Count after second Add = %d, want 2", s.Count())
}
}
func TestStore_Search(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
s := NewStore(path)
// Add entries with known embeddings.
s.Add("sess1", "user", "entry A", []float32{1, 0, 0}, 0)
s.Add("sess1", "user", "entry B", []float32{0, 1, 0}, 1)
s.Add("sess2", "user", "entry C", []float32{0.9, 0.1, 0}, 0)
s.Add("sess2", "user", "entry D", []float32{0, 0, 1}, 1) // orthogonal to query
t.Run("similarity filtering and sorting", func(t *testing.T) {
// Query similar to entries A and C, exclude no session.
results := s.Search([]float32{1, 0, 0}, "", 10)
if len(results) == 0 {
t.Fatal("expected results, got 0")
}
// Entry A should be highest (identical to query).
if results[0].Entry.Content != "entry A" {
t.Errorf("top result = %q, want 'entry A'", results[0].Entry.Content)
}
})
t.Run("session exclusion", func(t *testing.T) {
results := s.Search([]float32{1, 0, 0}, "sess1", 10)
for _, r := range results {
if r.Entry.SessionID == "sess1" {
t.Errorf("excluded session sess1 should not appear in results")
}
}
})
t.Run("min threshold 0.3", func(t *testing.T) {
// Entry D: [0,0,1] is orthogonal to [1,0,0] → similarity 0.
results := s.Search([]float32{1, 0, 0}, "", 10)
for _, r := range results {
if r.Score < minSimilarityThreshold {
t.Errorf("result %q has score %f below threshold %f",
r.Entry.Content, r.Score, minSimilarityThreshold)
}
}
})
t.Run("topK limit", func(t *testing.T) {
results := s.Search([]float32{1, 0, 0}, "", 1)
if len(results) > 1 {
t.Errorf("topK=1 but got %d results", len(results))
}
})
t.Run("empty store returns nil", func(t *testing.T) {
emptyPath := filepath.Join(dir, "empty.json")
empty := NewStore(emptyPath)
results := empty.Search([]float32{1, 0}, "", 5)
if results != nil {
t.Errorf("empty store search should return nil, got %v", results)
}
})
t.Run("empty query embedding returns nil", func(t *testing.T) {
results := s.Search([]float32{}, "", 5)
if results != nil {
t.Errorf("empty query should return nil, got %v", results)
}
})
}
func TestStore_Flush_Persistence(t *testing.T) {
t.Run("round trip", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
s1 := NewStore(path)
s1.Add("sess1", "user", "hello", []float32{1, 0}, 0)
s1.Add("sess1", "assistant", "world", []float32{0, 1}, 1)
if err := s1.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
// Reload from same path.
s2 := NewStore(path)
if s2.Count() != 2 {
t.Errorf("reloaded store Count = %d, want 2", s2.Count())
}
})
t.Run("corrupt JSON recovery", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
// Write corrupt JSON.
os.WriteFile(path, []byte("not valid json{{{"), 0o644)
s := NewStore(path)
if s.Count() != 0 {
t.Errorf("corrupt store Count = %d, want 0", s.Count())
}
})
t.Run("nextID restoration", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
s1 := NewStore(path)
s1.Add("sess1", "user", "first", []float32{1}, 0)
s1.Add("sess1", "user", "second", []float32{1}, 1)
s1.Flush()
s2 := NewStore(path)
id, _ := s2.Add("sess1", "user", "third", []float32{1}, 2)
if id != 3 {
t.Errorf("continued id = %d, want 3", id)
}
})
}
+46
View File
@@ -0,0 +1,46 @@
package ice
import "time"
// SourceKind identifies where a context chunk came from.
type SourceKind int
const (
SourceConversation SourceKind = iota
SourceMemory
)
// ConversationEntry is a single stored message with its embedding.
type ConversationEntry struct {
ID int `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"` // "user", "assistant", "summary"
Content string `json:"content"`
Embedding []float32 `json:"embedding"`
CreatedAt time.Time `json:"created_at"`
TurnIndex int `json:"turn_index"`
}
// ScoredEntry pairs a conversation entry with its similarity score.
type ScoredEntry struct {
Entry ConversationEntry
Score float32
}
// ContextChunk is a piece of assembled context ready for the prompt.
type ContextChunk struct {
Source SourceKind
Content string
Score float32
Tokens int
}
// Budget holds the token allocation for each context source.
type Budget struct {
Total int
System int
Conversation int
Memory int
Code int
Recent int
}
+184
View File
@@ -0,0 +1,184 @@
package initcmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// projectMarker maps a marker file name to its detected project type.
var projectMarkers = map[string]string{
"go.mod": "Go",
"go.sum": "Go",
"package.json": "Node.js",
"Cargo.toml": "Rust",
"pyproject.toml": "Python",
"requirements.txt": "Python",
"setup.py": "Python",
"Pipfile": "Python",
"Gemfile": "Ruby",
"pom.xml": "Java (Maven)",
"build.gradle": "Java (Gradle)",
"build.gradle.kts": "Kotlin (Gradle)",
"CMakeLists.txt": "C/C++ (CMake)",
"Makefile": "Make",
"Taskfile.yml": "Taskfile",
"Taskfile.yaml": "Taskfile",
"docker-compose.yml": "Docker Compose",
"docker-compose.yaml": "Docker Compose",
"Dockerfile": "Docker",
".gitignore": "Git",
}
// Options configures the behaviour of Run.
type Options struct {
// Force overwrites an existing AGENT.md.
Force bool
}
// Run scans dir for project markers and generates an AGENT.md file.
// It returns an error if AGENT.md already exists unless opts.Force is true.
func Run(dir string, opts Options) error {
agentPath := filepath.Join(dir, "AGENT.md")
if !opts.Force {
if _, err := os.Stat(agentPath); err == nil {
return fmt.Errorf("AGENT.md already exists in %s (use --force to overwrite)", dir)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory: %w", err)
}
// Detect project types from marker files.
detectedTypes := detectProjectTypes(entries)
// Build directory listing.
listing := buildDirectoryListing(dir, entries)
// Generate AGENT.md content.
content := generateAgentMD(detectedTypes, listing)
if err := os.WriteFile(agentPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing AGENT.md: %w", err)
}
return nil
}
// detectProjectTypes returns a deduplicated, sorted list of project types
// found based on marker files in the directory entries.
func detectProjectTypes(entries []os.DirEntry) []string {
seen := make(map[string]bool)
for _, e := range entries {
if e.IsDir() {
continue
}
if pt, ok := projectMarkers[e.Name()]; ok {
seen[pt] = true
}
}
types := make([]string, 0, len(seen))
for t := range seen {
types = append(types, t)
}
sort.Strings(types)
return types
}
// buildDirectoryListing returns a formatted string of top-level files and
// first-level subdirectory contents.
func buildDirectoryListing(dir string, entries []os.DirEntry) string {
var b strings.Builder
var files []string
var dirs []string
for _, e := range entries {
name := e.Name()
// Skip hidden files/dirs except well-known ones.
if strings.HasPrefix(name, ".") && name != ".gitignore" {
continue
}
if e.IsDir() {
dirs = append(dirs, name)
} else {
files = append(files, name)
}
}
sort.Strings(files)
sort.Strings(dirs)
for _, f := range files {
b.WriteString(f)
b.WriteByte('\n')
}
for _, d := range dirs {
b.WriteString(d + "/\n")
subEntries, err := os.ReadDir(filepath.Join(dir, d))
if err != nil {
continue
}
var subNames []string
for _, se := range subEntries {
n := se.Name()
if strings.HasPrefix(n, ".") {
continue
}
if se.IsDir() {
subNames = append(subNames, n+"/")
} else {
subNames = append(subNames, n)
}
}
sort.Strings(subNames)
for _, sn := range subNames {
b.WriteString(" " + sn + "\n")
}
}
return b.String()
}
// generateAgentMD produces the Markdown content for the AGENT.md file.
func generateAgentMD(projectTypes []string, listing string) string {
var b strings.Builder
b.WriteString("# AGENT.md\n\n")
// Project type section.
b.WriteString("## Project Type\n\n")
if len(projectTypes) == 0 {
b.WriteString("Unknown\n")
} else {
b.WriteString(strings.Join(projectTypes, ", ") + "\n")
}
// Directory structure.
b.WriteString("\n## Directory Structure\n\n")
b.WriteString("```\n")
b.WriteString(listing)
b.WriteString("```\n")
// Placeholder sections.
b.WriteString("\n## Build Commands\n\n")
b.WriteString("<!-- Add build, test, and run commands here -->\n")
b.WriteString("\n## Architecture\n\n")
b.WriteString("<!-- Describe the high-level architecture here -->\n")
b.WriteString("\n## Key Files\n\n")
b.WriteString("<!-- List important files and their purposes here -->\n")
b.WriteString("\n## Notes\n\n")
b.WriteString("<!-- Any additional notes for the agent -->\n")
return b.String()
}
+141
View File
@@ -0,0 +1,141 @@
package initcmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRun_GoProject(t *testing.T) {
dir := t.TempDir()
// Create a go.mod marker file.
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a source directory with a file.
if err := os.Mkdir(filepath.Join(dir, "cmd"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0644); err != nil {
t.Fatal(err)
}
if err := Run(dir, Options{}); err != nil {
t.Fatalf("Run() returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENT.md"))
if err != nil {
t.Fatalf("reading AGENT.md: %v", err)
}
content := string(data)
// Check project type detection.
if !strings.Contains(content, "Go") {
t.Error("expected AGENT.md to contain 'Go' project type")
}
// Check directory listing includes go.mod.
if !strings.Contains(content, "go.mod") {
t.Error("expected AGENT.md to list go.mod")
}
// Check directory listing includes cmd/ subdirectory.
if !strings.Contains(content, "cmd/") {
t.Error("expected AGENT.md to list cmd/ directory")
}
// Check that main.go appears under cmd/.
if !strings.Contains(content, "main.go") {
t.Error("expected AGENT.md to list main.go inside cmd/")
}
// Check placeholder sections exist.
for _, section := range []string{"## Build Commands", "## Architecture", "## Key Files", "## Notes"} {
if !strings.Contains(content, section) {
t.Errorf("expected AGENT.md to contain section %q", section)
}
}
}
func TestRun_EmptyDirectory(t *testing.T) {
dir := t.TempDir()
if err := Run(dir, Options{}); err != nil {
t.Fatalf("Run() returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENT.md"))
if err != nil {
t.Fatalf("reading AGENT.md: %v", err)
}
content := string(data)
// With no marker files, project type should be "Unknown".
if !strings.Contains(content, "Unknown") {
t.Error("expected AGENT.md to contain 'Unknown' project type for empty dir")
}
}
func TestRun_ExistingAgentMD_NoOverwrite(t *testing.T) {
dir := t.TempDir()
agentPath := filepath.Join(dir, "AGENT.md")
original := "# Original content\n"
if err := os.WriteFile(agentPath, []byte(original), 0644); err != nil {
t.Fatal(err)
}
err := Run(dir, Options{})
if err == nil {
t.Fatal("expected error when AGENT.md already exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' in error, got: %v", err)
}
// Verify file was not modified.
data, err := os.ReadFile(agentPath)
if err != nil {
t.Fatal(err)
}
if string(data) != original {
t.Error("AGENT.md was unexpectedly modified")
}
}
func TestRun_Force(t *testing.T) {
dir := t.TempDir()
agentPath := filepath.Join(dir, "AGENT.md")
if err := os.WriteFile(agentPath, []byte("# Old content\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a go.mod so the new content is distinguishable.
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n"), 0644); err != nil {
t.Fatal(err)
}
if err := Run(dir, Options{Force: true}); err != nil {
t.Fatalf("Run() with Force=true returned error: %v", err)
}
data, err := os.ReadFile(agentPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "Old content") {
t.Error("AGENT.md should have been overwritten with Force=true")
}
if !strings.Contains(content, "Go") {
t.Error("expected new AGENT.md to detect Go project type")
}
}
+230
View File
@@ -0,0 +1,230 @@
//go:build integration
// +build integration
package integration
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"ai-agent/internal/agent"
"ai-agent/internal/command"
"ai-agent/internal/config"
"ai-agent/internal/llm"
"ai-agent/internal/mcp"
"ai-agent/internal/tui"
tea "charm.land/bubbletea/v2"
)
func skipIfNoOllama(t *testing.T) {
if os.Getenv("OLLAMA_HOST") == "" {
os.Setenv("OLLAMA_HOST", "http://localhost:11434")
}
client := llm.NewClient(llm.Config{
BaseURL: os.Getenv("OLLAMA_HOST"),
Model: "qwen3.5:2b",
NumCtx: 262144,
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := client.Ping(ctx); err != nil {
t.Skip("Ollama not available: skipping integration test")
}
}
func TestTUI_Initialization(t *testing.T) {
skipIfNoOllama(t)
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
cfg := config.DefaultModelConfig()
router := config.NewRouter(&cfg)
modelManager := llm.NewModelManager("http://localhost:11434", 262144)
modelManager.SetCurrentModel("qwen3.5:2b")
ag := agent.New(modelManager, mcp.NewRegistry(), cfg.Ollama.NumCtx)
ag.SetRouter(router)
completer := tui.NewCompleter(reg, []string{"qwen3.5:2b"}, nil, nil, nil)
m := tui.New(ag, reg, nil, completer, modelManager, router, nil)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = updated.(*tui.Model)
if !m.Ready() {
t.Error("TUI should be ready after WindowSizeMsg")
}
}
func TestTUI_ScrollAnchorDuringStreaming(t *testing.T) {
skipIfNoOllama(t)
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
cfg := config.DefaultModelConfig()
router := config.NewRouter(&cfg)
modelManager := llm.NewModelManager("http://localhost:11434", 262144)
ag := agent.New(modelManager, mcp.NewRegistry(), 262144)
ag.SetRouter(router)
completer := tui.NewCompleter(reg, []string{"qwen3.5:2b"}, nil, nil, nil)
m := tui.New(ag, reg, nil, completer, modelManager, router, nil)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = updated.(*tui.Model)
if !m.AnchorActive() {
t.Error("anchorActive should be true after initialization")
}
updated, _ = m.Update(tui.StreamTextMsg{Text: "Hello"})
m = updated.(*tui.Model)
if !m.AnchorActive() {
t.Error("anchorActive should remain true during streaming")
}
}
func TestTUI_OverlayRendering(t *testing.T) {
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
cfg := config.DefaultModelConfig()
router := config.NewRouter(&cfg)
modelManager := llm.NewModelManager("http://localhost:11434", 262144)
ag := agent.New(modelManager, mcp.NewRegistry(), 262144)
ag.SetRouter(router)
completer := tui.NewCompleter(reg, []string{"qwen3.5:2b"}, nil, nil, nil)
m := tui.New(ag, reg, nil, completer, modelManager, router, nil)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = updated.(*tui.Model)
updated, _ = m.Update(tui.KeyPressMsg{Code: '?'})
m = updated.(*tui.Model)
view := m.View()
if view == nil {
t.Error("View should not be nil")
}
updated, _ = m.Update(tui.KeyPressMsg{Code: tea.KeyEscape})
m = updated.(*tui.Model)
}
func TestTUI_ToolCardRendering(t *testing.T) {
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
cfg := config.DefaultModelConfig()
router := config.NewRouter(&cfg)
modelManager := llm.NewModelManager("http://localhost:11434", 262144)
ag := agent.New(modelManager, mcp.NewRegistry(), 262144)
ag.SetRouter(router)
completer := tui.NewCompleter(reg, []string{"qwen3.5:2b"}, nil, nil, nil)
m := tui.New(ag, reg, nil, completer, modelManager, router, nil)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = updated.(*tui.Model)
startTime := time.Now()
updated, _ = m.Update(tui.ToolCallStartMsg{
Name: "read_file",
Args: map[string]any{"path": "test.go"},
StartTime: startTime,
})
m = updated.(*tui.Model)
updated, _ = m.Update(tui.ToolCallResultMsg{
Name: "read_file",
Result: "file content",
IsError: false,
Duration: 100 * time.Millisecond,
})
m = updated.(*tui.Model)
view := m.View()
if view == nil {
t.Error("View should not be nil after tool execution")
}
}
func TestQwenRouter_Integration(t *testing.T) {
cfg := config.DefaultModelConfig()
router := config.NewQwenModelRouter(&cfg)
tests := []struct {
query string
mode config.ModeContext
expectSmaller string
expectLarger string
}{
{"what is go?", config.ModeAskContext, "qwen3.5:2b", ""},
{"design architecture", config.ModeBuildContext, "", "qwen3.5:4b"},
{"plan the system", config.ModePlanContext, "", "qwen3.5:4b"},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
model := router.SelectModelForMode(tt.query, tt.mode)
if tt.expectSmaller != "" {
if modelRank(model) > modelRank(tt.expectSmaller) {
t.Errorf("model %s is larger than expected %s", model, tt.expectSmaller)
}
}
if tt.expectLarger != "" {
if modelRank(model) < modelRank(tt.expectLarger) {
t.Errorf("model %s is smaller than expected %s", model, tt.expectLarger)
}
}
})
}
}
func modelRank(model string) int {
switch {
case strings.Contains(model, "0.8b"):
return 1
case strings.Contains(model, "2b"):
return 2
case strings.Contains(model, "4b"):
return 3
case strings.Contains(model, "9b"):
return 4
default:
return 0
}
}
func TestFileOperations_Integration(t *testing.T) {
skipIfNoOllama(t)
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("failed to read test file: %v", err)
}
if string(content) != "hello world" {
t.Errorf("unexpected file content: %q", string(content))
}
}
func BenchmarkTUI_Render(b *testing.B) {
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
cfg := config.DefaultModelConfig()
router := config.NewRouter(&cfg)
modelManager := llm.NewModelManager("http://localhost:11434", 262144)
ag := agent.New(modelManager, mcp.NewRegistry(), 262144)
ag.SetRouter(router)
completer := tui.NewCompleter(reg, []string{"qwen3.5:2b"}, nil, nil, nil)
m := tui.New(ag, reg, nil, completer, modelManager, router, nil)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = updated.(*tui.Model)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = m.View()
}
}
func BenchmarkQwenRouter_Classification(b *testing.B) {
cfg := config.DefaultModelConfig()
router := config.NewQwenModelRouter(&cfg)
queries := []string{
"what is go",
"how do i create a file",
"debug this nil pointer error",
"design microservice architecture",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, q := range queries {
_ = router.SelectModelForMode(q, config.ModeAskContext)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package llm
import "context"
// Client is the interface for LLM providers.
type Client interface {
// ChatStream sends messages to the LLM and streams the response.
// The callback is called for each chunk. Return a non-nil error to abort.
ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error
// Ping checks if the LLM is reachable and the model is available.
Ping() error
// Model returns the current model name.
Model() string
// Embed generates embeddings for the given texts using the specified model.
Embed(ctx context.Context, model string, texts []string) ([][]float32, error)
}
// ChatOptions holds parameters for a chat request.
type ChatOptions struct {
Messages []Message
Tools []ToolDef
System string
}
// Message represents a conversation message.
type Message struct {
Role string `json:"role"` // system, user, assistant, tool
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// StreamChunk is a piece of a streaming response.
type StreamChunk struct {
Text string // incremental text content
ToolCalls []ToolCall // tool calls (usually in final chunk)
Done bool // true on the last chunk
EvalCount int // tokens generated (only on Done)
PromptEvalCount int // prompt tokens evaluated (only on Done)
}
// ToolCall represents a tool invocation requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
// ToolDef defines a tool the LLM can call.
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"` // JSON Schema
}
+163
View File
@@ -0,0 +1,163 @@
package llm
import (
"context"
"fmt"
"sync"
)
type ModelManager struct {
baseURL string
numCtx int
clients map[string]*OllamaClient
currentModel string
mu sync.RWMutex
}
var _ Client = (*ModelManager)(nil)
func NewModelManager(baseURL string, numCtx int) *ModelManager {
return &ModelManager{
baseURL: baseURL,
numCtx: numCtx,
clients: make(map[string]*OllamaClient),
}
}
func (m *ModelManager) GetClient(modelName string) (*OllamaClient, error) {
m.mu.RLock()
client, exists := m.clients[modelName]
m.mu.RUnlock()
if exists {
return client, nil
}
m.mu.Lock()
defer m.mu.Unlock()
if client, exists := m.clients[modelName]; exists {
return client, nil
}
client, err := NewOllamaClient(m.baseURL, modelName, m.numCtx)
if err != nil {
return nil, fmt.Errorf("create client for %s: %w", modelName, err)
}
m.clients[modelName] = client
return client, nil
}
func (m *ModelManager) SetCurrentModel(model string) error {
m.mu.Lock()
defer m.mu.Unlock()
client, err := NewOllamaClient(m.baseURL, model, m.numCtx)
if err != nil {
return fmt.Errorf("create client for %s: %w", model, err)
}
m.clients[model] = client
m.currentModel = model
return nil
}
func (m *ModelManager) CurrentModel() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentModel
}
func (m *ModelManager) ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error {
m.mu.RLock()
model := m.currentModel
m.mu.RUnlock()
if model == "" {
return fmt.Errorf("no model selected")
}
client, err := m.GetClient(model)
if err != nil {
return err
}
return client.ChatStream(ctx, opts, fn)
}
func (m *ModelManager) ChatStreamForModel(ctx context.Context, model string, opts ChatOptions, fn func(StreamChunk) error) error {
client, err := m.GetClient(model)
if err != nil {
return err
}
return client.ChatStream(ctx, opts, fn)
}
func (m *ModelManager) Ping() error {
m.mu.RLock()
model := m.currentModel
m.mu.RUnlock()
if model == "" {
return fmt.Errorf("no model selected")
}
client, err := m.GetClient(model)
if err != nil {
return err
}
return client.Ping()
}
func (m *ModelManager) PingModel(model string) error {
client, err := m.GetClient(model)
if err != nil {
return err
}
return client.Ping()
}
func (m *ModelManager) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
client, err := m.GetClient(model)
if err != nil {
return nil, err
}
return client.Embed(ctx, model, texts)
}
func (m *ModelManager) EmbedWithCurrentModel(ctx context.Context, texts []string) ([][]float32, error) {
m.mu.RLock()
model := m.currentModel
m.mu.RUnlock()
if model == "" {
return nil, fmt.Errorf("no model selected")
}
return m.Embed(ctx, model, texts)
}
func (m *ModelManager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for range m.clients {
}
m.clients = make(map[string]*OllamaClient)
}
func (m *ModelManager) BaseURL() string {
return m.baseURL
}
func (m *ModelManager) NumCtx() int {
return m.numCtx
}
func (m *ModelManager) Model() string {
return m.CurrentModel()
}
// ListModels returns model names available in Ollama at the manager's base URL.
func (m *ModelManager) ListModels(ctx context.Context) ([]string, error) {
return ListModels(ctx, m.baseURL)
}
+92
View File
@@ -0,0 +1,92 @@
package llm
import (
"testing"
)
func TestNewModelManager(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
if m.baseURL != "http://localhost:11434" {
t.Errorf("baseURL = %q, want %q", m.baseURL, "http://localhost:11434")
}
if m.numCtx != 4096 {
t.Errorf("numCtx = %d, want %d", m.numCtx, 4096)
}
if m.clients == nil {
t.Error("clients map should be initialized")
}
}
func TestModelManagerBaseURL(t *testing.T) {
m := NewModelManager("http://custom:9999", 2048)
if m.BaseURL() != "http://custom:9999" {
t.Errorf("BaseURL() = %q, want %q", m.BaseURL(), "http://custom:9999")
}
}
func TestModelManagerNumCtx(t *testing.T) {
m := NewModelManager("http://localhost:11434", 8192)
if m.NumCtx() != 8192 {
t.Errorf("NumCtx() = %d, want %d", m.NumCtx(), 8192)
}
}
func TestModelManagerCurrentModel(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
// Should return empty when no model set
if m.CurrentModel() != "" {
t.Errorf("CurrentModel() = %q, want %q", m.CurrentModel(), "")
}
// Set a model
m.SetCurrentModel("llama3")
if m.CurrentModel() != "llama3" {
t.Errorf("CurrentModel() = %q, want %q", m.CurrentModel(), "llama3")
}
}
func TestModelManagerChatStreamNoModel(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
err := m.ChatStream(nil, ChatOptions{}, func(chunk StreamChunk) error {
return nil
})
if err == nil {
t.Error("ChatStream should fail when no model is set")
}
}
func TestModelManagerPingNoModel(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
err := m.Ping()
if err == nil {
t.Error("Ping should fail when no model is set")
}
}
func TestModelManagerEmbedWithCurrentModelNoModel(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
_, err := m.EmbedWithCurrentModel(nil, []string{"test"})
if err == nil {
t.Error("EmbedWithCurrentModel should fail when no model is set")
}
}
func TestModelManagerClose(t *testing.T) {
m := NewModelManager("http://localhost:11434", 4096)
// Should not panic
m.Close()
if len(m.clients) != 0 {
t.Errorf("after Close, clients map should be empty, got %d", len(m.clients))
}
}
+222
View File
@@ -0,0 +1,222 @@
package llm
import (
"context"
"fmt"
"net/url"
"os"
ollamaapi "github.com/ollama/ollama/api"
)
// OllamaClient implements Client using the official Ollama Go library.
type OllamaClient struct {
client *ollamaapi.Client
model string
numCtx int
}
// NewOllamaClient creates a new Ollama client.
func NewOllamaClient(baseURL, model string, numCtx int) (*OllamaClient, error) {
// The official client reads OLLAMA_HOST, but we want to support our config too.
if baseURL != "" {
os.Setenv("OLLAMA_HOST", baseURL)
}
client, err := ollamaapi.ClientFromEnvironment()
if err != nil {
return nil, fmt.Errorf("create ollama client: %w", err)
}
return &OllamaClient{
client: client,
model: model,
numCtx: numCtx,
}, nil
}
func (o *OllamaClient) Model() string { return o.model }
// Ping checks Ollama is running and the model exists.
func (o *OllamaClient) Ping() error {
ctx := context.Background()
// Check the model is available by requesting a show.
req := &ollamaapi.ShowRequest{Model: o.model}
_, err := o.client.Show(ctx, req)
if err != nil {
return fmt.Errorf("model %q not available: %w", o.model, err)
}
return nil
}
// ChatStream sends a chat request and streams the response via callback.
func (o *OllamaClient) ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error {
messages := make([]ollamaapi.Message, 0, len(opts.Messages)+1)
if opts.System != "" {
messages = append(messages, ollamaapi.Message{
Role: "system",
Content: opts.System,
})
}
for _, m := range opts.Messages {
msg := ollamaapi.Message{
Role: m.Role,
Content: m.Content,
ToolName: m.ToolName,
ToolCallID: m.ToolCallID,
}
// Convert tool calls for assistant messages.
for _, tc := range m.ToolCalls {
args := ollamaapi.NewToolCallFunctionArguments()
for k, v := range tc.Arguments {
args.Set(k, v)
}
msg.ToolCalls = append(msg.ToolCalls, ollamaapi.ToolCall{
ID: tc.ID,
Function: ollamaapi.ToolCallFunction{
Name: tc.Name,
Arguments: args,
},
})
}
messages = append(messages, msg)
}
tools := convertTools(opts.Tools)
req := &ollamaapi.ChatRequest{
Model: o.model,
Messages: messages,
Tools: tools,
Options: map[string]any{
"num_ctx": o.numCtx,
},
}
return o.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error {
chunk := StreamChunk{
Text: resp.Message.Content,
Done: resp.Done,
}
if resp.Done {
chunk.EvalCount = resp.EvalCount
chunk.PromptEvalCount = resp.PromptEvalCount
}
// Collect tool calls from the response.
for _, tc := range resp.Message.ToolCalls {
chunk.ToolCalls = append(chunk.ToolCalls, ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments.ToMap(),
})
}
return fn(chunk)
})
}
// Embed generates embeddings for the given texts using the specified model.
func (o *OllamaClient) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
resp, err := o.client.Embed(ctx, &ollamaapi.EmbedRequest{
Model: model,
Input: texts,
})
if err != nil {
return nil, fmt.Errorf("embedding failed: %w", err)
}
return resp.Embeddings, nil
}
// convertTools transforms our ToolDef slice into Ollama's Tools format.
func convertTools(defs []ToolDef) ollamaapi.Tools {
if len(defs) == 0 {
return nil
}
tools := make(ollamaapi.Tools, 0, len(defs))
for _, d := range defs {
props := ollamaapi.NewToolPropertiesMap()
var required []string
// Extract properties from JSON Schema.
if propsRaw, ok := d.Parameters["properties"].(map[string]any); ok {
for name, schema := range propsRaw {
schemaMap, _ := schema.(map[string]any)
prop := ollamaapi.ToolProperty{
Description: strFromMap(schemaMap, "description"),
}
if t, ok := schemaMap["type"].(string); ok {
prop.Type = ollamaapi.PropertyType{t}
}
if enumRaw, ok := schemaMap["enum"].([]any); ok {
prop.Enum = enumRaw
}
props.Set(name, prop)
}
}
// Extract required fields.
if reqRaw, ok := d.Parameters["required"].([]any); ok {
for _, r := range reqRaw {
if s, ok := r.(string); ok {
required = append(required, s)
}
}
}
tools = append(tools, ollamaapi.Tool{
Type: "function",
Function: ollamaapi.ToolFunction{
Name: d.Name,
Description: d.Description,
Parameters: ollamaapi.ToolFunctionParameters{
Type: "object",
Properties: props,
Required: required,
},
},
})
}
return tools
}
func strFromMap(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return s
}
// BaseURL returns the configured Ollama base URL for display.
func (o *OllamaClient) BaseURL() string {
if v := os.Getenv("OLLAMA_HOST"); v != "" {
return v
}
return "http://localhost:11434"
}
// ParseBaseURL validates the Ollama URL.
func ParseBaseURL(rawURL string) (*url.URL, error) {
return url.Parse(rawURL)
}
// ListModels returns model names available in Ollama at baseURL.
func ListModels(ctx context.Context, baseURL string) ([]string, error) {
if baseURL != "" {
os.Setenv("OLLAMA_HOST", baseURL)
}
client, err := ollamaapi.ClientFromEnvironment()
if err != nil {
return nil, fmt.Errorf("ollama client: %w", err)
}
resp, err := client.List(ctx)
if err != nil {
return nil, fmt.Errorf("ollama list: %w", err)
}
names := make([]string, 0, len(resp.Models))
for _, m := range resp.Models {
names = append(names, m.Name)
}
return names, nil
}
+121
View File
@@ -0,0 +1,121 @@
package llm
import (
"testing"
)
func TestConvertTools(t *testing.T) {
tests := []struct {
name string
input []ToolDef
wantNil bool
wantCount int
}{
{
name: "nil input",
input: nil,
wantNil: true,
},
{
name: "single tool with properties and required",
input: []ToolDef{
{
Name: "read_file",
Description: "Read a file",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "file path",
},
},
"required": []any{"path"},
},
},
},
wantCount: 1,
},
{
name: "tool without properties in parameters",
input: []ToolDef{
{
Name: "noop",
Description: "Does nothing",
Parameters: map[string]any{"type": "object"},
},
},
wantCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertTools(tt.input)
if tt.wantNil {
if result != nil {
t.Errorf("convertTools() = %v, want nil", result)
}
return
}
if len(result) != tt.wantCount {
t.Errorf("convertTools() returned %d tools, want %d", len(result), tt.wantCount)
}
if tt.wantCount > 0 {
tool := result[0]
if tool.Function.Name != tt.input[0].Name {
t.Errorf("tool name = %q, want %q", tool.Function.Name, tt.input[0].Name)
}
if tool.Function.Description != tt.input[0].Description {
t.Errorf("tool description = %q, want %q", tool.Function.Description, tt.input[0].Description)
}
if tool.Type != "function" {
t.Errorf("tool type = %q, want %q", tool.Type, "function")
}
}
})
}
}
func TestStrFromMap(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
want string
}{
{
name: "key present",
m: map[string]any{"description": "a desc"},
key: "description",
want: "a desc",
},
{
name: "key missing",
m: map[string]any{"other": "value"},
key: "description",
want: "",
},
{
name: "nil map",
m: nil,
key: "description",
want: "",
},
{
name: "non-string value",
m: map[string]any{"count": 42},
key: "count",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strFromMap(tt.m, tt.key)
if got != tt.want {
t.Errorf("strFromMap() = %q, want %q", got, tt.want)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package logging
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/charmbracelet/log"
)
func NewSessionLogger() (*log.Logger, *os.File, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, fmt.Errorf("home dir: %w", err)
}
logDir := filepath.Join(home, ".config", "ai-agent", "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, nil, fmt.Errorf("create log dir: %w", err)
}
filename := time.Now().Format("2006-01-02_15-04-05") + ".log"
f, err := os.Create(filepath.Join(logDir, filename))
if err != nil {
return nil, nil, fmt.Errorf("create log file: %w", err)
}
logger := log.NewWithOptions(f, log.Options{
ReportTimestamp: true,
TimeFormat: time.RFC3339,
Prefix: "ai-agent",
Level: log.DebugLevel,
})
return logger, f, nil
}
+42
View File
@@ -0,0 +1,42 @@
package logging
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewSessionLogger(t *testing.T) {
logger, f, err := NewSessionLogger()
if err != nil {
t.Fatalf("NewSessionLogger() error: %v", err)
}
if f != nil {
defer f.Close()
defer os.Remove(f.Name())
}
if logger == nil {
t.Fatal("logger should not be nil")
}
if f == nil {
t.Fatal("file should not be nil")
}
dir := filepath.Dir(f.Name())
if !strings.Contains(dir, filepath.Join(".config", "ai-agent", "logs")) {
t.Errorf("log file should be in ~/.config/ai-agent/logs/, got %q", dir)
}
logger.Info("test message", "key", "value")
}
func TestNilLoggerNoPanic(t *testing.T) {
var called bool
logger, f, err := NewSessionLogger()
if err == nil && f != nil {
defer f.Close()
defer os.Remove(f.Name())
logger.Info("test")
called = true
}
_ = called
}
+92
View File
@@ -0,0 +1,92 @@
package logging
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type LogEntry struct {
Path string
ModTime time.Time
Size int64
}
func LogDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "ai-agent", "logs")
}
func ListLogs(n int) ([]LogEntry, error) {
return listLogsIn(LogDir(), n)
}
func listLogsIn(dir string, n int) ([]LogEntry, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read log dir: %w", err)
}
var logs []LogEntry
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
logs = append(logs, LogEntry{
Path: filepath.Join(dir, e.Name()),
ModTime: info.ModTime(),
Size: info.Size(),
})
}
sort.Slice(logs, func(i, j int) bool {
return logs[i].ModTime.After(logs[j].ModTime)
})
if n > 0 && n < len(logs) {
logs = logs[:n]
}
return logs, nil
}
func LatestLogPath() (string, error) {
return latestLogPathIn(LogDir())
}
func latestLogPathIn(dir string) (string, error) {
logs, err := listLogsIn(dir, 1)
if err != nil {
return "", err
}
if len(logs) == 0 {
return "", fmt.Errorf("no log files found in %s", dir)
}
return logs[0].Path, nil
}
func TailLog(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open log: %w", err)
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read log: %w", err)
}
if n > 0 && n < len(lines) {
lines = lines[len(lines)-n:]
}
return lines, nil
}
+157
View File
@@ -0,0 +1,157 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
// helper creates n temp log files in dir with distinct mod times.
func createFakeLogs(t *testing.T, dir string, n int) []string {
t.Helper()
var paths []string
for i := range n {
name := filepath.Join(dir, "2025-01-01_00-00-0"+string(rune('0'+i))+".log")
if err := os.WriteFile(name, []byte("line "+string(rune('0'+i))+"\n"), 0o644); err != nil {
t.Fatal(err)
}
// Stagger mod times so ordering is deterministic.
ts := time.Now().Add(time.Duration(i) * time.Second)
if err := os.Chtimes(name, ts, ts); err != nil {
t.Fatal(err)
}
paths = append(paths, name)
}
return paths
}
func TestListLogs(t *testing.T) {
dir := t.TempDir()
createFakeLogs(t, dir, 5)
logs, err := listLogsIn(dir, 3)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 3 {
t.Fatalf("expected 3 entries, got %d", len(logs))
}
// Verify newest-first ordering.
for i := 1; i < len(logs); i++ {
if logs[i].ModTime.After(logs[i-1].ModTime) {
t.Errorf("entry %d (%v) is newer than entry %d (%v)", i, logs[i].ModTime, i-1, logs[i-1].ModTime)
}
}
}
func TestListLogs_All(t *testing.T) {
dir := t.TempDir()
createFakeLogs(t, dir, 4)
logs, err := listLogsIn(dir, 0)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 4 {
t.Fatalf("expected 4 entries, got %d", len(logs))
}
}
func TestListLogs_EmptyDir(t *testing.T) {
dir := t.TempDir()
logs, err := listLogsIn(dir, 5)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 0 {
t.Fatalf("expected 0 entries, got %d", len(logs))
}
}
func TestListLogs_MissingDir(t *testing.T) {
_, err := listLogsIn("/tmp/nonexistent-log-dir-test-xyz", 5)
if err == nil {
t.Fatal("expected error for missing dir")
}
}
func TestTailLog(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
content := "line1\nline2\nline3\nline4\nline5\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
lines, err := TailLog(path, 3)
if err != nil {
t.Fatalf("TailLog error: %v", err)
}
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
}
if lines[0] != "line3" {
t.Errorf("expected 'line3', got %q", lines[0])
}
if lines[2] != "line5" {
t.Errorf("expected 'line5', got %q", lines[2])
}
}
func TestTailLog_FewerLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "short.log")
if err := os.WriteFile(path, []byte("only\n"), 0o644); err != nil {
t.Fatal(err)
}
lines, err := TailLog(path, 100)
if err != nil {
t.Fatalf("TailLog error: %v", err)
}
if len(lines) != 1 {
t.Fatalf("expected 1 line, got %d", len(lines))
}
}
func TestTailLog_MissingFile(t *testing.T) {
_, err := TailLog("/tmp/nonexistent-file-test-xyz.log", 10)
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLatestLogPath(t *testing.T) {
dir := t.TempDir()
paths := createFakeLogs(t, dir, 3)
latest, err := latestLogPathIn(dir)
if err != nil {
t.Fatalf("latestLogPathIn error: %v", err)
}
// The last created file has the newest mod time.
expected := paths[len(paths)-1]
if latest != expected {
t.Errorf("expected %q, got %q", expected, latest)
}
}
func TestLatestLogPath_EmptyDir(t *testing.T) {
dir := t.TempDir()
_, err := latestLogPathIn(dir)
if err == nil {
t.Fatal("expected error for empty dir")
}
}
func TestLogDir(t *testing.T) {
dir := LogDir()
if dir == "" {
t.Fatal("LogDir should not be empty")
}
if filepath.Base(dir) != "logs" {
t.Errorf("expected dir to end in 'logs', got %q", dir)
}
}
+110
View File
@@ -0,0 +1,110 @@
package mcp
import (
"context"
"fmt"
"os/exec"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
type MCPClient struct {
name string
client *sdkmcp.Client
session *sdkmcp.ClientSession
cmd *exec.Cmd
}
func Connect(ctx context.Context, name, command string, args []string, env []string, transport, url string) (*MCPClient, error) {
client := sdkmcp.NewClient(
&sdkmcp.Implementation{Name: "ai-agent", Version: "0.2.0"},
nil,
)
var t sdkmcp.Transport
switch transport {
case "sse":
if url == "" {
return nil, fmt.Errorf("sse transport requires url for %s", name)
}
t = &sdkmcp.SSEClientTransport{Endpoint: url}
case "streamable-http":
if url == "" {
return nil, fmt.Errorf("streamable-http transport requires url for %s", name)
}
t = &sdkmcp.StreamableClientTransport{Endpoint: url}
default:
if command == "" {
return nil, fmt.Errorf("stdio transport requires command for %s", name)
}
cmd := exec.Command(command, args...)
if len(env) > 0 {
cmd.Env = append(cmd.Environ(), env...)
}
t = &sdkmcp.CommandTransport{Command: cmd}
}
session, err := client.Connect(ctx, t, nil)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
return &MCPClient{
name: name,
client: client,
session: session,
}, nil
}
func (c *MCPClient) Name() string { return c.name }
func (c *MCPClient) ListTools(ctx context.Context) ([]*sdkmcp.Tool, error) {
caps := c.session.InitializeResult()
if caps == nil || caps.Capabilities.Tools == nil {
return nil, nil
}
var tools []*sdkmcp.Tool
for tool, err := range c.session.Tools(ctx, nil) {
if err != nil {
return tools, fmt.Errorf("list tools from %s: %w", c.name, err)
}
tools = append(tools, tool)
}
return tools, nil
}
func (c *MCPClient) CallTool(ctx context.Context, name string, args map[string]any) (*ToolResult, error) {
result, err := c.session.CallTool(ctx, &sdkmcp.CallToolParams{
Name: name,
Arguments: args,
})
if err != nil {
return nil, fmt.Errorf("call tool %s on %s: %w", name, c.name, err)
}
var text string
for _, ct := range result.Content {
if tc, ok := ct.(*sdkmcp.TextContent); ok {
if text != "" {
text += "\n"
}
text += tc.Text
}
}
return &ToolResult{Content: text, IsError: result.IsError}, nil
}
func (c *MCPClient) Close() error {
if c.session != nil {
return c.session.Close()
}
return nil
}
func (c *MCPClient) IsConnected() bool {
return c.session != nil
}
func (c *MCPClient) Ping(ctx context.Context) error {
if c.session == nil {
return fmt.Errorf("no session")
}
_, err := c.ListTools(ctx)
return err
}
+241
View File
@@ -0,0 +1,241 @@
package mcp
import (
"context"
"fmt"
"sync"
"time"
"ai-agent/internal/config"
"ai-agent/internal/llm"
)
type FailedServer struct {
Name string
Reason string
}
type ServerStatus struct {
Name string
Connected bool
LastError string
LastPing time.Time
}
type Registry struct {
mu sync.RWMutex
clients []*MCPClient
toolMap map[string]*MCPClient
toolDefs []llm.ToolDef
failedServers []FailedServer
serverConfigs map[string]config.ServerConfig
}
func NewRegistry() *Registry {
return &Registry{toolMap: make(map[string]*MCPClient), serverConfigs: make(map[string]config.ServerConfig)}
}
const connectTimeout = 5 * time.Second
func (r *Registry) ConnectServer(ctx context.Context, srv config.ServerConfig) (int, error) {
connCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
client, err := Connect(connCtx, srv.Name, srv.Command, srv.Args, srv.Env, srv.Transport, srv.URL)
if err != nil {
r.mu.Lock()
r.failedServers = append(r.failedServers, FailedServer{Name: srv.Name, Reason: err.Error()})
r.mu.Unlock()
return 0, fmt.Errorf("connect to %s: %w", srv.Name, err)
}
tools, err := client.ListTools(connCtx)
if err != nil {
client.Close()
r.mu.Lock()
r.failedServers = append(r.failedServers, FailedServer{Name: srv.Name, Reason: err.Error()})
r.mu.Unlock()
return 0, fmt.Errorf("%s tools: %w", srv.Name, err)
}
r.mu.Lock()
r.clients = append(r.clients, client)
for _, tool := range tools {
r.toolMap[tool.Name] = client
r.toolDefs = append(r.toolDefs, ToLLMToolDef(tool.Name, tool.Description, tool.InputSchema))
}
r.serverConfigs[srv.Name] = srv
r.mu.Unlock()
return len(tools), nil
}
func (r *Registry) ConnectAll(ctx context.Context, servers []config.ServerConfig, logFn func(string)) {
for _, srv := range servers {
toolCount, err := r.ConnectServer(ctx, srv)
if err != nil {
logFn(fmt.Sprintf("skip %s: %v", srv.Name, err))
continue
}
logFn(fmt.Sprintf("connected %s (%d tools)", srv.Name, toolCount))
}
}
func (r *Registry) Tools() []llm.ToolDef {
r.mu.RLock()
defer r.mu.RUnlock()
return r.toolDefs
}
func (r *Registry) ToolCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.toolDefs)
}
func (r *Registry) ServerCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.clients)
}
func (r *Registry) ServerNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, len(r.clients))
for i, c := range r.clients {
names[i] = c.Name()
}
return names
}
func (r *Registry) FailedServers() []FailedServer {
r.mu.RLock()
defer r.mu.RUnlock()
return r.failedServers
}
func (r *Registry) CallTool(ctx context.Context, name string, args map[string]any) (*ToolResult, error) {
r.mu.RLock()
client, ok := r.toolMap[name]
r.mu.RUnlock()
if !ok {
return &ToolResult{
Content: fmt.Sprintf("unknown tool: %s", name),
IsError: true,
}, nil
}
return client.CallTool(ctx, name, args)
}
func (r *Registry) Close() {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range r.clients {
c.Close()
}
r.clients = nil
r.toolMap = make(map[string]*MCPClient)
r.toolDefs = nil
}
func (r *Registry) HealthCheck(ctx context.Context) []ServerStatus {
r.mu.RLock()
defer r.mu.RUnlock()
var results []ServerStatus
for _, client := range r.clients {
status := ServerStatus{Name: client.Name()}
if client.IsConnected() {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := client.Ping(pingCtx)
cancel()
status.Connected = err == nil
if err != nil {
status.LastError = err.Error()
}
status.LastPing = time.Now()
}
results = append(results, status)
}
for _, failed := range r.failedServers {
results = append(results, ServerStatus{
Name: failed.Name,
Connected: false,
LastError: failed.Reason,
})
}
return results
}
func (r *Registry) ReconnectServer(ctx context.Context, name string) (int, error) {
r.mu.RLock()
srv, ok := r.serverConfigs[name]
r.mu.RUnlock()
if !ok {
return 0, fmt.Errorf("no config found for server: %s", name)
}
r.mu.Lock()
var remainingFailed []FailedServer
for _, f := range r.failedServers {
if f.Name != name {
remainingFailed = append(remainingFailed, f)
}
}
r.failedServers = remainingFailed
r.mu.Unlock()
return r.ConnectServer(ctx, srv)
}
type MonitorConfig struct {
Interval time.Duration
MaxRetries int
BackoffBase time.Duration
}
var defaultMonitorConfig = MonitorConfig{
Interval: 30 * time.Second,
MaxRetries: 3,
BackoffBase: 5 * time.Second,
}
func (r *Registry) StartHealthMonitor(ctx context.Context, cfg MonitorConfig, logFn func(string)) context.CancelFunc {
if cfg.Interval == 0 {
cfg = defaultMonitorConfig
}
monitorCtx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
for {
select {
case <-monitorCtx.Done():
return
case <-ticker.C:
r.healthCheckRound(monitorCtx, cfg, logFn)
}
}
}()
return cancel
}
func (r *Registry) healthCheckRound(ctx context.Context, cfg MonitorConfig, logFn func(string)) {
statuses := r.HealthCheck(ctx)
for _, status := range statuses {
if status.Connected {
continue
}
logFn(fmt.Sprintf("server %s unhealthy, attempting reconnect...", status.Name))
for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
backoff := cfg.BackoffBase * time.Duration(attempt)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
_, err := r.ReconnectServer(ctx, status.Name)
if err == nil {
logFn(fmt.Sprintf("server %s reconnected", status.Name))
break
}
if attempt == cfg.MaxRetries {
logFn(fmt.Sprintf("server %s reconnection failed after %d attempts: %v", status.Name, cfg.MaxRetries, err))
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package mcp
import (
"context"
"strings"
"testing"
)
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if r.ToolCount() != 0 {
t.Errorf("ToolCount() = %d, want 0", r.ToolCount())
}
if r.ServerCount() != 0 {
t.Errorf("ServerCount() = %d, want 0", r.ServerCount())
}
if tools := r.Tools(); len(tools) != 0 {
t.Errorf("Tools() = %v, want empty", tools)
}
}
func TestRegistry_CallTool_Unknown(t *testing.T) {
r := NewRegistry()
result, err := r.CallTool(context.Background(), "nonexistent_tool", nil)
if err != nil {
t.Fatalf("CallTool() unexpected error: %v", err)
}
if !result.IsError {
t.Error("CallTool() IsError = false, want true for unknown tool")
}
if !strings.Contains(result.Content, "unknown tool") {
t.Errorf("CallTool() Content = %q, want to contain 'unknown tool'", result.Content)
}
}
func TestRegistry_HealthCheck_Empty(t *testing.T) {
r := NewRegistry()
statuses := r.HealthCheck(context.Background())
if len(statuses) != 0 {
t.Errorf("HealthCheck() returned %d statuses, want 0", len(statuses))
}
}
func TestRegistry_HealthCheck_TracksFailedServers(t *testing.T) {
r := NewRegistry()
// Simulate a failed server by directly adding to failedServers
r.mu.Lock()
r.failedServers = append(r.failedServers, FailedServer{
Name: "failed-server",
Reason: "connection refused",
})
r.mu.Unlock()
statuses := r.HealthCheck(context.Background())
if len(statuses) != 1 {
t.Fatalf("HealthCheck() returned %d statuses, want 1", len(statuses))
}
status := statuses[0]
if status.Name != "failed-server" {
t.Errorf("status.Name = %q, want 'failed-server'", status.Name)
}
if status.Connected {
t.Error("status.Connected = true, want false")
}
if status.LastError != "connection refused" {
t.Errorf("status.LastError = %q, want 'connection refused'", status.LastError)
}
}
+27
View File
@@ -0,0 +1,27 @@
package mcp
import (
"ai-agent/internal/llm"
)
type ServerInfo struct {
Name string
ToolCount int
}
type ToolResult struct {
Content string
IsError bool
}
func ToLLMToolDef(name, description string, inputSchema any) llm.ToolDef {
params, _ := inputSchema.(map[string]any)
if params == nil {
params = map[string]any{"type": "object", "properties": map[string]any{}}
}
return llm.ToolDef{
Name: name,
Description: description,
Parameters: params,
}
}
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"testing"
)
func TestToLLMToolDef(t *testing.T) {
tests := []struct {
name string
toolName string
description string
inputSchema any
wantName string
wantDesc string
wantParams bool // true = should have non-nil params
}{
{
name: "normal with valid schema",
toolName: "read_file",
description: "Read a file",
inputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
wantName: "read_file",
wantDesc: "Read a file",
wantParams: true,
},
{
name: "nil schema uses default",
toolName: "noop",
description: "No-op tool",
inputSchema: nil,
wantName: "noop",
wantDesc: "No-op tool",
wantParams: true,
},
{
name: "non-map schema uses default",
toolName: "bad_schema",
description: "Bad schema tool",
inputSchema: "not a map",
wantName: "bad_schema",
wantDesc: "Bad schema tool",
wantParams: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToLLMToolDef(tt.toolName, tt.description, tt.inputSchema)
if result.Name != tt.wantName {
t.Errorf("Name = %q, want %q", result.Name, tt.wantName)
}
if result.Description != tt.wantDesc {
t.Errorf("Description = %q, want %q", result.Description, tt.wantDesc)
}
if tt.wantParams && result.Parameters == nil {
t.Error("Parameters should not be nil")
}
// Nil and non-map schemas should get the default object schema.
if tt.inputSchema == nil || func() bool { _, ok := tt.inputSchema.(map[string]any); return !ok }() {
if result.Parameters["type"] != "object" {
t.Errorf("default schema type = %v, want 'object'", result.Parameters["type"])
}
}
})
}
}
+259
View File
@@ -0,0 +1,259 @@
package memory
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type Memory struct {
ID int `json:"id"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
type Store struct {
mu sync.Mutex
path string
memories []Memory
nextID int
}
func NewStore(path string) *Store {
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
path = filepath.Join(home, ".config", "ai-agent", "memories.json")
}
s := &Store{path: path}
s.load()
return s
}
func (s *Store) Save(content string, tags []string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
mem := Memory{
ID: s.nextID,
Content: content,
Tags: tags,
CreatedAt: time.Now(),
LastUsed: time.Now(),
}
s.memories = append(s.memories, mem)
if err := s.persist(); err != nil {
return 0, err
}
return mem.ID, nil
}
func (s *Store) Recall(query string, maxResults int) []Memory {
s.mu.Lock()
defer s.mu.Unlock()
if maxResults <= 0 {
maxResults = 5
}
queryLower := strings.ToLower(query)
words := strings.Fields(queryLower)
type scored struct {
mem Memory
score int
}
var results []scored
for i := range s.memories {
mem := s.memories[i]
score := 0
contentLower := strings.ToLower(mem.Content)
for _, w := range words {
if strings.Contains(contentLower, w) {
score += 2
}
}
for _, tag := range mem.Tags {
tagLower := strings.ToLower(tag)
for _, w := range words {
if strings.Contains(tagLower, w) {
score += 3
}
}
}
if score > 0 {
results = append(results, scored{mem: mem, score: score})
}
}
sort.Slice(results, func(i, j int) bool {
if results[i].score != results[j].score {
return results[i].score > results[j].score
}
return results[i].mem.LastUsed.After(results[j].mem.LastUsed)
})
if len(results) > maxResults {
results = results[:maxResults]
}
now := time.Now()
out := make([]Memory, len(results))
for i, r := range results {
out[i] = r.mem
for j := range s.memories {
if s.memories[j].ID == r.mem.ID {
s.memories[j].LastUsed = now
break
}
}
}
_ = s.persist()
return out
}
func (s *Store) Recent(n int) []Memory {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.memories) == 0 {
return nil
}
sorted := make([]Memory, len(s.memories))
copy(sorted, s.memories)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].LastUsed.After(sorted[j].LastUsed)
})
if n > len(sorted) {
n = len(sorted)
}
return sorted[:n]
}
func (s *Store) Count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.memories)
}
func (s *Store) Delete(id int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i, mem := range s.memories {
if mem.ID == id {
s.memories = append(s.memories[:i], s.memories[i+1:]...)
return true, s.persist()
}
}
return false, nil
}
func (s *Store) DeleteByTag(tag string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
tagLower := strings.ToLower(tag)
var remaining []Memory
deleted := 0
for _, mem := range s.memories {
found := false
for _, t := range mem.Tags {
if strings.ToLower(t) == tagLower {
found = true
break
}
}
if found {
deleted++
} else {
remaining = append(remaining, mem)
}
}
s.memories = remaining
if deleted > 0 {
return deleted, s.persist()
}
return deleted, nil
}
func (s *Store) Update(id int, content string, tags []string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i, mem := range s.memories {
if mem.ID == id {
if content != "" {
s.memories[i].Content = content
}
if tags != nil {
s.memories[i].Tags = tags
}
s.memories[i].LastUsed = time.Now()
return true, s.persist()
}
}
return false, nil
}
func (s *Store) Prune(olderThan time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-olderThan)
var remaining []Memory
deleted := 0
for _, mem := range s.memories {
if mem.CreatedAt.Before(cutoff) {
deleted++
} else {
remaining = append(remaining, mem)
}
}
s.memories = remaining
if deleted > 0 {
return deleted, s.persist()
}
return deleted, nil
}
func (s *Store) Get(id int) (Memory, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, mem := range s.memories {
if mem.ID == id {
return mem, true
}
}
return Memory{}, false
}
func (s *Store) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
var memories []Memory
if err := json.Unmarshal(data, &memories); err != nil {
return
}
s.memories = memories
for _, m := range s.memories {
if m.ID > s.nextID {
s.nextID = m.ID
}
}
}
func (s *Store) persist() error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create memory dir: %w", err)
}
data, err := json.MarshalIndent(s.memories, "", " ")
if err != nil {
return fmt.Errorf("marshal memories: %w", err)
}
if err := os.WriteFile(s.path, data, 0o644); err != nil {
return fmt.Errorf("write memories: %w", err)
}
return nil
}
+381
View File
@@ -0,0 +1,381 @@
package memory
import (
"path/filepath"
"testing"
"time"
)
func TestStore_Save_And_Count(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
if s.Count() != 0 {
t.Fatalf("new store Count = %d, want 0", s.Count())
}
id1, err := s.Save("first memory", []string{"tag1"})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
if id1 != 1 {
t.Errorf("first Save id = %d, want 1", id1)
}
if s.Count() != 1 {
t.Errorf("Count after first Save = %d, want 1", s.Count())
}
id2, err := s.Save("second memory", []string{"tag2"})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
if id2 != 2 {
t.Errorf("second Save id = %d, want 2", id2)
}
if s.Count() != 2 {
t.Errorf("Count after second Save = %d, want 2", s.Count())
}
}
func TestStore_Recall(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
s.Save("the user prefers Go language", []string{"preference", "golang"})
s.Save("project uses PostgreSQL database", []string{"tech", "database"})
s.Save("user name is Alice", []string{"name"})
t.Run("content match", func(t *testing.T) {
results := s.Recall("Go", 10)
if len(results) == 0 {
t.Fatal("expected results for 'Go' query")
}
found := false
for _, r := range results {
if r.Content == "the user prefers Go language" {
found = true
}
}
if !found {
t.Error("expected to find 'the user prefers Go language'")
}
})
t.Run("tag match", func(t *testing.T) {
results := s.Recall("golang", 10)
if len(results) == 0 {
t.Fatal("expected results for 'golang' tag query")
}
if results[0].Content != "the user prefers Go language" {
t.Errorf("top result = %q, want 'the user prefers Go language'", results[0].Content)
}
})
t.Run("combined scoring", func(t *testing.T) {
// "database" matches both content and tag for PostgreSQL entry.
results := s.Recall("database", 10)
if len(results) == 0 {
t.Fatal("expected results for 'database' query")
}
if results[0].Content != "project uses PostgreSQL database" {
t.Errorf("top result = %q, want 'project uses PostgreSQL database'",
results[0].Content)
}
})
t.Run("maxResults limit", func(t *testing.T) {
results := s.Recall("user", 1)
if len(results) > 1 {
t.Errorf("maxResults=1 but got %d results", len(results))
}
})
t.Run("default maxResults 5 when 0", func(t *testing.T) {
// With 3 memories, should return all 3 (default limit is 5).
results := s.Recall("user", 0)
if len(results) > 5 {
t.Errorf("default maxResults should be 5, got %d results", len(results))
}
})
t.Run("case insensitive", func(t *testing.T) {
results := s.Recall("ALICE", 10)
if len(results) == 0 {
t.Fatal("expected case-insensitive match for 'ALICE'")
}
if results[0].Content != "user name is Alice" {
t.Errorf("result = %q, want 'user name is Alice'", results[0].Content)
}
})
t.Run("no matches", func(t *testing.T) {
results := s.Recall("xyzzyzxyz", 10)
if len(results) != 0 {
t.Errorf("expected no results for nonsense query, got %d", len(results))
}
})
}
func TestStore_Recall_TieBreakByRecency(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
// Save two memories with the same scoring potential.
s.Save("alpha topic info", []string{"info"})
// Small delay so LastUsed differs.
time.Sleep(10 * time.Millisecond)
s.Save("beta topic info", []string{"info"})
results := s.Recall("info", 10)
if len(results) < 2 {
t.Fatalf("expected at least 2 results, got %d", len(results))
}
// Both match tag "info" equally (+3), so more recent (beta) should come first.
if results[0].Content != "beta topic info" {
t.Errorf("expected more recent 'beta topic info' first, got %q", results[0].Content)
}
}
func TestStore_Recent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
s.Save("old memory", nil)
time.Sleep(10 * time.Millisecond)
s.Save("new memory", nil)
t.Run("ordering by LastUsed", func(t *testing.T) {
recent := s.Recent(2)
if len(recent) != 2 {
t.Fatalf("Recent(2) returned %d, want 2", len(recent))
}
if recent[0].Content != "new memory" {
t.Errorf("first recent = %q, want 'new memory'", recent[0].Content)
}
if recent[1].Content != "old memory" {
t.Errorf("second recent = %q, want 'old memory'", recent[1].Content)
}
})
t.Run("limit exceeds count returns all", func(t *testing.T) {
recent := s.Recent(100)
if len(recent) != 2 {
t.Errorf("Recent(100) returned %d, want 2", len(recent))
}
})
t.Run("empty store", func(t *testing.T) {
emptyPath := filepath.Join(dir, "empty.json")
empty := NewStore(emptyPath)
recent := empty.Recent(5)
if recent != nil {
t.Errorf("empty Recent should return nil, got %v", recent)
}
})
}
func TestStore_Persistence_RoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s1 := NewStore(path)
s1.Save("persistent memory", []string{"test"})
s1.Save("another memory", []string{"test2"})
// Create new store from same path.
s2 := NewStore(path)
if s2.Count() != 2 {
t.Errorf("reloaded Count = %d, want 2", s2.Count())
}
// Verify data is intact.
recent := s2.Recent(2)
contents := map[string]bool{}
for _, m := range recent {
contents[m.Content] = true
}
if !contents["persistent memory"] {
t.Error("missing 'persistent memory' after reload")
}
if !contents["another memory"] {
t.Error("missing 'another memory' after reload")
}
// Verify IDs continue.
id, err := s2.Save("third", nil)
if err != nil {
t.Fatalf("Save after reload: %v", err)
}
if id != 3 {
t.Errorf("continued id = %d, want 3", id)
}
}
func TestStore_Delete(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
id, _ := s.Save("to be deleted", []string{"temp"})
if s.Count() != 1 {
t.Fatalf("expected 1 memory, got %d", s.Count())
}
deleted, err := s.Delete(id)
if err != nil {
t.Fatalf("Delete returned error: %v", err)
}
if !deleted {
t.Error("Delete returned false for existing memory")
}
if s.Count() != 0 {
t.Errorf("Count after delete = %d, want 0", s.Count())
}
// Try deleting non-existent.
deleted, err = s.Delete(999)
if err != nil {
t.Fatalf("Delete returned error: %v", err)
}
if deleted {
t.Error("Delete should return false for non-existent memory")
}
}
func TestStore_Update(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
id, _ := s.Save("original content", []string{"original"})
updated, err := s.Update(id, "updated content", []string{"updated"})
if err != nil {
t.Fatalf("Update returned error: %v", err)
}
if !updated {
t.Error("Update returned false for existing memory")
}
// Verify update.
mem, found := s.Get(id)
if !found {
t.Fatal("memory not found after update")
}
if mem.Content != "updated content" {
t.Errorf("Content = %q, want 'updated content'", mem.Content)
}
if len(mem.Tags) != 1 || mem.Tags[0] != "updated" {
t.Errorf("Tags = %v, want ['updated']", mem.Tags)
}
// Try updating non-existent.
updated, err = s.Update(999, "test", nil)
if err != nil {
t.Fatalf("Update returned error: %v", err)
}
if updated {
t.Error("Update should return false for non-existent memory")
}
}
func TestStore_DeleteByTag(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
s.Save("keep this 1", []string{"keep"})
s.Save("delete this", []string{"temp"})
s.Save("keep this 2", []string{"keep"})
s.Save("delete this too", []string{"temp"})
s.Save("also keep", []string{"permanent"})
deleted, err := s.DeleteByTag("temp")
if err != nil {
t.Fatalf("DeleteByTag returned error: %v", err)
}
if deleted != 2 {
t.Errorf("DeleteByTag deleted = %d, want 2", deleted)
}
if s.Count() != 3 {
t.Errorf("Count after delete = %d, want 3", s.Count())
}
// Verify only temp memories are gone.
results := s.Recall("keep", 10)
if len(results) != 3 {
t.Errorf("Recall returned %d, want 3", len(results))
}
}
func TestStore_Get(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
id, _ := s.Save("test memory", []string{"tag"})
mem, found := s.Get(id)
if !found {
t.Fatal("Get returned false for existing memory")
}
if mem.Content != "test memory" {
t.Errorf("Content = %q, want 'test memory'", mem.Content)
}
if len(mem.Tags) != 1 || mem.Tags[0] != "tag" {
t.Errorf("Tags = %v, want ['tag']", mem.Tags)
}
// Try getting non-existent.
_, found = s.Get(999)
if found {
t.Error("Get should return false for non-existent memory")
}
}
func TestStore_UpdatePartial(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memories.json")
s := NewStore(path)
id, _ := s.Save("original content", []string{"original", "tags"})
// Update only content, keep tags.
updated, err := s.Update(id, "new content", nil)
if err != nil {
t.Fatalf("Update returned error: %v", err)
}
if !updated {
t.Error("Update returned false")
}
mem, _ := s.Get(id)
if mem.Content != "new content" {
t.Errorf("Content = %q, want 'new content'", mem.Content)
}
// Tags should remain unchanged when nil is passed.
if len(mem.Tags) != 2 {
t.Errorf("Tags = %v, want 2 tags", mem.Tags)
}
// Update only tags, keep content.
updated, err = s.Update(id, "", []string{"only", "tags"})
if err != nil {
t.Fatalf("Update returned error: %v", err)
}
if !updated {
t.Error("Update returned false")
}
mem, _ = s.Get(id)
if mem.Content != "new content" {
t.Errorf("Content changed unexpectedly to %q", mem.Content)
}
if len(mem.Tags) != 2 || mem.Tags[0] != "only" || mem.Tags[1] != "tags" {
t.Errorf("Tags = %v, want ['only', 'tags']", mem.Tags)
}
}
+102
View File
@@ -0,0 +1,102 @@
package memory
import (
"ai-agent/internal/llm"
)
func BuiltinToolDefs() []llm.ToolDef {
return []llm.ToolDef{
{
Name: "memory_save",
Description: "Save an important fact, user preference, or piece of context to persistent memory. Use this proactively when the user shares information worth remembering across sessions.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{
"type": "string",
"description": "The fact or information to remember.",
},
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "Optional tags for categorization (e.g., 'preference', 'project', 'name').",
},
},
"required": []string{"content"},
},
},
{
Name: "memory_recall",
Description: "Search persistent memory for previously saved facts. Use this when you need to recall user preferences, project details, or other saved context.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Search query to find relevant memories.",
},
},
"required": []string{"query"},
},
},
{
Name: "memory_delete",
Description: "Delete a memory by its ID. Use memory_recall or memory_list first to find the ID of the memory you want to delete.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{
"type": "integer",
"description": "The ID of the memory to delete (use memory_list or memory_recall to find IDs).",
},
},
"required": []string{"id"},
},
},
{
Name: "memory_update",
Description: "Update an existing memory's content or tags. Use memory_recall or memory_list first to find the ID.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{
"type": "integer",
"description": "The ID of the memory to update (use memory_list or memory_recall to find IDs).",
},
"content": map[string]any{
"type": "string",
"description": "New content for the memory.",
},
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "New tags for the memory.",
},
},
"required": []string{"id"},
},
},
{
Name: "memory_list",
Description: "List all stored memories with their IDs, content, and tags. Use this to see what has been saved.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "integer",
"description": "Maximum number of memories to return (default: 20).",
},
},
},
},
}
}
func IsBuiltinTool(name string) bool {
switch name {
case "memory_save", "memory_recall", "memory_delete", "memory_update", "memory_list":
return true
default:
return false
}
}
+48
View File
@@ -0,0 +1,48 @@
package memory
import "testing"
func TestBuiltinToolDefs(t *testing.T) {
defs := BuiltinToolDefs()
if len(defs) != 5 {
t.Fatalf("BuiltinToolDefs() returned %d defs, want 5", len(defs))
}
names := map[string]bool{}
for _, d := range defs {
names[d.Name] = true
}
expected := []string{"memory_save", "memory_recall", "memory_delete", "memory_update", "memory_list"}
for _, name := range expected {
if !names[name] {
t.Errorf("missing %s tool definition", name)
}
}
}
func TestIsBuiltinTool(t *testing.T) {
tests := []struct {
name string
tool string
want bool
}{
{name: "memory_save", tool: "memory_save", want: true},
{name: "memory_recall", tool: "memory_recall", want: true},
{name: "memory_delete", tool: "memory_delete", want: true},
{name: "memory_update", tool: "memory_update", want: true},
{name: "memory_list", tool: "memory_list", want: true},
{name: "unknown tool", tool: "unknown", want: false},
{name: "empty string", tool: "", want: false},
{name: "partial match", tool: "memory_", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsBuiltinTool(tt.tool)
if got != tt.want {
t.Errorf("IsBuiltinTool(%q) = %v, want %v", tt.tool, got, tt.want)
}
})
}
}
+158
View File
@@ -0,0 +1,158 @@
package permission
import (
"context"
"sync"
"ai-agent/internal/db"
)
type Policy string
const (
PolicyAllow Policy = "allow"
PolicyDeny Policy = "deny"
PolicyAsk Policy = "ask"
)
type Checker struct {
store *db.Store
cache map[string]Policy
mu sync.RWMutex
yolo bool
}
func NewChecker(store *db.Store, yolo bool) *Checker {
c := &Checker{
store: store,
cache: make(map[string]Policy),
yolo: yolo,
}
if store != nil {
c.loadFromDB()
}
return c
}
func (c *Checker) Check(toolName string) Policy {
if c.yolo {
return PolicyAllow
}
c.mu.RLock()
defer c.mu.RUnlock()
if p, ok := c.cache[toolName]; ok {
return p
}
return PolicyAsk
}
func (c *Checker) SetPolicy(toolName string, policy Policy) {
c.mu.Lock()
c.cache[toolName] = policy
c.mu.Unlock()
if c.store != nil {
c.store.UpsertToolPermission(context.Background(), db.UpsertToolPermissionParams{
ToolName: toolName,
Policy: string(policy),
})
}
}
func (c *Checker) IsYolo() bool {
return c.yolo
}
func (c *Checker) AllPolicies() map[string]Policy {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string]Policy, len(c.cache))
for k, v := range c.cache {
result[k] = v
}
return result
}
func (c *Checker) Reset() {
c.mu.Lock()
c.cache = make(map[string]Policy)
c.mu.Unlock()
if c.store != nil {
c.store.ResetToolPermissions(context.Background())
}
}
func (c *Checker) loadFromDB() {
perms, err := c.store.ListToolPermissions(context.Background())
if err != nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for _, p := range perms {
switch Policy(p.Policy) {
case PolicyAllow, PolicyDeny, PolicyAsk:
c.cache[p.ToolName] = Policy(p.Policy)
}
}
}
type ApprovalRequest struct {
ToolName string
Args map[string]any
Response chan ApprovalResponse
}
type ApprovalResponse struct {
Allowed bool
Always bool
}
func RequestApproval(toolName string, args map[string]any, callback func(ApprovalRequest)) (bool, bool) {
if callback == nil {
return true, false
}
ch := make(chan ApprovalResponse, 1)
callback(ApprovalRequest{
ToolName: toolName,
Args: args,
Response: ch,
})
resp := <-ch
return resp.Allowed, resp.Always
}
type CheckResult int
const (
CheckAllow CheckResult = iota
CheckDeny
CheckAsk
)
func (c *Checker) ToCheckResult(toolName string) CheckResult {
if c == nil || c.yolo {
return CheckAllow
}
switch c.Check(toolName) {
case PolicyAllow:
return CheckAllow
case PolicyDeny:
return CheckDeny
default:
return CheckAsk
}
}
func NilSafe(store *db.Store, yolo bool) *Checker {
return NewChecker(store, yolo)
}
var AlwaysAllow = func(_ ApprovalRequest) {}
type ErrDenied struct {
ToolName string
}
func (e *ErrDenied) Error() string {
return "tool call denied by permission policy: " + e.ToolName
}
+98
View File
@@ -0,0 +1,98 @@
package permission
import (
"path/filepath"
"testing"
"ai-agent/internal/db"
)
func TestChecker_DefaultPolicy(t *testing.T) {
c := NewChecker(nil, false)
if got := c.Check("some_tool"); got != PolicyAsk {
t.Errorf("Check() = %q, want %q", got, PolicyAsk)
}
}
func TestChecker_Yolo(t *testing.T) {
c := NewChecker(nil, true)
if got := c.Check("any_tool"); got != PolicyAllow {
t.Errorf("Check() = %q, want %q", got, PolicyAllow)
}
if !c.IsYolo() {
t.Error("expected IsYolo() = true")
}
}
func TestChecker_SetPolicy(t *testing.T) {
c := NewChecker(nil, false)
c.SetPolicy("bash", PolicyAllow)
if got := c.Check("bash"); got != PolicyAllow {
t.Errorf("Check() = %q, want %q", got, PolicyAllow)
}
c.SetPolicy("bash", PolicyDeny)
if got := c.Check("bash"); got != PolicyDeny {
t.Errorf("Check() = %q, want %q", got, PolicyDeny)
}
}
func TestChecker_WithDB(t *testing.T) {
store, err := db.OpenPath(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
c := NewChecker(store, false)
c.SetPolicy("file_write", PolicyAllow)
c2 := NewChecker(store, false)
if got := c2.Check("file_write"); got != PolicyAllow {
t.Errorf("persisted Check() = %q, want %q", got, PolicyAllow)
}
}
func TestChecker_Reset(t *testing.T) {
c := NewChecker(nil, false)
c.SetPolicy("tool1", PolicyAllow)
c.SetPolicy("tool2", PolicyDeny)
c.Reset()
if got := c.Check("tool1"); got != PolicyAsk {
t.Errorf("after reset Check() = %q, want %q", got, PolicyAsk)
}
}
func TestChecker_AllPolicies(t *testing.T) {
c := NewChecker(nil, false)
c.SetPolicy("a", PolicyAllow)
c.SetPolicy("b", PolicyDeny)
policies := c.AllPolicies()
if len(policies) != 2 {
t.Errorf("AllPolicies() len = %d, want 2", len(policies))
}
if policies["a"] != PolicyAllow {
t.Errorf("policies[a] = %q, want %q", policies["a"], PolicyAllow)
}
}
func TestToCheckResult(t *testing.T) {
c := NewChecker(nil, false)
c.SetPolicy("allowed", PolicyAllow)
c.SetPolicy("denied", PolicyDeny)
if c.ToCheckResult("allowed") != CheckAllow {
t.Error("expected CheckAllow for allowed tool")
}
if c.ToCheckResult("denied") != CheckDeny {
t.Error("expected CheckDeny for denied tool")
}
if c.ToCheckResult("unknown") != CheckAsk {
t.Error("expected CheckAsk for unknown tool")
}
}
func TestToCheckResult_Nil(t *testing.T) {
var c *Checker
if c.ToCheckResult("anything") != CheckAllow {
t.Error("nil checker should return CheckAllow")
}
}
+118
View File
@@ -0,0 +1,118 @@
package skill
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Manager struct {
skills []*Skill
dirs []string
}
func NewManager(dir string) *Manager {
dirs := []string{}
if dir != "" {
dirs = append(dirs, dir)
} else {
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".config", "ai-agent", "skills"))
}
}
return &Manager{dirs: dirs}
}
func (m *Manager) AddSearchPath(dir string) {
for _, d := range m.dirs {
if d == dir {
return
}
}
m.dirs = append(m.dirs, dir)
}
func (m *Manager) Names() []string {
var names []string
for _, s := range m.skills {
names = append(names, s.Name)
}
return names
}
func (m *Manager) LoadAll() error {
for _, dir := range m.dirs {
if err := m.loadFromDir(dir); err != nil {
return err
}
}
return nil
}
func (m *Manager) loadFromDir(dir string) error {
if dir == "" {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read skills dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
skill, err := parseFrontmatter(string(data))
if err != nil {
continue
}
skill.Path = path
if skill.Name == "" {
skill.Name = strings.TrimSuffix(entry.Name(), ".md")
}
m.skills = append(m.skills, skill)
}
return nil
}
func (m *Manager) All() []*Skill {
return m.skills
}
func (m *Manager) Activate(name string) error {
for _, s := range m.skills {
if s.Name == name {
s.Active = true
return nil
}
}
return fmt.Errorf("skill not found: %s", name)
}
func (m *Manager) Deactivate(name string) error {
for _, s := range m.skills {
if s.Name == name {
s.Active = false
return nil
}
}
return fmt.Errorf("skill not found: %s", name)
}
func (m *Manager) ActiveContent() string {
var parts []string
for _, s := range m.skills {
if s.Active && s.Content != "" {
parts = append(parts, fmt.Sprintf("### %s\n%s", s.Name, s.Content))
}
}
return strings.Join(parts, "\n\n")
}
+169
View File
@@ -0,0 +1,169 @@
package skill
import (
"os"
"path/filepath"
"testing"
)
func TestManager_LoadAll(t *testing.T) {
dir := t.TempDir()
// Create valid skill files.
os.WriteFile(filepath.Join(dir, "greeting.md"), []byte("---\nname: greeting\ndescription: Say hello\n---\nHello!"), 0o644)
os.WriteFile(filepath.Join(dir, "farewell.md"), []byte("---\nname: farewell\ndescription: Say bye\n---\nGoodbye!"), 0o644)
// Create a non-.md file (should be skipped).
os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a skill"), 0o644)
// Create a subdirectory (should be skipped).
os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)
m := NewManager(dir)
if err := m.LoadAll(); err != nil {
t.Fatalf("LoadAll: %v", err)
}
skills := m.All()
if len(skills) != 2 {
t.Fatalf("loaded %d skills, want 2", len(skills))
}
names := map[string]bool{}
for _, s := range skills {
names[s.Name] = true
}
if !names["greeting"] {
t.Error("missing 'greeting' skill")
}
if !names["farewell"] {
t.Error("missing 'farewell' skill")
}
}
func TestManager_LoadAll_NoFrontmatter(t *testing.T) {
dir := t.TempDir()
// File without frontmatter uses filename as name.
os.WriteFile(filepath.Join(dir, "plain.md"), []byte("Just content, no frontmatter"), 0o644)
m := NewManager(dir)
if err := m.LoadAll(); err != nil {
t.Fatalf("LoadAll: %v", err)
}
skills := m.All()
if len(skills) != 1 {
t.Fatalf("loaded %d skills, want 1", len(skills))
}
if skills[0].Name != "plain" {
t.Errorf("Name = %q, want 'plain'", skills[0].Name)
}
}
func TestManager_LoadAll_NonexistentDir(t *testing.T) {
m := NewManager("/nonexistent/path/that/does/not/exist")
if err := m.LoadAll(); err != nil {
t.Fatalf("LoadAll on nonexistent dir should not error, got: %v", err)
}
if len(m.All()) != 0 {
t.Errorf("expected 0 skills from nonexistent dir, got %d", len(m.All()))
}
}
func TestManager_Activate_Deactivate(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "test.md"), []byte("---\nname: test\n---\nTest content"), 0o644)
m := NewManager(dir)
m.LoadAll()
t.Run("activate found", func(t *testing.T) {
err := m.Activate("test")
if err != nil {
t.Fatalf("Activate: %v", err)
}
skill := m.All()[0]
if !skill.Active {
t.Error("skill should be active after Activate")
}
})
t.Run("activate not found", func(t *testing.T) {
err := m.Activate("nonexistent")
if err == nil {
t.Error("expected error for nonexistent skill")
}
})
t.Run("deactivate found", func(t *testing.T) {
err := m.Deactivate("test")
if err != nil {
t.Fatalf("Deactivate: %v", err)
}
skill := m.All()[0]
if skill.Active {
t.Error("skill should be inactive after Deactivate")
}
})
t.Run("deactivate not found", func(t *testing.T) {
err := m.Deactivate("nonexistent")
if err == nil {
t.Error("expected error for nonexistent skill")
}
})
}
func TestManager_ActiveContent(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "alpha.md"), []byte("---\nname: alpha\n---\nAlpha content"), 0o644)
os.WriteFile(filepath.Join(dir, "beta.md"), []byte("---\nname: beta\n---\nBeta content"), 0o644)
m := NewManager(dir)
m.LoadAll()
t.Run("none active returns empty", func(t *testing.T) {
content := m.ActiveContent()
if content != "" {
t.Errorf("expected empty content, got %q", content)
}
})
t.Run("one active returns its content", func(t *testing.T) {
m.Activate("alpha")
content := m.ActiveContent()
if content == "" {
t.Fatal("expected non-empty content")
}
if !contains(content, "Alpha content") {
t.Errorf("content missing 'Alpha content': %q", content)
}
if contains(content, "Beta content") {
t.Errorf("content should not contain inactive 'Beta content': %q", content)
}
m.Deactivate("alpha")
})
t.Run("multiple active returns combined", func(t *testing.T) {
m.Activate("alpha")
m.Activate("beta")
content := m.ActiveContent()
if !contains(content, "Alpha content") || !contains(content, "Beta content") {
t.Errorf("combined content missing expected parts: %q", content)
}
})
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+65
View File
@@ -0,0 +1,65 @@
package skill
import (
"bufio"
"strings"
"gopkg.in/yaml.v3"
)
// Skill represents a loadable skill definition.
type Skill struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Active bool `yaml:"-"`
Content string `yaml:"-"` // markdown body after frontmatter
Path string `yaml:"-"` // file path
}
// parseFrontmatter extracts YAML frontmatter and markdown body from a skill file.
// Frontmatter is delimited by "---" on the first and closing lines.
func parseFrontmatter(data string) (*Skill, error) {
scanner := bufio.NewScanner(strings.NewReader(data))
// Check for opening "---".
if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" {
// No frontmatter — treat entire content as body.
return &Skill{Content: data}, nil
}
// Read YAML lines until closing "---".
var yamlBuf strings.Builder
foundEnd := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
foundEnd = true
break
}
yamlBuf.WriteString(line)
yamlBuf.WriteString("\n")
}
if !foundEnd {
// No closing delimiter — treat as body only.
return &Skill{Content: data}, nil
}
// Parse YAML frontmatter.
s := &Skill{}
if err := yaml.Unmarshal([]byte(yamlBuf.String()), s); err != nil {
return nil, err
}
// Remaining content is the markdown body.
var bodyBuf strings.Builder
for scanner.Scan() {
if bodyBuf.Len() > 0 {
bodyBuf.WriteString("\n")
}
bodyBuf.WriteString(scanner.Text())
}
s.Content = strings.TrimSpace(bodyBuf.String())
return s, nil
}
+78
View File
@@ -0,0 +1,78 @@
package skill
import "testing"
func TestParseFrontmatter(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantDesc string
wantContent string
wantErr bool
}{
{
name: "valid frontmatter",
input: "---\nname: test\ndescription: desc\n---\nBody content",
wantName: "test",
wantDesc: "desc",
wantContent: "Body content",
},
{
name: "no frontmatter",
input: "Just body",
wantContent: "Just body",
},
{
name: "missing closing delimiter",
input: "---\nname: test\nBody",
wantContent: "---\nname: test\nBody",
},
{
name: "invalid YAML",
input: "---\n: :\n---\nbody",
wantErr: true,
},
{
name: "empty body",
input: "---\nname: test\n---\n",
wantName: "test",
wantContent: "",
},
{
name: "empty input",
input: "",
wantContent: "",
},
{
name: "multiline body",
input: "---\nname: multi\n---\nline 1\nline 2\nline 3",
wantName: "multi",
wantContent: "line 1\nline 2\nline 3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
skill, err := parseFrontmatter(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if skill.Name != tt.wantName {
t.Errorf("Name = %q, want %q", skill.Name, tt.wantName)
}
if skill.Description != tt.wantDesc {
t.Errorf("Description = %q, want %q", skill.Description, tt.wantDesc)
}
if skill.Content != tt.wantContent {
t.Errorf("Content = %q, want %q", skill.Content, tt.wantContent)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package tools
import (
"ai-agent/internal/llm"
)
var builtinToolNames = map[string]bool{
"grep": true,
"read": true,
"write": true,
"glob": true,
"bash": true,
"ls": true,
"find": true,
"diff": true,
"edit": true,
"mkdir": true,
"remove": true,
"copy": true,
"move": true,
"exists": true,
}
func AllToolDefs() []llm.ToolDef {
return []llm.ToolDef{
GrepToolDef(),
ReadToolDef(),
WriteToolDef(),
GlobToolDef(),
BashToolDef(),
LsToolDef(),
FindToolDef(),
DiffToolDef(),
EditToolDef(),
MkdirToolDef(),
RemoveToolDef(),
CopyToolDef(),
MoveToolDef(),
ExistsToolDef(),
}
}
func IsBuiltinTool(name string) bool {
return builtinToolNames[name]
}
+306
View File
@@ -0,0 +1,306 @@
package tools
import (
"ai-agent/internal/llm"
)
func GrepToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "grep",
Description: "Search for a pattern in files. Use this to find code, text, or values across multiple files.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "The regex pattern to search for.",
},
"path": map[string]any{
"type": "string",
"description": "Directory path to search in (defaults to current directory).",
},
"include": map[string]any{
"type": "string",
"description": "File pattern to include (e.g., '*.go', '*.ts').",
},
"context": map[string]any{
"type": "integer",
"description": "Number of lines of context to show around matches (default: 3).",
},
},
"required": []string{"pattern"},
},
}
}
func ReadToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "read",
Description: "Read the contents of a file. Use this to view source code, configuration files, or any text file.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to read.",
},
"limit": map[string]any{
"type": "integer",
"description": "Maximum number of lines to read (optional).",
},
"offset": map[string]any{
"type": "integer",
"description": "Line number to start reading from (optional, 1-indexed).",
},
},
"required": []string{"path"},
},
}
}
func WriteToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "write",
Description: "Write content to a file. Use this to create new files or overwrite existing ones. Creates parent directories if needed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to write.",
},
"content": map[string]any{
"type": "string",
"description": "Content to write to the file.",
},
},
"required": []string{"path", "content"},
},
}
}
func GlobToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "glob",
Description: "Find files matching a pattern. Use this to discover files by name patterns like '*.go', '**/*.ts', etc.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Glob pattern to match (e.g., '**/*.go', 'src/**/*.ts').",
},
"path": map[string]any{
"type": "string",
"description": "Directory to search in (defaults to current directory).",
},
},
"required": []string{"pattern"},
},
}
}
func BashToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "bash",
Description: "Execute a shell command. Use this to run git, npm, go, or other command-line tools. Output is returned after completion.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "The shell command to execute.",
},
"timeout": map[string]any{
"type": "integer",
"description": "Timeout in seconds (default: 30, max: 120).",
},
},
"required": []string{"command"},
},
}
}
func LsToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "ls",
Description: "List files and directories. Use this to see what's in a directory.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Directory path to list (defaults to current directory).",
},
},
},
}
}
func FindToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "find",
Description: "Find files or directories by name. Use this to locate specific files when you know all or part of the filename.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "Name or pattern to search for (supports * and ? wildcards).",
},
"path": map[string]any{
"type": "string",
"description": "Directory to search in (defaults to current directory).",
},
"type": map[string]any{
"type": "string",
"description": "Type to find: 'f' for files, 'd' for directories (default: both).",
},
},
"required": []string{"name"},
},
}
}
func DiffToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "diff",
Description: "Show the differences between the current file content and new content. Use this to preview changes before writing.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to diff.",
},
"new_content": map[string]any{
"type": "string",
"description": "The new content to compare against the current file.",
},
},
"required": []string{"path", "new_content"},
},
}
}
func EditToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "edit",
Description: "Apply a patch to a file. Use this to make targeted edits to specific lines without overwriting the entire file. The patch format is: @@ -start,count +new_start,new_count @@\nfollowed by lines starting with - (remove), + (add), or (context).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to edit.",
},
"patch": map[string]any{
"type": "string",
"description": "Unified diff patch to apply. Format: @@ -start,count +new_start,new_count @@ followed by -line (remove), +line (add), or context line.",
},
},
"required": []string{"path", "patch"},
},
}
}
func MkdirToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "mkdir",
Description: "Create one or more directories. Creates parent directories as needed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the directory to create.",
},
},
"required": []string{"path"},
},
}
}
func RemoveToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "remove",
Description: "Remove files or directories. Use with caution - this permanently deletes files.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to remove (file or directory).",
},
"recursive": map[string]any{
"type": "boolean",
"description": "Remove directories recursively (default: false).",
},
"force": map[string]any{
"type": "boolean",
"description": "Ignore nonexistent files (default: false).",
},
},
"required": []string{"path"},
},
}
}
func CopyToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "copy",
Description: "Copy a file from source to destination.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"source": map[string]any{
"type": "string",
"description": "Source path to copy from.",
},
"destination": map[string]any{
"type": "string",
"description": "Destination path to copy to.",
},
},
"required": []string{"source", "destination"},
},
}
}
func MoveToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "move",
Description: "Move or rename a file or directory.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"source": map[string]any{
"type": "string",
"description": "Source path to move from.",
},
"destination": map[string]any{
"type": "string",
"description": "Destination path to move to.",
},
},
"required": []string{"source", "destination"},
},
}
}
func ExistsToolDef() llm.ToolDef {
return llm.ToolDef{
Name: "exists",
Description: "Check if a file or directory exists and get information about it.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to check.",
},
},
"required": []string{"path"},
},
}
}
+156
View File
@@ -0,0 +1,156 @@
package tools
import (
"testing"
)
func TestGrepToolDef(t *testing.T) {
tool := GrepToolDef()
if tool.Name != "grep" {
t.Errorf("Name = %q, want %q", tool.Name, "grep")
}
if tool.Description == "" {
t.Error("Description should not be empty")
}
if tool.Parameters == nil {
t.Error("Parameters should not be nil")
}
}
func TestReadToolDef(t *testing.T) {
tool := ReadToolDef()
if tool.Name != "read" {
t.Errorf("Name = %q, want %q", tool.Name, "read")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["path"]; !ok {
t.Error("should have path property")
}
}
func TestWriteToolDef(t *testing.T) {
tool := WriteToolDef()
if tool.Name != "write" {
t.Errorf("Name = %q, want %q", tool.Name, "write")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["path"]; !ok {
t.Error("should have path property")
}
if _, ok := props["content"]; !ok {
t.Error("should have content property")
}
}
func TestGlobToolDef(t *testing.T) {
tool := GlobToolDef()
if tool.Name != "glob" {
t.Errorf("Name = %q, want %q", tool.Name, "glob")
}
}
func TestBashToolDef(t *testing.T) {
tool := BashToolDef()
if tool.Name != "bash" {
t.Errorf("Name = %q, want %q", tool.Name, "bash")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["command"]; !ok {
t.Error("should have command property")
}
}
func TestLsToolDef(t *testing.T) {
tool := LsToolDef()
if tool.Name != "ls" {
t.Errorf("Name = %q, want %q", tool.Name, "ls")
}
}
func TestFindToolDef(t *testing.T) {
tool := FindToolDef()
if tool.Name != "find" {
t.Errorf("Name = %q, want %q", tool.Name, "find")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["name"]; !ok {
t.Error("should have name property")
}
}
func TestDiffToolDef(t *testing.T) {
tool := DiffToolDef()
if tool.Name != "diff" {
t.Errorf("Name = %q, want %q", tool.Name, "diff")
}
}
func TestEditToolDef(t *testing.T) {
tool := EditToolDef()
if tool.Name != "edit" {
t.Errorf("Name = %q, want %q", tool.Name, "edit")
}
}
func TestMkdirToolDef(t *testing.T) {
tool := MkdirToolDef()
if tool.Name != "mkdir" {
t.Errorf("Name = %q, want %q", tool.Name, "mkdir")
}
}
func TestRemoveToolDef(t *testing.T) {
tool := RemoveToolDef()
if tool.Name != "remove" {
t.Errorf("Name = %q, want %q", tool.Name, "remove")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["recursive"]; !ok {
t.Error("should have recursive property")
}
if _, ok := props["force"]; !ok {
t.Error("should have force property")
}
}
func TestCopyToolDef(t *testing.T) {
tool := CopyToolDef()
if tool.Name != "copy" {
t.Errorf("Name = %q, want %q", tool.Name, "copy")
}
props := tool.Parameters["properties"].(map[string]any)
if _, ok := props["source"]; !ok {
t.Error("should have source property")
}
if _, ok := props["destination"]; !ok {
t.Error("should have destination property")
}
}
func TestMoveToolDef(t *testing.T) {
tool := MoveToolDef()
if tool.Name != "move" {
t.Errorf("Name = %q, want %q", tool.Name, "move")
}
}
func TestExistsToolDef(t *testing.T) {
tool := ExistsToolDef()
if tool.Name != "exists" {
t.Errorf("Name = %q, want %q", tool.Name, "exists")
}
}
+162
View File
@@ -0,0 +1,162 @@
# Responsive Width Implementation
## Overview
This document describes the responsive width calculations implemented to prevent horizontal scrolling in the TUI chat interface.
## Width Calculation Hierarchy
### 1. Viewport Width (Primary Constraint)
The viewport is the main container for chat content. All other widths derive from this.
**Formula** (from `model.go:373-380`):
```go
viewportWidth := screenWidth - 1
if sidePanel.IsVisible() {
viewportWidth = screenWidth - panelWidth - 2
}
if viewportWidth < 20 {
viewportWidth = 20 // minimum width
}
```
**Breakdown**:
- `screenWidth - 1`: Full width minus right edge padding (when panel hidden)
- `screenWidth - panelWidth - 2`: Width minus panel and separator line (when panel visible)
- Minimum 20 characters to ensure readability
### 2. Content Width (Text Wrapping)
Used for wrapping text in `renderEntries()`, `renderUserMsg()`, `renderAssistantMsg()`, etc.
**Formula** (from `view.go:422-429`):
```go
contentW := screenWidth - 4
if sidePanel.IsVisible() {
contentW = screenWidth - panelWidth - 5
}
if contentW < 20 {
contentW = 20
}
```
**Breakdown**:
- `screenWidth - 4`: Full width with 2-char padding on each side
- `screenWidth - panelWidth - 5`: Accounts for panel, separator, and padding
- Minimum 20 characters
### 3. Markdown Width (Glamour Rendering)
Used for rendering markdown content via Glamour.
**Formula** (from `model.go:382-386`):
```go
markdownWidth := viewportWidth - 3
if markdownWidth < 20 {
markdownWidth = 20
}
```
**Breakdown**:
- Derived from viewport width minus 3 chars for padding/indentation
- Minimum 20 characters
### 4. Input Width
Matches viewport width exactly for unified appearance.
**Formula** (from `model.go:431`):
```go
input.SetWidth(viewportWidth)
```
## Panel Width Calculation
Panel width is dynamic based on screen size (from `model.go:365-371`):
```go
panelWidth := 30 // default
if screenWidth < 100 {
panelWidth = 25
} else if screenWidth > 160 {
panelWidth = 40
}
```
## Layout Constraints
### With Panel Visible
```
┌─────────────────────────────────────────────────┐
│ Panel (25-40) ││ Chat Viewport │
│ ││ (screen - panel - 2) │
│ ││ │
│ ││ Content wrapped to: │
│ ││ (screen - panel - 5) │
└─────────────────────────────────────────────────┘
```
### Without Panel
```
┌─────────────────────────────────────────────────┐
│ Chat Viewport (screen - 1) │
│ │
│ Content wrapped to: (screen - 4) │
└─────────────────────────────────────────────────┘
```
## Critical Invariants
The following invariants are enforced to prevent horizontal scrolling:
1. **viewportWidth ≤ screenWidth - 1** (or `screenWidth - panelWidth - 1` when panel visible)
2. **contentWidth ≤ viewportWidth**
3. **markdownWidth ≤ viewportWidth**
4. **All widths ≥ 20** (minimum readability)
## Test Coverage
Comprehensive tests in `width_test.go` verify:
- `TestViewportWidthCalculation`: Validates width calculations for various screen sizes
- `TestResponsiveWidthToggle`: Ensures widths adjust correctly when panel is toggled
- `TestMinimumWidthConstraints`: Verifies minimum width enforcement on small screens
- `TestRenderedTextWidth`: Tests actual text wrapping behavior
- `TestLayoutConsistency`: Exhaustive testing across screen sizes 40-200 chars
## Example Calculations
### 120-char screen with panel (30 chars)
```
Viewport: 120 - 30 - 2 = 88 chars
Content: 120 - 30 - 5 = 85 chars
Markdown: 88 - 3 = 85 chars
Input: 88 chars
Total: 30 (panel) + 1 (separator) + 88 (viewport) = 119 ✓
```
### 80-char screen without panel
```
Viewport: 80 - 1 = 79 chars
Content: 80 - 4 = 76 chars
Markdown: 79 - 3 = 76 chars
Input: 79 chars
Total: 79 chars ✓
```
### 40-char screen with panel (25 chars) - Edge Case
```
Viewport: 40 - 25 - 2 = 13 → 20 (minimum enforced)
Content: 40 - 25 - 5 = 10 → 20 (minimum enforced)
Markdown: 20 - 3 = 17 → 20 (minimum enforced)
Input: 20 chars
Total: 25 + 1 + 20 = 46 (exceeds screen, but minimum width takes priority)
```
**Note**: On very small screens (< 46 chars with panel), the minimum width constraints take precedence. Users should be advised to use larger terminal windows for optimal experience.
## Responsive Behavior
When the side panel is toggled:
1. Viewport width recalculates immediately
2. Content is re-wrapped to new width via `invalidateRenderedCache()`
3. Markdown renderer is recreated with new width
4. Input field resizes to match viewport
This ensures seamless responsive behavior without horizontal scrolling.
+240
View File
@@ -0,0 +1,240 @@
package tui
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
)
// AccessibilityHelper provides accessibility features like screen reader support.
type AccessibilityHelper struct {
isDark bool
styles AccessibilityStyles
speakFunc func(string) // Function to speak text (for screen readers)
announceFunc func(string) // Function to announce changes
}
// AccessibilityStyles holds styling.
type AccessibilityStyles struct {
Announce lipgloss.Style
}
// DefaultAccessibilityStyles returns default styles.
func DefaultAccessibilityStyles(isDark bool) AccessibilityStyles {
return AccessibilityStyles{
Announce: lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0")),
}
}
// NewAccessibilityHelper creates a new accessibility helper.
func NewAccessibilityHelper(isDark bool) *AccessibilityHelper {
return &AccessibilityHelper{
isDark: isDark,
styles: DefaultAccessibilityStyles(isDark),
}
}
// SetDark updates theme.
func (ah *AccessibilityHelper) SetDark(isDark bool) {
ah.isDark = isDark
ah.styles = DefaultAccessibilityStyles(isDark)
}
// SetSpeakFunc sets the function to speak text.
func (ah *AccessibilityHelper) SetSpeakFunc(f func(string)) {
ah.speakFunc = f
}
// SetAnnounceFunc sets the function to announce changes.
func (ah *AccessibilityHelper) SetAnnounceFunc(f func(string)) {
ah.announceFunc = f
}
// Announce announces a message to the user.
func (ah *AccessibilityHelper) Announce(format string, args ...string) {
if ah.announceFunc != nil {
msg := format
if len(args) > 0 {
msg = fmt.Sprintf(format, args)
}
ah.announceFunc(msg)
}
}
// Speak speaks text directly.
func (ah *AccessibilityHelper) Speak(text string) {
if ah.speakFunc != nil {
ah.speakFunc(text)
}
}
// DescribeEntry creates an accessibility description for a chat entry.
func (ah *AccessibilityHelper) DescribeEntry(entry ChatEntry, index int, toolCount int) string {
var desc strings.Builder
switch entry.Kind {
case "user":
desc.WriteString("User message")
case "assistant":
desc.WriteString("Assistant response")
if entry.ThinkingContent != "" {
desc.WriteString(", has thinking")
}
case "tool_group":
desc.WriteString("Tool execution")
if index >= 0 && index < toolCount {
desc.WriteString(", tool result")
}
case "system":
desc.WriteString("System message")
case "error":
desc.WriteString("Error")
}
// Add content preview
if entry.Content != "" {
preview := truncateStr(entry.Content, 50)
desc.WriteString(": ")
desc.WriteString(preview)
}
return desc.String()
}
// DescribeState creates an accessibility description of the current state.
func (ah *AccessibilityHelper) DescribeState(state State, model, mode string) string {
var desc string
switch state {
case StateIdle:
desc = "Ready"
case StateWaiting:
desc = "Waiting for response"
case StateStreaming:
desc = "Receiving response"
}
if model != "" {
desc += ", model: " + model
}
if mode != "" {
desc += ", mode: " + mode
}
return desc
}
// DescribeOverlay creates an accessibility description of the current overlay.
func (ah *AccessibilityHelper) DescribeOverlay(overlay OverlayKind) string {
switch overlay {
case OverlayNone:
return ""
case OverlayHelp:
return "Help overlay open"
case OverlayCompletion:
return "Completion menu open"
case OverlayModelPicker:
return "Model picker open"
case OverlayPlanForm:
return "Plan form open"
case OverlaySessionsPicker:
return "Sessions picker open"
default:
return "Overlay open"
}
}
// DescribeTools creates an accessibility description of tool status.
func (ah *AccessibilityHelper) DescribeTools(pending, total int) string {
if pending == 0 && total == 0 {
return "No tools running"
}
if pending > 0 {
return fmt.Sprintf("%d tool running", pending)
}
return fmt.Sprintf("%d tools completed", total)
}
// truncate truncates a string to maxLength.
func truncateStr(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
return s[:maxLength-3] + "..."
}
// AccessibilityLabel returns an accessibility label for a view element.
func AccessibilityLabel(role, name string, props ...string) string {
var b strings.Builder
b.WriteString(role)
b.WriteString(": ")
b.WriteString(name)
for _, p := range props {
b.WriteString(", ")
b.WriteString(p)
}
return b.String()
}
// FocusOrder represents the focus order for keyboard navigation.
type FocusOrder struct {
Current int
Items []Focusable
}
// Focusable is an interface for focusable elements.
type Focusable interface {
Focus() error
Blur() error
IsFocused() bool
}
// NewFocusOrder creates a new focus order.
func NewFocusOrder(items []Focusable) *FocusOrder {
return &FocusOrder{
Current: 0,
Items: items,
}
}
// Next moves focus to the next item.
func (fo *FocusOrder) Next() {
if len(fo.Items) == 0 {
return
}
fo.Current = (fo.Current + 1) % len(fo.Items)
fo.focusCurrent()
}
// Prev moves focus to the previous item.
func (fo *FocusOrder) Prev() {
if len(fo.Items) == 0 {
return
}
fo.Current--
if fo.Current < 0 {
fo.Current = len(fo.Items) - 1
}
fo.focusCurrent()
}
// Current returns the currently focused item.
func (fo *FocusOrder) CurrentItem() Focusable {
if fo.Current >= 0 && fo.Current < len(fo.Items) {
return fo.Items[fo.Current]
}
return nil
}
func (fo *FocusOrder) focusCurrent() {
for i, item := range fo.Items {
if i == fo.Current {
item.Focus()
} else {
item.Blur()
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package tui
import (
"time"
tea "charm.land/bubbletea/v2"
)
// Adapter bridges the agent.Output interface to BubbleTea messages.
type Adapter struct {
program *tea.Program
}
// NewAdapter creates an Adapter that sends messages to the given program.
func NewAdapter(p *tea.Program) *Adapter {
return &Adapter{program: p}
}
func (a *Adapter) StreamText(text string) {
sendMsg(a.program, StreamTextMsg{Text: text})
}
func (a *Adapter) StreamDone(evalCount, promptTokens int) {
sendMsg(a.program, StreamDoneMsg{EvalCount: evalCount, PromptTokens: promptTokens})
}
func (a *Adapter) ToolCallStart(name string, args map[string]any) {
sendMsg(a.program, ToolCallStartMsg{Name: name, Args: args, StartTime: time.Now()})
}
func (a *Adapter) ToolCallResult(name string, result string, isError bool, duration time.Duration) {
sendMsg(a.program, ToolCallResultMsg{Name: name, Result: result, IsError: isError, Duration: duration})
}
func (a *Adapter) SystemMessage(msg string) {
sendMsg(a.program, SystemMessageMsg{Msg: msg})
}
func (a *Adapter) Error(msg string) {
// Log error for debugging
if len(msg) > 100 {
msg = msg[:97] + "..."
}
sendMsg(a.program, ErrorMsg{Msg: msg})
}
// Done sends the final completion message.
func (a *Adapter) Done() {
sendMsg(a.program, AgentDoneMsg{})
}
+127
View File
@@ -0,0 +1,127 @@
package tui
import (
"testing"
)
func TestLastAssistantContent(t *testing.T) {
t.Run("found", func(t *testing.T) {
m := newTestModel(t)
m.entries = []ChatEntry{
{Kind: "user", Content: "hello"},
{Kind: "assistant", Content: "world"},
}
got := m.lastAssistantContent()
if got != "world" {
t.Errorf("expected 'world', got %q", got)
}
})
t.Run("not_found", func(t *testing.T) {
m := newTestModel(t)
m.entries = []ChatEntry{
{Kind: "user", Content: "hello"},
{Kind: "system", Content: "info"},
}
got := m.lastAssistantContent()
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
})
t.Run("returns_last", func(t *testing.T) {
m := newTestModel(t)
m.entries = []ChatEntry{
{Kind: "assistant", Content: "first"},
{Kind: "user", Content: "question"},
{Kind: "assistant", Content: "second"},
}
got := m.lastAssistantContent()
if got != "second" {
t.Errorf("expected 'second', got %q", got)
}
})
t.Run("empty_entries", func(t *testing.T) {
m := newTestModel(t)
m.entries = nil
got := m.lastAssistantContent()
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
})
}
func TestCopyLast_OnlyWhenIdleAndEmpty(t *testing.T) {
t.Run("idle_empty_with_assistant", func(t *testing.T) {
m := newTestModel(t)
m.state = StateIdle
m.entries = []ChatEntry{
{Kind: "assistant", Content: "response text"},
}
m.input.SetValue("")
_, cmd := m.Update(ctrlKey('y'))
if cmd == nil {
t.Error("expected a command to be returned for copy")
}
})
t.Run("non_empty_input_no_trigger", func(t *testing.T) {
m := newTestModel(t)
m.state = StateIdle
m.entries = []ChatEntry{
{Kind: "assistant", Content: "response text"},
}
m.input.SetValue("some text")
_, cmd := m.Update(ctrlKey('y'))
// When input is non-empty, ctrl+y should not trigger copy.
// The cmd may be non-nil (textarea update), but no copy should occur.
// Verify no system message about clipboard appears.
if cmd != nil {
msg := cmd()
if sysMsg, ok := msg.(SystemMessageMsg); ok {
if sysMsg.Msg == "Copied to clipboard." {
t.Error("should not trigger copy when input is non-empty")
}
}
}
})
t.Run("non_idle_no_trigger", func(t *testing.T) {
m := newTestModel(t)
m.state = StateStreaming
m.entries = []ChatEntry{
{Kind: "assistant", Content: "response text"},
}
m.input.SetValue("")
initialEntryCount := len(m.entries)
m.Update(ctrlKey('y'))
// Should not add any system message about clipboard
if len(m.entries) > initialEntryCount {
t.Error("should not trigger copy when not idle")
}
})
t.Run("no_assistant_entries", func(t *testing.T) {
m := newTestModel(t)
m.state = StateIdle
m.entries = []ChatEntry{
{Kind: "user", Content: "hello"},
}
m.input.SetValue("")
_, cmd := m.Update(ctrlKey('y'))
// Should not return a copy command when there's no assistant content
if cmd != nil {
msg := cmd()
if sysMsg, ok := msg.(SystemMessageMsg); ok {
if sysMsg.Msg == "Copied to clipboard." {
t.Error("should not trigger copy when no assistant content")
}
}
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package tui
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"ai-agent/internal/llm"
tea "charm.land/bubbletea/v2"
)
func runCommit(client llm.Client, model string, extraMsg string) tea.Cmd {
return func() tea.Msg {
diff, err := gitDiff()
if err != nil {
return CommitResultMsg{Err: fmt.Errorf("git diff: %w", err)}
}
if strings.TrimSpace(diff) == "" {
return CommitResultMsg{Err: fmt.Errorf("no staged changes (use `git add` first)")}
}
if len(diff) > 8000 {
diff = diff[:8000] + "\n... (truncated)"
}
prompt := "Write a concise git commit message for the following staged diff. " +
"Return ONLY the commit message, no explanation or markdown. " +
"Use conventional commit style (e.g. feat:, fix:, refactor:). " +
"Keep the first line under 72 characters."
if extraMsg != "" {
prompt += "\n\nAdditional context: " + extraMsg
}
prompt += "\n\nDiff:\n" + diff
var msgBuf strings.Builder
err = client.ChatStream(context.Background(), llm.ChatOptions{
Messages: []llm.Message{{Role: "user", Content: prompt}},
System: "You are a helpful assistant that writes git commit messages.",
}, func(chunk llm.StreamChunk) error {
if chunk.Text != "" {
msgBuf.WriteString(chunk.Text)
}
return nil
})
if err != nil {
return CommitResultMsg{Err: fmt.Errorf("LLM error: %w", err)}
}
commitMsg := strings.TrimSpace(msgBuf.String())
if commitMsg == "" {
return CommitResultMsg{Err: fmt.Errorf("LLM returned empty commit message")}
}
commitMsg += fmt.Sprintf("\n\nAssisted-by: ai-agent (%s)", model)
if err := gitCommit(commitMsg); err != nil {
return CommitResultMsg{Err: fmt.Errorf("git commit: %w", err)}
}
return CommitResultMsg{Message: commitMsg}
}
}
func gitDiff() (string, error) {
cmd := exec.Command("git", "diff", "--cached", "--stat")
stat, _ := cmd.Output()
cmd = exec.Command("git", "diff", "--cached")
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(stat) + "\n" + string(out), nil
}
func gitCommit(msg string) error {
cmd := exec.Command("git", "commit", "-m", msg)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %s", err, stderr.String())
}
return nil
}
+339
View File
@@ -0,0 +1,339 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"ai-agent/internal/command"
"ai-agent/internal/config"
"ai-agent/internal/mcp"
)
type Completion struct {
Label string
Insert string
Category string
Description string
Index int
}
type Completer struct {
commands []*command.Command
models []string
skills []string
agents []string
workDir string
registry *mcp.Registry
ignorePatterns *config.IgnorePatterns
}
func NewCompleter(cmdReg *command.Registry, models, skills, agents []string, registry *mcp.Registry) *Completer {
workDir, _ := os.Getwd()
return &Completer{
commands: cmdReg.All(),
models: models,
skills: skills,
agents: agents,
workDir: workDir,
registry: registry,
}
}
func (c *Completer) Complete(input string) []Completion {
var completions []Completion
if strings.HasPrefix(input, "/") {
completions = c.completeCommand(input)
} else if strings.HasPrefix(input, "@") {
completions = c.completeAgentOrFile(input)
} else if strings.HasPrefix(input, "#") {
completions = c.completeSkill(input)
}
return completions
}
func (c *Completer) completeCommand(input string) []Completion {
var completions []Completion
input = strings.TrimPrefix(input, "/")
for _, cmd := range c.commands {
if strings.HasPrefix(cmd.Name, input) {
comp := Completion{
Label: "/" + cmd.Name,
Insert: "/" + cmd.Name + " ",
Category: "command",
}
if cmd.Usage != "" {
parts := strings.Fields(cmd.Usage)
if len(parts) > 1 {
comp.Label = "/" + cmd.Name + " " + parts[1]
}
}
completions = append(completions, comp)
}
for _, alias := range cmd.Aliases {
if strings.HasPrefix(alias, input) {
completions = append(completions, Completion{
Label: "/" + alias,
Insert: "/" + alias + " ",
Category: "command",
})
}
}
}
return completions
}
func (c *Completer) completeAgentOrFile(input string) []Completion {
var completions []Completion
input = strings.TrimPrefix(input, "@")
// Always show agents first
for _, agent := range c.agents {
if strings.HasPrefix(agent, input) {
completions = append(completions, Completion{
Label: "@" + agent,
Insert: "@" + agent + " ",
Category: "agent",
})
}
}
// Always append file results (not just when no agents match)
completions = append(completions, c.completeFile(input)...)
return completions
}
func (c *Completer) completeFile(input string) []Completion {
var completions []Completion
// Determine the directory to list
dir := c.workDir
if strings.Contains(input, "/") {
// User is typing a path
lastSlash := strings.LastIndex(input, "/")
dirPart := input[:lastSlash]
if !strings.HasPrefix(dirPart, "/") {
dirPart = filepath.Join(c.workDir, dirPart)
}
if info, err := os.Stat(dirPart); err == nil && info.IsDir() {
dir = dirPart
}
}
// Read directory entries
entries, err := os.ReadDir(dir)
if err != nil {
return completions
}
prefix := input
if strings.Contains(input, "/") {
prefix = input[strings.LastIndex(input, "/")+1:]
}
for _, entry := range entries {
name := entry.Name()
// Skip hidden files unless user explicitly types .
if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") {
continue
}
// Skip entries matching ignore patterns.
if c.ignorePatterns.Match(name) {
continue
}
if strings.HasPrefix(name, prefix) {
isDir := entry.IsDir()
displayName := name
insertName := name
if isDir {
displayName += "/"
insertName += "/"
}
// Build full path relative to input
if strings.Contains(input, "/") {
dirPath := input[:strings.LastIndex(input, "/")+1]
displayName = dirPath + displayName
insertName = dirPath + insertName
} else if dir != c.workDir {
relPath, _ := filepath.Rel(c.workDir, dir)
if relPath != "." {
displayName = relPath + "/" + name
if isDir {
displayName += "/"
} else {
insertName = relPath + "/" + insertName
}
}
}
category := "file"
if isDir {
category = "folder"
}
completions = append(completions, Completion{
Label: "@" + displayName,
Insert: "@" + insertName + " ",
Category: category,
})
}
}
return completions
}
// CompleteFilePath lists directory contents at a given relative path.
// Used for folder drill-down in the completion modal.
func (c *Completer) CompleteFilePath(relPath string) []Completion {
var completions []Completion
dir := filepath.Join(c.workDir, relPath)
entries, err := os.ReadDir(dir)
if err != nil {
return completions
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
// Skip entries matching ignore patterns.
if c.ignorePatterns.Match(name) {
continue
}
isDir := entry.IsDir()
displayName := name
insertPath := relPath
if insertPath != "" && !strings.HasSuffix(insertPath, "/") {
insertPath += "/"
}
insertPath += name
if isDir {
displayName += "/"
}
category := "file"
if isDir {
category = "folder"
}
completions = append(completions, Completion{
Label: displayName,
Insert: "@" + insertPath + " ",
Category: category,
})
}
return completions
}
func (c *Completer) completeSkill(input string) []Completion {
var completions []Completion
input = strings.TrimPrefix(input, "#")
for _, skill := range c.skills {
if strings.HasPrefix(skill, input) {
completions = append(completions, Completion{
Label: "#" + skill,
Insert: "#" + skill + " ",
Category: "skill",
})
}
}
return completions
}
// FilterCompletions filters completions by case-insensitive substring match on Label.
func FilterCompletions(items []Completion, query string) []Completion {
if query == "" {
return items
}
q := strings.ToLower(query)
var filtered []Completion
for _, item := range items {
if strings.Contains(strings.ToLower(item.Label), q) {
filtered = append(filtered, item)
}
}
return filtered
}
// SearchFiles performs an async vecgrep search via the MCP registry.
func (c *Completer) SearchFiles(ctx context.Context, query string) []Completion {
if c.registry == nil || query == "" {
return nil
}
result, err := c.registry.CallTool(ctx, "vecgrep_search", map[string]any{
"query": query,
"limit": 10,
})
if err != nil {
return nil
}
var results []Completion
// Parse the result content as JSON array of file paths or objects
var searchResults []struct {
Path string `json:"path"`
Score float64 `json:"score"`
}
if err := json.Unmarshal([]byte(result.Content), &searchResults); err != nil {
// Try as simple string lines
for _, line := range strings.Split(result.Content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
results = append(results, Completion{
Label: "@" + line,
Insert: "@" + line + " ",
Category: "search_result",
Description: "vecgrep match",
})
}
return results
}
for _, sr := range searchResults {
results = append(results, Completion{
Label: "@" + sr.Path,
Insert: "@" + sr.Path + " ",
Category: "search_result",
Description: "vecgrep match",
})
}
return results
}
func (c *Completer) UpdateModels(models []string) {
c.models = models
}
func (c *Completer) UpdateSkills(skills []string) {
c.skills = skills
}
func (c *Completer) UpdateAgents(agents []string) {
c.agents = agents
}
// SetIgnorePatterns sets the ignore patterns used to filter file completions.
func (c *Completer) SetIgnorePatterns(patterns *config.IgnorePatterns) {
c.ignorePatterns = patterns
}
+169
View File
@@ -0,0 +1,169 @@
package tui
import (
"testing"
"ai-agent/internal/command"
)
func TestCompleter_Complete(t *testing.T) {
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
c := NewCompleter(reg, []string{"model-a"}, []string{"skill-a", "skill-b"}, []string{"agent-x"}, nil)
t.Run("slash_dispatches_to_commands", func(t *testing.T) {
results := c.Complete("/h")
if len(results) == 0 {
t.Error("expected command completions for /h")
}
for _, r := range results {
if r.Category != "command" {
t.Errorf("expected category 'command', got %q", r.Category)
}
}
})
t.Run("at_dispatches_to_agents", func(t *testing.T) {
results := c.Complete("@agent")
found := false
for _, r := range results {
if r.Category == "agent" {
found = true
}
}
if !found {
t.Error("expected agent completions for @agent")
}
})
t.Run("hash_dispatches_to_skills", func(t *testing.T) {
results := c.Complete("#skill")
if len(results) == 0 {
t.Error("expected skill completions for #skill")
}
for _, r := range results {
if r.Category != "skill" {
t.Errorf("expected category 'skill', got %q", r.Category)
}
}
})
t.Run("plain_returns_nothing", func(t *testing.T) {
results := c.Complete("hello")
if len(results) != 0 {
t.Errorf("expected no completions for plain text, got %d", len(results))
}
})
}
func TestCompleteCommand(t *testing.T) {
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
c := NewCompleter(reg, nil, nil, nil, nil)
t.Run("prefix_matching", func(t *testing.T) {
results := c.Complete("/hel")
found := false
for _, r := range results {
if r.Insert == "/help " {
found = true
}
}
if !found {
t.Error("expected /help completion for prefix /hel")
}
})
t.Run("alias_matching", func(t *testing.T) {
// /h is an alias for /help
results := c.Complete("/h")
if len(results) == 0 {
t.Error("expected completions for /h (alias)")
}
})
t.Run("usage_suffix_in_label", func(t *testing.T) {
// /model has Usage: "/model [name|list|fast|smart]"
results := c.Complete("/model")
for _, r := range results {
if r.Insert == "/model " {
// The label should include usage args from the Usage field.
if r.Label == "/model" {
// Label should have usage suffix if Usage has args.
// Actually, let's check what the code does:
// The code checks if cmd.Usage has >1 field.
// "/model [name|list|fast|smart]" -> fields: ["/model", "[name|list|fast|smart]"]
// So label should be "/model [name|list|fast|smart]"
t.Error("label should include usage args")
}
}
}
})
t.Run("no_matches", func(t *testing.T) {
results := c.Complete("/zzzzz")
if len(results) != 0 {
t.Errorf("expected no completions for /zzzzz, got %d", len(results))
}
})
}
func TestCompleteSkill(t *testing.T) {
reg := command.NewRegistry()
c := NewCompleter(reg, nil, []string{"coding", "writing", "debugging"}, nil, nil)
t.Run("prefix_matching", func(t *testing.T) {
results := c.Complete("#cod")
if len(results) != 1 {
t.Fatalf("expected 1 match for #cod, got %d", len(results))
}
if results[0].Label != "#coding" {
t.Errorf("expected '#coding', got %q", results[0].Label)
}
if results[0].Category != "skill" {
t.Errorf("expected category 'skill', got %q", results[0].Category)
}
})
t.Run("all_match_empty_prefix", func(t *testing.T) {
results := c.Complete("#")
if len(results) != 3 {
t.Errorf("expected 3 matches for #, got %d", len(results))
}
})
t.Run("no_matches", func(t *testing.T) {
results := c.Complete("#zzz")
if len(results) != 0 {
t.Errorf("expected no matches for #zzz, got %d", len(results))
}
})
}
func TestCompleterUpdateModels(t *testing.T) {
reg := command.NewRegistry()
c := NewCompleter(reg, []string{"old-model"}, nil, nil, nil)
c.UpdateModels([]string{"new-model-a", "new-model-b"})
if len(c.models) != 2 {
t.Errorf("expected 2 models, got %d", len(c.models))
}
if c.models[0] != "new-model-a" {
t.Errorf("expected 'new-model-a', got %q", c.models[0])
}
}
func TestCompleterUpdateAgents(t *testing.T) {
reg := command.NewRegistry()
c := NewCompleter(reg, nil, nil, []string{"old-agent"}, nil)
c.UpdateAgents([]string{"new-agent"})
if len(c.agents) != 1 {
t.Errorf("expected 1 agent, got %d", len(c.agents))
}
if c.agents[0] != "new-agent" {
t.Errorf("expected 'new-agent', got %q", c.agents[0])
}
}
+133
View File
@@ -0,0 +1,133 @@
package tui
import (
"charm.land/lipgloss/v2"
)
// ContextMenuItem represents an item in a context menu.
type ContextMenuItem struct {
Label string
Action string
Shortcut string
}
// ContextMenuState holds the state for a context menu.
type ContextMenuState struct {
X, Y int
Items []ContextMenuItem
Selected int
Active bool
isDark bool
styles ContextMenuStyles
}
// ContextMenuStyles holds styling for context menus.
type ContextMenuStyles struct {
Item lipgloss.Style
Selected lipgloss.Style
Shortcut lipgloss.Style
Border lipgloss.Style
}
// DefaultContextMenuStyles returns default styles.
func DefaultContextMenuStyles(isDark bool) ContextMenuStyles {
if isDark {
return ContextMenuStyles{
Item: lipgloss.NewStyle().Foreground(lipgloss.Color("#d8dee9")),
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#88c0d0")).Background(lipgloss.Color("#3b4252")),
Shortcut: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Border: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
}
}
return ContextMenuStyles{
Item: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4f8f8f")).Background(lipgloss.Color("#e5e9f0")),
Shortcut: lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca0a8")),
Border: lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca0a8")),
}
}
// NewContextMenuState creates a new context menu state.
func NewContextMenuState(items []ContextMenuItem, x, y int, isDark bool) *ContextMenuState {
return &ContextMenuState{
X: x,
Y: y,
Items: items,
Selected: 0,
Active: true,
isDark: isDark,
styles: DefaultContextMenuStyles(isDark),
}
}
// Activate shows the context menu at position.
func (cm *ContextMenuState) Activate(x, y int, items []ContextMenuItem) {
cm.X = x
cm.Y = y
cm.Items = items
cm.Selected = 0
cm.Active = true
}
// Deactivate hides the context menu.
func (cm *ContextMenuState) Deactivate() {
cm.Active = false
}
// IsActive returns true if the menu is visible.
func (cm *ContextMenuState) IsActive() bool {
return cm.Active
}
// SelectedAction returns the action of the selected item.
func (cm *ContextMenuState) SelectedAction() string {
if cm.Selected >= 0 && cm.Selected < len(cm.Items) {
return cm.Items[cm.Selected].Action
}
return ""
}
// MoveUp selects the previous item.
func (cm *ContextMenuState) MoveUp() {
if cm.Selected > 0 {
cm.Selected--
}
}
// MoveDown selects the next item.
func (cm *ContextMenuState) MoveDown() {
if cm.Selected < len(cm.Items)-1 {
cm.Selected++
}
}
// Render returns the context menu view.
func (cm *ContextMenuState) Render(width int) string {
if !cm.Active {
return ""
}
styles := DefaultContextMenuStyles(cm.isDark)
var b string
for i, item := range cm.Items {
row := " " + item.Label
if item.Shortcut != "" {
row += " " + styles.Shortcut.Render(item.Shortcut)
}
if i == cm.Selected {
b += styles.Selected.Render(row) + "\n"
} else {
b += styles.Item.Render(row) + "\n"
}
}
// Wrap in border
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#4c566a")).
Padding(0, 1)
return box.Render(b)
}
+196
View File
@@ -0,0 +1,196 @@
package tui
import (
"fmt"
"os"
"strings"
)
// DiffLineKind represents the type of a diff line.
type DiffLineKind int
const (
DiffContext DiffLineKind = iota
DiffAdded
DiffRemoved
)
// DiffLine is a single line in a unified diff.
type DiffLine struct {
Kind DiffLineKind
Content string
}
// readFileForDiff extracts a file path from tool args and reads its content.
func readFileForDiff(rawArgs map[string]any) string {
for _, key := range []string{"path", "file_path", "filename", "file"} {
if p, ok := rawArgs[key].(string); ok {
data, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(data)
}
}
return ""
}
// computeDiff computes a line-level diff between before and after text.
// Returns nil if the texts are identical.
func computeDiff(before, after string) []DiffLine {
if before == after {
return nil
}
beforeLines := splitLines(before)
afterLines := splitLines(after)
lcs := lcsLines(beforeLines, afterLines)
var all []DiffLine
bi, ai, li := 0, 0, 0
for li < len(lcs) {
for bi < len(beforeLines) && beforeLines[bi] != lcs[li] {
all = append(all, DiffLine{DiffRemoved, beforeLines[bi]})
bi++
}
for ai < len(afterLines) && afterLines[ai] != lcs[li] {
all = append(all, DiffLine{DiffAdded, afterLines[ai]})
ai++
}
all = append(all, DiffLine{DiffContext, lcs[li]})
bi++
ai++
li++
}
for bi < len(beforeLines) {
all = append(all, DiffLine{DiffRemoved, beforeLines[bi]})
bi++
}
for ai < len(afterLines) {
all = append(all, DiffLine{DiffAdded, afterLines[ai]})
ai++
}
return filterContext(all, 3)
}
// renderDiff renders diff lines with styles, capping output at maxLines.
func renderDiff(lines []DiffLine, styles Styles, maxLines int) string {
if len(lines) == 0 {
return ""
}
var b strings.Builder
displayed := 0
for _, line := range lines {
if maxLines > 0 && displayed >= maxLines {
b.WriteString(styles.DiffHeader.Render(fmt.Sprintf(" ... %d more lines", len(lines)-displayed)))
b.WriteString("\n")
break
}
switch line.Kind {
case DiffAdded:
b.WriteString(styles.DiffAdded.Render("+ " + line.Content))
case DiffRemoved:
b.WriteString(styles.DiffRemoved.Render("- " + line.Content))
case DiffContext:
b.WriteString(styles.DiffContext.Render(" " + line.Content))
}
b.WriteString("\n")
displayed++
}
return b.String()
}
// lcsLines computes the longest common subsequence of two string slices.
func lcsLines(a, b []string) []string {
m, n := len(a), len(b)
if m == 0 || n == 0 {
return nil
}
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
result := make([]string, dp[m][n])
k := dp[m][n] - 1
i, j := m, n
for i > 0 && j > 0 {
if a[i-1] == b[j-1] {
result[k] = a[i-1]
k--
i--
j--
} else if dp[i-1][j] >= dp[i][j-1] {
i--
} else {
j--
}
}
return result
}
// filterContext keeps only diff lines near changes, with contextLines of context.
func filterContext(lines []DiffLine, contextLines int) []DiffLine {
if len(lines) == 0 {
return nil
}
keep := make([]bool, len(lines))
for i, line := range lines {
if line.Kind != DiffContext {
lo := i - contextLines
if lo < 0 {
lo = 0
}
hi := i + contextLines
if hi >= len(lines) {
hi = len(lines) - 1
}
for j := lo; j <= hi; j++ {
keep[j] = true
}
}
}
var result []DiffLine
for i, line := range lines {
if keep[i] {
result = append(result, line)
}
}
return result
}
// splitLines splits text into lines, removing a trailing empty line from a trailing newline.
func splitLines(s string) []string {
if s == "" {
return nil
}
lines := strings.Split(s, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
+187
View File
@@ -0,0 +1,187 @@
package tui
import (
"strings"
"testing"
)
func TestComputeDiff_Identical(t *testing.T) {
result := computeDiff("hello\nworld\n", "hello\nworld\n")
if result != nil {
t.Errorf("identical texts should return nil, got %d lines", len(result))
}
}
func TestComputeDiff_EmptyBefore(t *testing.T) {
result := computeDiff("", "line1\nline2\n")
if len(result) == 0 {
t.Fatal("expected diff lines for new file")
}
for _, line := range result {
if line.Kind != DiffAdded {
t.Errorf("new file should have only added lines, got kind %d", line.Kind)
}
}
}
func TestComputeDiff_EmptyAfter(t *testing.T) {
result := computeDiff("line1\nline2\n", "")
if len(result) == 0 {
t.Fatal("expected diff lines for deleted file")
}
for _, line := range result {
if line.Kind != DiffRemoved {
t.Errorf("deleted file should have only removed lines, got kind %d", line.Kind)
}
}
}
func TestComputeDiff_Modification(t *testing.T) {
before := "line1\nline2\nline3\n"
after := "line1\nline2-modified\nline3\n"
result := computeDiff(before, after)
if len(result) == 0 {
t.Fatal("expected diff lines for modification")
}
// Should contain removed and added lines.
var hasAdded, hasRemoved, hasContext bool
for _, line := range result {
switch line.Kind {
case DiffAdded:
hasAdded = true
if line.Content != "line2-modified" {
t.Errorf("added line should be 'line2-modified', got %q", line.Content)
}
case DiffRemoved:
hasRemoved = true
if line.Content != "line2" {
t.Errorf("removed line should be 'line2', got %q", line.Content)
}
case DiffContext:
hasContext = true
}
}
if !hasAdded || !hasRemoved {
t.Error("modification should produce both added and removed lines")
}
if !hasContext {
t.Error("modification should have context lines")
}
}
func TestComputeDiff_ContextLimiting(t *testing.T) {
// Create a file with many lines and a change in the middle.
var before, after strings.Builder
for i := 0; i < 50; i++ {
before.WriteString("line" + strings.Repeat("x", i) + "\n")
after.WriteString("line" + strings.Repeat("x", i) + "\n")
}
// Change line 25
beforeStr := strings.Replace(before.String(), "line"+strings.Repeat("x", 25), "CHANGED", 1)
afterStr := strings.Replace(after.String(), "line"+strings.Repeat("x", 25), "MODIFIED", 1)
result := computeDiff(beforeStr, afterStr)
if len(result) == 0 {
t.Fatal("expected diff lines")
}
// Should not include all 50 lines — context filtering should limit output.
if len(result) > 20 {
t.Errorf("context limiting should reduce output, got %d lines", len(result))
}
}
func TestFilterContext_EmptyInput(t *testing.T) {
result := filterContext(nil, 3)
if result != nil {
t.Errorf("empty input should return nil, got %d lines", len(result))
}
}
func TestFilterContext_AllChanges(t *testing.T) {
lines := []DiffLine{
{DiffAdded, "a"},
{DiffAdded, "b"},
{DiffRemoved, "c"},
}
result := filterContext(lines, 3)
if len(result) != 3 {
t.Errorf("all changes should be kept, got %d lines", len(result))
}
}
func TestSplitLines_Empty(t *testing.T) {
result := splitLines("")
if result != nil {
t.Errorf("empty string should return nil, got %v", result)
}
}
func TestSplitLines_TrailingNewline(t *testing.T) {
result := splitLines("a\nb\n")
if len(result) != 2 {
t.Errorf("should have 2 lines, got %d: %v", len(result), result)
}
}
func TestLcsLines_Empty(t *testing.T) {
result := lcsLines(nil, []string{"a"})
if result != nil {
t.Errorf("LCS with empty input should be nil, got %v", result)
}
}
func TestLcsLines_Basic(t *testing.T) {
a := []string{"a", "b", "c", "d"}
b := []string{"a", "c", "d", "e"}
lcs := lcsLines(a, b)
expected := []string{"a", "c", "d"}
if len(lcs) != len(expected) {
t.Fatalf("LCS length mismatch: got %v, want %v", lcs, expected)
}
for i, v := range lcs {
if v != expected[i] {
t.Errorf("LCS[%d] = %q, want %q", i, v, expected[i])
}
}
}
func TestRenderDiff_Empty(t *testing.T) {
s := NewStyles(true)
result := renderDiff(nil, s, 10)
if result != "" {
t.Errorf("empty diff should render empty, got %q", result)
}
}
func TestRenderDiff_MaxLines(t *testing.T) {
lines := []DiffLine{
{DiffAdded, "a"},
{DiffAdded, "b"},
{DiffAdded, "c"},
{DiffAdded, "d"},
{DiffAdded, "e"},
}
s := NewStyles(true)
result := renderDiff(lines, s, 3)
// Should contain "more lines" indicator.
if !strings.Contains(result, "more lines") {
t.Error("should show 'more lines' when truncating")
}
}
func TestReadFileForDiff_NoArgs(t *testing.T) {
result := readFileForDiff(nil)
if result != "" {
t.Errorf("nil args should return empty, got %q", result)
}
}
func TestReadFileForDiff_NonexistentFile(t *testing.T) {
result := readFileForDiff(map[string]any{"path": "/nonexistent/file/path"})
if result != "" {
t.Errorf("nonexistent file should return empty, got %q", result)
}
}
+204
View File
@@ -0,0 +1,204 @@
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
"ai-agent/internal/command"
)
// helpContentWidth returns the inner width for the help modal content.
func (m *Model) helpContentWidth() int {
maxW := 60
if m.width < maxW+8 {
maxW = m.width - 8
}
if maxW < 30 {
maxW = 30
}
return maxW
}
// helpViewportHeight returns the viewport height for the help modal.
func (m *Model) helpViewportHeight() int {
// Leave room for border (2), padding (2), title (2), footer (1)
h := m.height - 10
if h < 5 {
h = 5
}
return h
}
// buildHelpContent builds the raw help text (without border/viewport wrapper).
func (m *Model) buildHelpContent(innerW int) string {
var b strings.Builder
loc := m.tr()
b.WriteString(m.styles.OverlayAccent.Render(loc.KeyboardShortcuts))
b.WriteString("\n")
shortcuts := []struct{ key, desc string }{
{"enter", loc.SendMessage},
{"shift+enter", loc.NewLineInInput},
{"shift+tab", loc.CycleMode},
{"F6", loc.QuickModelSwitch},
{"esc", loc.CancelStreaming},
{"ctrl+c / ctrl+q / F10", loc.QuitKeys},
{"ctrl+l", loc.ClearScreen},
{"ctrl+n", loc.NewConversation},
{"?", loc.ToggleHelp},
{"t", loc.ExpandTools},
{"space", loc.ToggleToolDetails},
{"ctrl+y", loc.CopyLastResponse},
{"ctrl+t", loc.ToggleThinking},
{"ctrl+k", loc.ToggleCompact},
{"ctrl+e", loc.OpenInEditor},
{"↑/↓", loc.BrowseHistory},
{"pgup/pgdown", loc.ScrollViewport},
{"ctrl+u/d", loc.HalfPageScroll},
{"tab", loc.Autocomplete},
{"F2", loc.LanguageF2},
}
for _, s := range shortcuts {
fmt.Fprintf(&b, " %s %s\n",
m.styles.FocusIndicator.Width(16).Render(s.key),
m.styles.OverlayDim.Render(s.desc),
)
}
b.WriteString("\n")
b.WriteString(m.styles.OverlayAccent.Render(loc.InputShortcuts))
b.WriteString("\n")
inputShortcuts := []struct{ key, desc string }{
{"@file", loc.AttachFile},
{"#skill", loc.ActivateSkill},
{"/cmd", loc.RunSlashCommand},
}
for _, s := range inputShortcuts {
fmt.Fprintf(&b, " %s %s\n",
m.styles.FocusIndicator.Width(16).Render(s.key),
m.styles.OverlayDim.Render(s.desc),
)
}
b.WriteString("\n")
b.WriteString(m.styles.OverlayAccent.Render(loc.SlashCommands))
b.WriteString("\n")
// Slash commands.
if m.cmdRegistry != nil {
for _, cmd := range m.cmdRegistry.All() {
fmt.Fprintf(&b, " %s %s\n",
m.styles.FocusIndicator.Width(16).Render("/"+cmd.Name),
m.styles.OverlayDim.Render(cmd.Description),
)
}
}
return b.String()
}
// initHelpViewport creates and populates the help viewport for scrolling.
func (m *Model) initHelpViewport() {
innerW := m.helpContentWidth()
vpH := m.helpViewportHeight()
m.helpViewport = viewport.New(
viewport.WithWidth(innerW),
viewport.WithHeight(vpH),
)
// Disable default arrow key bindings (we handle j/k/up/down ourselves via parent)
m.helpViewport.KeyMap.Up.SetEnabled(false)
m.helpViewport.KeyMap.Down.SetEnabled(false)
m.helpViewport.KeyMap.PageUp.SetEnabled(false)
m.helpViewport.KeyMap.PageDown.SetEnabled(false)
m.helpViewport.KeyMap.HalfPageUp.SetEnabled(false)
m.helpViewport.KeyMap.HalfPageDown.SetEnabled(false)
content := m.buildHelpContent(innerW)
m.helpViewport.SetContent(content)
}
// renderHelpOverlay builds a centered, scrollable help modal.
func (m *Model) renderHelpOverlay(contentWidth int) string {
innerW := m.helpContentWidth()
var b strings.Builder
loc := m.tr()
b.WriteString(m.styles.OverlayTitle.Render(loc.Help))
b.WriteString("\n\n")
// Viewport content (scrollable).
b.WriteString(m.helpViewport.View())
b.WriteString("\n")
pct := m.helpViewport.ScrollPercent()
var hint string
if pct <= 0 {
hint = loc.ScrollMore
} else if pct >= 1.0 {
hint = loc.ScrollClose
} else {
hint = fmt.Sprintf(loc.ScrollPct, pct*100)
}
b.WriteString(m.styles.OverlayDim.Render(hint))
// Wrap in a box.
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.styles.FocusIndicator.GetForeground()).
Padding(1, 2).
Width(innerW + 6) // +6 for padding (2*2) + border (2)
return box.Render(b.String())
}
// overlayOnContent renders the overlay centered on the viewport area.
func (m *Model) overlayOnContent(base, overlay string) string {
baseLines := strings.Split(base, "\n")
overlayLines := strings.Split(overlay, "\n")
// Center vertically.
startY := (len(baseLines) - len(overlayLines)) / 2
if startY < 0 {
startY = 0
}
for i, ol := range overlayLines {
row := startY + i
if row >= len(baseLines) {
break
}
// Center horizontally.
olW := lipgloss.Width(ol)
padLeft := (m.width - olW) / 2
if padLeft < 0 {
padLeft = 0
}
baseLines[row] = strings.Repeat(" ", padLeft) + ol
}
return strings.Join(baseLines, "\n")
}
// commandHelpEntries extracts SkillInfo from commands for display.
func commandHelpEntries(reg *command.Registry) []struct{ Name, Desc string } {
var entries []struct{ Name, Desc string }
if reg == nil {
return entries
}
for _, cmd := range reg.All() {
entries = append(entries, struct{ Name, Desc string }{
Name: "/" + cmd.Name,
Desc: cmd.Description,
})
}
return entries
}
+73
View File
@@ -0,0 +1,73 @@
package tui
import (
"testing"
"time"
"ai-agent/internal/agent"
"ai-agent/internal/command"
tea "charm.land/bubbletea/v2"
)
var (
testTime = time.Now()
testDuration = 100 * time.Millisecond
)
func newTestModel(t *testing.T) *Model {
t.Helper()
reg := command.NewRegistry()
command.RegisterBuiltins(reg)
completer := NewCompleter(reg, []string{"model-a", "model-b"}, []string{"skill-a"}, []string{"agent-x"}, nil)
ag := agent.New(nil, nil, 0)
m := New(ag, reg, nil, completer, nil, nil, nil)
m.promptHistoryPath = ""
m.promptHistory = nil
m.lang = LangEn
m.initializing = false
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return updated.(*Model)
}
func escKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEscape}
}
func enterKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEnter}
}
func tabKey() tea.KeyPressMsg {return tea.KeyPressMsg{Code: tea.KeyTab} }
func upKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyUp}
}
func downKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyDown}
}
func leftKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyLeft}
}
func rightKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyRight}
}
func spaceKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeySpace}
}
func charKey(r rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: r, Text: string(r)}
}
func ctrlKey(r rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl}
}
func shiftTabKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}
}
+229
View File
@@ -0,0 +1,229 @@
package tui
import "testing"
func TestPushHistory_Basic(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.pushHistory("world")
if len(m.promptHistory) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(m.promptHistory))
}
if m.promptHistory[0] != "hello" || m.promptHistory[1] != "world" {
t.Errorf("unexpected history: %v", m.promptHistory)
}
}
func TestPushHistory_Empty(t *testing.T) {
m := newTestModel(t)
m.pushHistory("")
if len(m.promptHistory) != 0 {
t.Error("empty string should not be added to history")
}
}
func TestPushHistory_DedupConsecutive(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.pushHistory("hello")
m.pushHistory("hello")
if len(m.promptHistory) != 1 {
t.Errorf("expected 1 entry after dedup, got %d", len(m.promptHistory))
}
}
func TestPushHistory_DedupNonConsecutive(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.pushHistory("world")
m.pushHistory("hello")
if len(m.promptHistory) != 3 {
t.Errorf("non-consecutive duplicates should be kept, got %d", len(m.promptHistory))
}
}
func TestPushHistory_CapAt100(t *testing.T) {
m := newTestModel(t)
for i := 0; i < 110; i++ {
m.pushHistory(string(rune('a' + i%26)) + string(rune('0'+i/26)))
}
if len(m.promptHistory) > 100 {
t.Errorf("history should be capped at 100, got %d", len(m.promptHistory))
}
}
func TestNavigateHistory_EmptyHistory(t *testing.T) {
m := newTestModel(t)
if m.navigateHistory(-1) {
t.Error("up on empty history should return false")
}
if m.navigateHistory(1) {
t.Error("down on empty history should return false")
}
}
func TestNavigateHistory_UpDown(t *testing.T) {
m := newTestModel(t)
m.pushHistory("first")
m.pushHistory("second")
m.pushHistory("third")
// Set current input
m.input.SetValue("current")
// Press up: should go to "third" (most recent)
if !m.navigateHistory(-1) {
t.Fatal("up should succeed")
}
if m.input.Value() != "third" {
t.Errorf("expected 'third', got %q", m.input.Value())
}
if m.historySaved != "current" {
t.Errorf("current input should be saved, got %q", m.historySaved)
}
// Press up again: "second"
if !m.navigateHistory(-1) {
t.Fatal("up should succeed")
}
if m.input.Value() != "second" {
t.Errorf("expected 'second', got %q", m.input.Value())
}
// Press up again: "first"
if !m.navigateHistory(-1) {
t.Fatal("up should succeed")
}
if m.input.Value() != "first" {
t.Errorf("expected 'first', got %q", m.input.Value())
}
// Press up again: at oldest, should fail
if m.navigateHistory(-1) {
t.Error("up at oldest should return false")
}
// Press down: "second"
if !m.navigateHistory(1) {
t.Fatal("down should succeed")
}
if m.input.Value() != "second" {
t.Errorf("expected 'second', got %q", m.input.Value())
}
// Press down: "third"
if !m.navigateHistory(1) {
t.Fatal("down should succeed")
}
if m.input.Value() != "third" {
t.Errorf("expected 'third', got %q", m.input.Value())
}
// Press down past newest: restore saved input
if !m.navigateHistory(1) {
t.Fatal("down past newest should succeed")
}
if m.input.Value() != "current" {
t.Errorf("expected restored 'current', got %q", m.input.Value())
}
if m.historyIndex != -1 {
t.Errorf("historyIndex should be -1 after exiting history, got %d", m.historyIndex)
}
}
func TestNavigateHistory_DownNotBrowsing(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
// Down without first pressing up should return false
if m.navigateHistory(1) {
t.Error("down when not browsing should return false")
}
}
func TestHistoryKey_OnlyWhenIdleAndEmpty(t *testing.T) {
t.Run("idle_empty_with_history", func(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.state = StateIdle
m.overlay = OverlayNone
updated, _ := m.Update(upKey())
m = updated.(*Model)
if m.input.Value() != "hello" {
t.Errorf("up key should navigate history, got %q", m.input.Value())
}
})
t.Run("idle_nonempty_no_history", func(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.state = StateIdle
m.overlay = OverlayNone
m.input.SetValue("typing something")
updated, _ := m.Update(upKey())
m = updated.(*Model)
// Should NOT navigate history when input has content and not already browsing
if m.historyIndex != -1 {
t.Error("up key should not navigate history when input is non-empty")
}
})
t.Run("waiting_no_history", func(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.state = StateWaiting
updated, _ := m.Update(upKey())
m = updated.(*Model)
if m.historyIndex != -1 {
t.Error("up key should not navigate history when not idle")
}
})
t.Run("overlay_no_history", func(t *testing.T) {
m := newTestModel(t)
m.pushHistory("hello")
m.state = StateIdle
m.overlay = OverlayHelp
updated, _ := m.Update(upKey())
m = updated.(*Model)
if m.historyIndex != -1 {
t.Error("up key should not navigate history when overlay is open")
}
})
}
func TestHistoryKey_AlreadyBrowsing(t *testing.T) {
m := newTestModel(t)
m.pushHistory("first")
m.pushHistory("second")
m.state = StateIdle
m.overlay = OverlayNone
// Input must be empty to start browsing
m.input.SetValue("")
// Press up — enters history (input is empty, so allowed)
updated, _ := m.Update(upKey())
m = updated.(*Model)
if m.input.Value() != "second" {
t.Fatalf("expected 'second', got %q", m.input.Value())
}
// Now input is non-empty (from history), up should still work because historyIndex != -1
updated, _ = m.Update(upKey())
m = updated.(*Model)
if m.input.Value() != "first" {
t.Errorf("expected 'first', got %q", m.input.Value())
}
}
+295
View File
@@ -0,0 +1,295 @@
package tui
import (
"os"
"path/filepath"
"strings"
)
// Lang is the UI language code.
type Lang string
const (
LangEn Lang = "en"
LangRu Lang = "ru"
)
// L holds all localizable UI strings.
type L struct {
// General
Help string
Quit string
Cancel string
Send string
New string
Clear string
Complete string
ScrollMore string
ScrollClose string
ScrollPct string
// Placeholder & input
Placeholder string
// Help overlay
KeyboardShortcuts string
InputShortcuts string
SlashCommands string
SendMessage string
NewLineInInput string
CycleMode string
QuickModelSwitch string
CancelStreaming string
QuitKeys string
ClearScreen string
NewConversation string
ToggleHelp string
ExpandTools string
ToggleToolDetails string
CopyLastResponse string
ToggleThinking string
ToggleCompact string
OpenInEditor string
BrowseHistory string
ScrollViewport string
HalfPageScroll string
Autocomplete string
AttachFile string
ActivateSkill string
RunSlashCommand string
Language string
LanguageF2 string
// Side panel
SidePanelAIAgent string
SidePanelTagline string
SidePanelModels string
SidePanelServers string
SidePanelICE string
SidePanelQuickActions string
SidePanelHelp string
SidePanelHelpDesc string
SidePanelServersDesc string
SidePanelModelDesc string
SidePanelLoadDesc string
SidePanelLoad string
ToolsConnected string
NoServersConnected string
ICEConversations string
ICECrossSessionActive string
ICEDisabled string
ICECrossSessionInactive string
// Model picker
SelectModel string
// Modes
ModeAsk string
ModePlan string
ModeBuild string
// Window title
WindowTitle string
WindowTitleThink string
WindowTitleStream string
WindowTitleDone string
// Key hints (short action names)
HintSend string
HintComplete string
HintHelp string
HintCancel string
HintQuit string
HintNew string
HintClear string
HintCommands string
HintFiles string
HintSkills string
// Toasts / messages
NoModelsAvailable string
LanguageSet string
}
var localeEn = L{
Help: "Help", Quit: "quit", Cancel: "cancel", Send: "send", New: "new", Clear: "clear", Complete: "complete",
ScrollMore: "↓ scroll for more", ScrollClose: "Esc or q to close", ScrollPct: "%.0f%% · j/k to scroll",
Placeholder: "Ask anything... (Enter to send, ctrl+b for sidebar)",
KeyboardShortcuts: "Keyboard Shortcuts",
InputShortcuts: "Input Shortcuts",
SlashCommands: "Slash Commands",
SendMessage: "Send message",
NewLineInInput: "New line in input",
CycleMode: "Cycle mode (ASK/PLAN/BUILD)",
QuickModelSwitch: "Quick model switch",
CancelStreaming: "Cancel streaming / close overlay",
QuitKeys: "Quit",
ClearScreen: "Clear screen (keep history)",
NewConversation: "New conversation",
ToggleHelp: "Toggle this help (when input empty)",
ExpandTools: "Expand/collapse all tools",
ToggleToolDetails: "Toggle last tool details",
CopyLastResponse: "Copy last response",
ToggleThinking: "Toggle thinking display",
ToggleCompact: "Toggle compact mode",
OpenInEditor: "Open input in $EDITOR",
BrowseHistory: "Browse input history",
ScrollViewport: "Scroll viewport",
HalfPageScroll: "Half-page scroll",
Autocomplete: "Autocomplete (commands/files/skills)",
AttachFile: "Attach file or agent",
ActivateSkill: "Activate skill",
RunSlashCommand: "Run slash command",
Language: "Language",
LanguageF2: "Switch interface language (F2)",
SidePanelAIAgent: "AI AGENT",
SidePanelTagline: "100% local · Your data never leaves",
SidePanelModels: "Models",
SidePanelServers: "Servers",
SidePanelICE: "ICE",
SidePanelQuickActions: "Quick Actions",
SidePanelHelp: "Help",
SidePanelHelpDesc: "Keyboard shortcuts",
SidePanelServersDesc: "List connected tools",
SidePanelModelDesc: "Switch model",
SidePanelLoad: "Load",
SidePanelLoadDesc: "Add context from file",
ToolsConnected: "%d tools connected",
NoServersConnected: "No servers connected",
ICEConversations: "%d conversations",
ICECrossSessionActive: "Cross-session memory active",
ICEDisabled: "ICE disabled",
ICECrossSessionInactive: "Cross-session memory inactive",
SelectModel: "Select Model",
ModeAsk: "ASK", ModePlan: "PLAN", ModeBuild: "BUILD",
WindowTitle: "AI AGENT", WindowTitleThink: "AI AGENT · thinking...",
WindowTitleStream: "AI AGENT · streaming...", WindowTitleDone: "AI AGENT · done",
HintSend: "send", HintComplete: "complete", HintHelp: "help", HintCancel: "cancel", HintQuit: "quit",
HintNew: "new", HintClear: "clear", HintCommands: "commands", HintFiles: "files", HintSkills: "skills",
NoModelsAvailable: "No models available. Check Ollama connection.",
LanguageSet: "Language: %s",
}
var localeRu = L{
Help: "Справка", Quit: "выход", Cancel: "отмена", Send: "отправить", New: "новый", Clear: "очистить", Complete: "дополнение",
ScrollMore: "↓ листать вниз", ScrollClose: "Esc или q — закрыть", ScrollPct: "%.0f%% · j/k листать",
Placeholder: "Спросите что угодно... (Enter — отправить, ctrl+b — панель)",
KeyboardShortcuts: "Горячие клавиши",
InputShortcuts: "Клавиши ввода",
SlashCommands: "Слэш-команды",
SendMessage: "Отправить сообщение",
NewLineInInput: "Новая строка в поле ввода",
CycleMode: "Режим (ASK/PLAN/BUILD)",
QuickModelSwitch: "Быстрая смена модели",
CancelStreaming: "Отмена / закрыть окно",
QuitKeys: "Выход",
ClearScreen: "Очистить экран (история сохраняется)",
NewConversation: "Новый диалог",
ToggleHelp: "Показать справку (при пустом вводе)",
ExpandTools: "Развернуть/свернуть инструменты",
ToggleToolDetails: "Детали последнего инструмента",
CopyLastResponse: "Копировать последний ответ",
ToggleThinking: "Показать процесс размышления",
ToggleCompact: "Компактный режим",
OpenInEditor: "Открыть в $EDITOR",
BrowseHistory: "История ввода",
ScrollViewport: "Прокрутка",
HalfPageScroll: "На полстраницы",
Autocomplete: "Дополнение (команды/файлы/навыки)",
AttachFile: "Прикрепить файл или агента",
ActivateSkill: "Подключить навык",
RunSlashCommand: "Выполнить слэш-команду",
Language: "Язык",
LanguageF2: "Язык интерфейса (F2)",
SidePanelAIAgent: "AI AGENT",
SidePanelTagline: "100% локально · Ваши данные не покидают устройство",
SidePanelModels: "Модели",
SidePanelServers: "Серверы",
SidePanelICE: "ICE",
SidePanelQuickActions: "Быстрые действия",
SidePanelHelp: "Справка",
SidePanelHelpDesc: "Горячие клавиши",
SidePanelServersDesc: "Подключённые инструменты",
SidePanelModelDesc: "Сменить модель",
SidePanelLoad: "Загрузить",
SidePanelLoadDesc: "Добавить контекст из файла",
ToolsConnected: "Подключено инструментов: %d",
NoServersConnected: "Серверы не подключены",
ICEConversations: "Диалогов: %d",
ICECrossSessionActive: "Память между сессиями активна",
ICEDisabled: "ICE выключен",
ICECrossSessionInactive: "Память между сессиями неактивна",
SelectModel: "Выбор модели",
ModeAsk: "ASK", ModePlan: "PLAN", ModeBuild: "BUILD",
WindowTitle: "AI AGENT", WindowTitleThink: "AI AGENT · думает...",
WindowTitleStream: "AI AGENT · отвечает...", WindowTitleDone: "AI AGENT · готово",
HintSend: "отправить", HintComplete: "дополнение", HintHelp: "справка", HintCancel: "отмена", HintQuit: "выход",
HintNew: "новый", HintClear: "очистить", HintCommands: "команды", HintFiles: "файлы", HintSkills: "навыки",
NoModelsAvailable: "Нет моделей. Проверьте подключение к Ollama.",
LanguageSet: "Язык: %s",
}
// Locale returns the strings for the given language. Unknown lang falls back to English.
func Locale(lang Lang) L {
switch lang {
case LangRu:
return localeRu
default:
return localeEn
}
}
// LangName returns a display name for the language.
func LangName(lang Lang) string {
switch lang {
case LangRu:
return "Русский"
default:
return "English"
}
}
// NextLang cycles to the next language (en -> ru -> en).
func NextLang(lang Lang) Lang {
switch lang {
case LangEn:
return LangRu
case LangRu:
return LangEn
default:
return LangEn
}
}
// DefaultLangPath returns the path for storing UI language preference.
func DefaultLangPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "lang"
}
return filepath.Join(home, ".config", "ai-agent", "lang")
}
// LoadLang reads the saved language from DefaultLangPath(). Returns LangEn if missing or invalid.
func LoadLang() Lang {
data, err := os.ReadFile(DefaultLangPath())
if err != nil {
return LangEn
}
switch strings.TrimSpace(strings.ToLower(string(data))) {
case "ru", "русский":
return LangRu
default:
return LangEn
}
}
// SaveLang writes the language to DefaultLangPath().
func SaveLang(lang Lang) error {
path := DefaultLangPath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(lang), 0o644)
}
+147
View File
@@ -0,0 +1,147 @@
package tui
import (
"strings"
"charm.land/lipgloss/v2"
)
// KeyHint displays a keyboard shortcut hint.
type KeyHint struct {
Key string
Action string
}
// KeyHints renders a row of key hints.
type KeyHints struct {
hints []KeyHint
styles KeyHintStyles
maxWidth int
}
// KeyHintStyles holds styling for key hints.
type KeyHintStyles struct {
Key lipgloss.Style
Action lipgloss.Style
Divider lipgloss.Style
}
// DefaultKeyHintStyles returns default styles.
func DefaultKeyHintStyles(isDark bool) KeyHintStyles {
if isDark {
return KeyHintStyles{
Key: lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0")).Background(lipgloss.Color("#3b4252")).Padding(0, 1),
Action: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Divider: lipgloss.NewStyle().Foreground(lipgloss.Color("#3b4252")),
}
}
return KeyHintStyles{
Key: lipgloss.NewStyle().Foreground(lipgloss.Color("#4f8f8f")).Background(lipgloss.Color("#e5e9f0")).Padding(0, 1),
Action: lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca0a8")),
Divider: lipgloss.NewStyle().Foreground(lipgloss.Color("#d8dee9")),
}
}
// NewKeyHints creates a new key hints component.
func NewKeyHints(hints []KeyHint, maxWidth int, isDark bool) *KeyHints {
return &KeyHints{
hints: hints,
styles: DefaultKeyHintStyles(isDark),
maxWidth: maxWidth,
}
}
// SetDark updates theme.
func (kh *KeyHints) SetDark(isDark bool) {
kh.styles = DefaultKeyHintStyles(isDark)
}
// SetHints updates the hints.
func (kh *KeyHints) SetHints(hints []KeyHint) {
kh.hints = hints
}
// Render returns the key hints as a single line.
func (kh *KeyHints) Render() string {
if len(kh.hints) == 0 {
return ""
}
var b strings.Builder
b.WriteString(kh.styles.Divider.Render("│"))
for i, hint := range kh.hints {
if i > 0 {
b.WriteString(" ")
}
b.WriteString(kh.styles.Key.Render(hint.Key))
b.WriteString(" ")
b.WriteString(kh.styles.Action.Render(hint.Action))
}
return b.String()
}
// RenderInline renders hints as inline text (no key box).
func (kh *KeyHints) RenderInline() string {
if len(kh.hints) == 0 {
return ""
}
var b strings.Builder
for i, hint := range kh.hints {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(hint.Key)
b.WriteString(" ")
b.WriteString(kh.styles.Action.Render(hint.Action))
}
return b.String()
}
// SetMaxWidth sets the maximum width for wrapping.
func (kh *KeyHints) SetMaxWidth(w int) {
kh.maxWidth = w
}
func defaultHintsForLang(lang Lang) []KeyHint {
loc := Locale(lang)
return []KeyHint{
{Key: "Enter", Action: loc.HintSend},
{Key: "Tab", Action: loc.HintComplete},
{Key: "?", Action: loc.HintHelp},
{Key: "Esc", Action: loc.HintCancel},
{Key: "Ctrl+C / F10", Action: loc.HintQuit},
}
}
// DefaultKeyHints returns common key hints for the application.
func DefaultKeyHints(lang Lang, isDark bool) *KeyHints {
return NewKeyHints(defaultHintsForLang(lang), 60, isDark)
}
// FooterHints returns hints shown in the footer.
func FooterHints(lang Lang, keys KeyMap, isDark bool) *KeyHints {
loc := Locale(lang)
hints := []KeyHint{
{Key: "?", Action: loc.HintHelp},
{Key: "Ctrl+N", Action: loc.HintNew},
{Key: "Ctrl+L", Action: loc.HintClear},
}
return NewKeyHints(hints, 40, isDark)
}
// InputHints returns hints shown when typing.
func InputHints(lang Lang, keys KeyMap, isDark bool) *KeyHints {
loc := Locale(lang)
hints := []KeyHint{
{Key: "Tab", Action: loc.HintComplete},
{Key: "/", Action: loc.HintCommands},
{Key: "@", Action: loc.HintFiles},
{Key: "#", Action: loc.HintSkills},
}
return NewKeyHints(hints, 40, isDark)
}

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