diff --git a/AGENTS.md b/AGENTS.md index 41eddb1..8816419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,91 +1,60 @@ # AGENTS.md — DivaCode -## Quick start +## Commands ```bash -# build -make build # go build -o build/divad ./cmd/divad +make all # lint → vet → build +make build # go build -o build/divad ./cmd/divad +make run # build + run (needs llama.cpp on :8080) +make test # go test -race -count=1 ./... +make lint # golangci-lint run ./... +make vet # go vet ./... +make tidy # go mod tidy +make clean # rm -rf build/ +``` -# run (llama.cpp server must be on :8080) -make run # build + run +Private proxy for deps: -# verify -make vet # go vet ./... -GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GOPRIV=git.db123.ir GOPROXY=https://proxy.golang.org,direct go vet ./... +```bash +GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GOPRIV=git.db123.ir \ +GOPROXY=https://proxy.golang.org,direct go vet ./... ``` ## Architecture -``` -cmd/divad/main.go # entrypoint: wires agent, llama, companion, TUI, API, scheduler -internal/agent/ # core loop: message → memory search → prompt → llama.cpp → save → respond -internal/agent/scheduler.go # autonomous tick: mood check, diary write, proactive message -internal/companion/ # orchestrates personality, relationship, reflection, mood, prompt builder - engine.go # BuildPrompt: assembles dynamic prompt from all sub-engines - personality.go # 5 bounded traits in SQLite (humor, curiosity, playfulness, formality, affection) - relationship.go # familiarity, trust, shared topics, inside jokes - reflection.go # diary entries, observations - promptbuilder.go # generates dynamic system prompt section - mood.go # mood derivation from recent interaction + energy level -internal/memory/ # RAG store + self-memory (promises, strategies, mistakes) -internal/llama/client.go # llama.cpp HTTP client (POST /completion, POST /embedding, GET /health) -internal/storage/ # SQLite wrapper, auto-creates data/ directory, auto-migrates on startup -internal/log/handler.go # custom slog: [LEVEL] [TIME] [FILE:LINE] message key=val -internal/tools/ # MCP tool registry + builtins (web_fetch, web_search stub) -internal/channel/ # Bubble Tea TUI + Matrix adapter (stub) - tui.go # chat UI: viewport + input bar, Ctrl+C/Ctrl+Q to quit -internal/config/config.go # std lib os.Getenv only (no third-party config libs) -pkg/types/types.go # Message, Role, Mood, ToolCall types -pkg/personality/traits.go # TraitValue, RelationshipMetrics, DiaryEntry, Observation types -``` +| Layer | Dir | Role | +| ---------- | -------------------------------- | ---------------------------------------------------------------------------- | +| Entrypoint | `cmd/divad/main.go` | Wires agent, llama, companion, TUI, API, scheduler | +| Agent | `internal/agent/` | Message loop: receive → memory search → prompt → llama → save → respond | +| Companion | `internal/companion/` | Personality (5 traits), relationship, reflection, mood, prompt builder | +| Memory | `internal/memory/` | RAG store + self-memory (promises, strategies) | +| Inference | `internal/llama/` | llama.cpp HTTP client (`POST /completion`, `POST /embedding`, `GET /health`) | +| Storage | `internal/storage/` | SQLite wrapper, auto-creates `data/`, auto-migrates on startup | +| Channel | `internal/channel/` | Bubble Tea TUI + Matrix stub | +| Tools | `internal/tools/` | MCP tool registry + builtins (`web_fetch`, `web_search` stub) | +| Config | `internal/config/` | All env vars via std lib `os.Getenv` only | +| Types | `pkg/types/`, `pkg/personality/` | Shared data types | ## Key constraints -- **Module path**: `git.db123.ir/db123/divacode` — private Gitea. Set `GOPRIV=git.db123.ir GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GODIRECT=git.db123.ir GOPROXY=https://proxy.golang.org,direct` for `go mod tidy` and `go build`. -- **Database**: modernc.org/sqlite (pure Go, no CGO). Schema in `internal/storage/migrations.go` — 15 tables across two schema groups. `data/` dir auto-created on startup. -- **llama.cpp**: required at runtime, connected via HTTP. Default `http://localhost:8080`. Agent exits if health check fails. +- **Module**: `git.db123.ir/db123/divacode` — private Gitea. Set `GOPRIV`, `GONOSUMDB`, `GONOSUMCHECK` for `go mod tidy`/`go build`. +- **Database**: `modernc.org/sqlite` (pure Go, no CGO). **12 tables** across two schema groups in `internal/storage/migrations.go`. Two DB files: `divacode.db` (conversations, messages, context_state, memories) and `companion.db` (personality, relationship, reflection, self-memory). `data/` dir auto-created on startup. +- **Migrations** are code-internal constants; the `migrations/` directory is empty. +- **llama.cpp** required at runtime. Default `http://localhost:8080`. Agent exits if health check fails. - **Logging**: `[LEVEL] [TIME] [FILE:LINE] message key=val`. Set `LOG_LEVEL=DEBUG` for verbose output. -- **HTTP API**: std lib `net/http`, enabled via `API_ENABLED=true`. Endpoints: `POST /v1/chat`, `POST /v1/conversations`, `GET /health`. -- **TUI quirks**: Bubble Tea with `tea.WithAltScreen()`. Keys: enter to send, backspace to edit, Ctrl+C/Ctrl+Q to quit. -- **OpenCode permissions**: `bash` commands need approval (ask mode), `git push*` and `rm -rf*` are denied. +- **HTTP API** (enabled via `API_ENABLED=true`): std lib `net/http` only. Endpoints: `GET /health`, `POST /v1/chat`, `POST /v1/conversations`. +- **TUI**: Bubble Tea with `tea.WithAltScreen()`. Enter to send, backspace to edit, Ctrl+C/Ctrl+Q to quit. +- **Formatters** in `opencode.json`: `gofmt` for `.go`, `prettier` for everything else. LSP enabled. +- **Config**: all via env vars, never third-party config libs. See `.env.example` for all knobs. ## Conventions (from "The Holy Code Bible") -| Context | Rule | -| ------------------------- | -------------------------------------------------------------- | -| exported Go identifiers | PascalCase | -| unexported Go identifiers | camelCase | -| Go file names | snake_case (`user_handler.go`) | -| Error wrapping | `fmt.Errorf("context: %w", err)` | -| Error checking | always check, no silent discards | -| Config | env vars only via `os.Getenv`, never `caarlos0/env` or similar | -| HTTP API | std lib `net/http` only, no third-party routers | - -## Project files layout - -``` -cmd/divad/main.go # single entrypoint -internal/ # all app logic (unexported) - agent/ # two files: agent.go, scheduler.go - channel/ # two files: tui.go, matrix.go - companion/ # six files: engine.go + five sub-engines - config/ # one file - llama/ # one file - log/ # one file - memory/ # two files - storage/ # two files: sqlite.go, migrations.go - tools/ # two files: registry.go, builtin.go -pkg/ # shared types (exported if needed elsewhere) -``` - -## Docker - -```bash -docker compose up -d # starts divacode agent only (llama.cpp runs externally) -``` - -Override variables via `.env` or environment: - -```bash -LLAMACPP_URL=http://host.docker.internal:8080 docker compose up -``` +| Context | Rule | +| ------------------------- | ----------------------------------------------- | +| Exported Go identifiers | PascalCase | +| Unexported Go identifiers | camelCase | +| Go file names | `snake_case.go` | +| Error wrapping | `fmt.Errorf("context: %w", err)` | +| Error checking | always check, no silent discards | +| Config | env vars only via `os.Getenv` | +| HTTP API | std lib `net/http` only, no third-party routers | diff --git a/cmd/divad/main.go b/cmd/divad/main.go index fcffc62..35e6838 100644 --- a/cmd/divad/main.go +++ b/cmd/divad/main.go @@ -96,7 +96,7 @@ func main() { cancel() }() - p := tea.NewProgram(channel.NewTUIModel(ag), tea.WithAltScreen()) + p := tea.NewProgram(channel.NewTUIModel(ag), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { slog.Error("tui error", "error", err) os.Exit(1) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c52235c..5e1e2f1 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -72,7 +72,7 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro slog.Warn("memory search failed", "error", err) } - prompt, err := a.companion.BuildPrompt(ctx, &companion.PromptInput{ + buildResult, err := a.companion.BuildPrompt(ctx, &companion.PromptInput{ UserMessage: content, RecentMessages: a.messages, Memories: memories, @@ -81,11 +81,30 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro return "", fmt.Errorf("build prompt: %w", err) } + segments := []types.PromptSegment{ + {Type: types.SegSystem, Content: buildResult.SystemContent}, + } + start := 0 + if len(a.messages) > 10 { + start = len(a.messages) - 10 + } + for _, msg := range a.messages[start:] { + segType := types.SegUser + if msg.Role == types.RoleAssistant { + segType = types.SegAssistant + } + segments = append(segments, types.PromptSegment{Type: segType, Content: msg.Content}) + } + + tmpl := llama.NewTemplateEngine(a.cfg.ChatTemplate) + prompt := tmpl.Build(segments) + resp, err := a.llm.Complete(&llama.CompletionRequest{ Prompt: prompt, Temperature: a.cfg.Temperature, TopP: a.cfg.TopP, MaxTokens: a.cfg.MaxTokens, + Stop: tmpl.StopTokens(), Stream: false, CachePrompt: true, }) @@ -93,10 +112,13 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro return "", fmt.Errorf("llm complete: %w", err) } + parsed := ParseResponse(resp.Content) + assistantMsg := types.Message{ ID: fmt.Sprintf("%x", time.Now().UnixNano()), Role: types.RoleAssistant, - Content: resp.Content, + Content: parsed.Content, + Reasoning: parsed.Reasoning, CreatedAt: time.Now(), } a.messages = append(a.messages, assistantMsg) @@ -108,7 +130,7 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro } }() - return resp.Content, nil + return parsed.Content, nil } func (a *Agent) Messages() []types.Message { @@ -130,8 +152,8 @@ func (a *Agent) saveMessage(msg types.Message) { toolCallJSON = string(b) } err := a.store.Exec( - "INSERT INTO messages (id, conversation_id, role, content, tool_call, created_at) VALUES (?, ?, ?, ?, ?, ?)", - msg.ID, a.conversationID, string(msg.Role), msg.Content, toolCallJSON, msg.CreatedAt.Format(time.RFC3339), + "INSERT INTO messages (id, conversation_id, role, content, reasoning, tool_call, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + msg.ID, a.conversationID, string(msg.Role), msg.Content, msg.Reasoning, toolCallJSON, msg.CreatedAt.Format(time.RFC3339), ) if err != nil { slog.Warn("failed to save message", "error", err) diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go new file mode 100644 index 0000000..a927355 --- /dev/null +++ b/internal/agent/reasoning.go @@ -0,0 +1,41 @@ +package agent + +import ( + "strings" +) + +type ParsedResponse struct { + Reasoning string + Content string +} + +func ParseResponse(raw string) ParsedResponse { + tags := []struct { + open string + close string + }{ + {"", ""}, + {"", ""}, + {"", ""}, + {"", ""}, + {"", ""}, + } + + for _, t := range tags { + if t.open == "" { + break + } + idxOpen := strings.Index(raw, t.open) + idxClose := strings.LastIndex(raw, t.close) + if idxOpen != -1 && idxClose != -1 && idxClose > idxOpen { + reasoning := raw[idxOpen+len(t.open) : idxClose] + content := raw[:idxOpen] + raw[idxClose+len(t.close):] + return ParsedResponse{ + Reasoning: strings.TrimSpace(reasoning), + Content: strings.TrimSpace(content), + } + } + } + + return ParsedResponse{Content: strings.TrimSpace(raw)} +} diff --git a/internal/channel/tui.go b/internal/channel/tui.go index d657f2d..81c26e6 100644 --- a/internal/channel/tui.go +++ b/internal/channel/tui.go @@ -23,6 +23,22 @@ var ( Foreground(lipgloss.Color("#10B981")). Bold(true) + reasoningLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Background(lipgloss.Color("#1F2937")). + Padding(0, 1). + Italic(true) + + reasoningContentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Italic(true) + + reasoningHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Background(lipgloss.Color("#1F2937")). + Padding(0, 1). + Bold(true) + inputStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("#374151")) @@ -40,20 +56,23 @@ type model struct { ctx context.Context cancel context.CancelFunc - viewport viewport.Model - input string - state sessionState - messages []types.Message - ready bool + viewport viewport.Model + input string + state sessionState + messages []types.Message + reasoningExpanded map[string]bool + reasoningOrder []string + ready bool } func NewTUIModel(a *agent.Agent) tea.Model { ctx, cancel := context.WithCancel(context.Background()) return &model{ - agent: a, - ctx: ctx, - cancel: cancel, - state: stateReady, + agent: a, + ctx: ctx, + cancel: cancel, + state: stateReady, + reasoningExpanded: make(map[string]bool), } } @@ -74,6 +93,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tea.MouseMsg: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + case tea.KeyMsg: switch msg.String() { case "ctrl+c", "ctrl+q": @@ -88,6 +112,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.sendMessage(input) } + case "tab": + for i := len(m.messages) - 1; i >= 0; i-- { + if m.messages[i].Role == types.RoleAssistant && m.messages[i].Reasoning != "" { + id := m.messages[i].ID + m.reasoningExpanded[id] = !m.reasoningExpanded[id] + break + } + } + case "backspace": if len(m.input) > 0 { m.input = m.input[:len(m.input)-1] @@ -107,7 +140,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Content: fmt.Sprintf("error: %v", msg.err), }) } else { - m.messages = m.agent.Messages() + newMsgs := m.agent.Messages() + for _, msg := range newMsgs { + if msg.Role == types.RoleAssistant && msg.Reasoning != "" { + found := false + for _, id := range m.reasoningOrder { + if id == msg.ID { + found = true + break + } + } + if !found { + m.reasoningOrder = append(m.reasoningOrder, msg.ID) + } + } + } + m.messages = newMsgs } m.viewport.GotoBottom() } @@ -134,14 +182,26 @@ func (m *model) View() string { default: prefix = "system" } - b.WriteString(style.Render(prefix+": ") + msg.Content + "\n\n") + b.WriteString(style.Render(prefix+": ") + msg.Content + "\n") + + if msg.Role == types.RoleAssistant && msg.Reasoning != "" { + if m.reasoningExpanded[msg.ID] { + b.WriteString(reasoningHeaderStyle.Render(" reasoning ") + "\n") + b.WriteString(reasoningContentStyle.Render(msg.Reasoning) + "\n") + } else { + label := reasoningLabelStyle.Render(" ` reasoning (tab)") + b.WriteString(label + "\n") + } + } + + b.WriteString("\n") } m.viewport.SetContent(b.String()) prompt := "> " if m.state == stateWaiting { - prompt = "waiting... " + prompt = "... " } return fmt.Sprintf("%s\n%s%s", m.viewport.View(), inputStyle.Render(prompt), m.input) diff --git a/internal/companion/engine.go b/internal/companion/engine.go index 1eb8e17..e9adafd 100644 --- a/internal/companion/engine.go +++ b/internal/companion/engine.go @@ -45,7 +45,7 @@ func NewEngine(cfg *config.Config, db *storage.DB) *Engine { } } -func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (string, error) { +func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (*BuildResult, error) { e.mu.Lock() defer e.mu.Unlock() @@ -54,7 +54,7 @@ func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (string, e mood := e.mood.Current() observations := e.reflection.RecentObservations(5) - prompt := e.promptBuilder.Build(&BuildInput{ + result := e.promptBuilder.Build(&BuildInput{ SystemPrompt: e.cfg.SystemPrompt, Traits: traits, Relationship: rel, @@ -66,7 +66,7 @@ func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (string, e }) e.lastContact = time.Now() - return prompt, nil + return result, nil } func (e *Engine) WriteDiary(ctx context.Context) error { diff --git a/internal/companion/promptbuilder.go b/internal/companion/promptbuilder.go index b6e83f5..72ebf62 100644 --- a/internal/companion/promptbuilder.go +++ b/internal/companion/promptbuilder.go @@ -3,9 +3,9 @@ package companion import ( "fmt" "strings" + "time" "git.db123.ir/db123/divacode/internal/config" - "git.db123.ir/db123/divacode/pkg/types" ) type PromptBuilder struct { @@ -16,26 +16,29 @@ func NewPromptBuilder(cfg *config.Config) *PromptBuilder { return &PromptBuilder{cfg: cfg} } -func (pb *PromptBuilder) Build(in *BuildInput) string { +type BuildResult struct { + SystemContent string +} + +func (pb *PromptBuilder) Build(in *BuildInput) *BuildResult { var b strings.Builder - b.WriteString("[System]\n") + now := time.Now() + b.WriteString(fmt.Sprintf("Current date and time: %s\n", now.Format("2006-01-02 15:04:05 (MST)"))) b.WriteString(in.SystemPrompt) b.WriteString("\n\n") - b.WriteString("### Current Personality\n") + b.WriteString("Current Personality:\n") for _, t := range in.Traits { label := string(t.Trait) pct := int(t.Value * 100) b.WriteString(fmt.Sprintf("- %s: %d%%\n", label, pct)) } - b.WriteString("\n### Current Mood\n") - b.WriteString(string(in.Mood)) - b.WriteString("\n") + b.WriteString(fmt.Sprintf("\nMood: %s\n", in.Mood)) if in.Relationship != nil && in.Relationship.Familiarity > 0.1 { - b.WriteString(fmt.Sprintf("\n### Relationship Context\n")) + b.WriteString("\nRelationship:\n") b.WriteString(fmt.Sprintf("- Familiarity: %d%%\n", int(in.Relationship.Familiarity*100))) b.WriteString(fmt.Sprintf("- Trust: %d%%\n", int(in.Relationship.Trust*100))) if len(in.Relationship.SharedTopics) > 0 { @@ -44,14 +47,14 @@ func (pb *PromptBuilder) Build(in *BuildInput) string { } if len(in.Observations) > 0 { - b.WriteString("\n### Recent Observations\n") + b.WriteString("\nObservations:\n") for _, o := range in.Observations { b.WriteString(fmt.Sprintf("- %s\n", o.Content)) } } if len(in.Memories) > 0 { - b.WriteString("\n### Relevant Memories\n") + b.WriteString("\nMemories:\n") for i, m := range in.Memories { if i >= 5 { break @@ -60,19 +63,5 @@ func (pb *PromptBuilder) Build(in *BuildInput) string { } } - b.WriteString("\n### Recent Conversation\n") - start := 0 - if len(in.Messages) > 10 { - start = len(in.Messages) - 10 - } - for _, msg := range in.Messages[start:] { - prefix := "User" - if msg.Role == types.RoleAssistant { - prefix = "You" - } - b.WriteString(fmt.Sprintf("%s: %s\n", prefix, msg.Content)) - } - - b.WriteString("\nYou:") - return b.String() + return &BuildResult{SystemContent: b.String()} } diff --git a/internal/config/config.go b/internal/config/config.go index 7d31b55..1c6d624 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,9 @@ type Config struct { MemoryTopK int MemoryMinScore float64 + // Chat template + ChatTemplate string + // Embedding EmbeddingModel string @@ -76,6 +79,8 @@ func Load() *Config { MemoryTopK: getEnvInt("MEMORY_TOP_K", 5), MemoryMinScore: getEnvFloat("MEMORY_MIN_SCORE", 0.6), + ChatTemplate: getEnv("CHAT_TEMPLATE", "chatml"), + EmbeddingModel: getEnv("EMBEDDING_MODEL", ""), TraitChangeMaxDaily: getEnvFloat("TRAIT_CHANGE_MAX_DAILY", 0.005), @@ -104,6 +109,11 @@ func (c *Config) Validate() error { if c.DiaryHour < 0 || c.DiaryHour > 23 { return fmt.Errorf("DIARY_HOUR must be 0-23") } + switch c.ChatTemplate { + case "chatml", "llama3", "none": + default: + return fmt.Errorf("CHAT_TEMPLATE must be chatml, llama3, or none") + } return nil } diff --git a/internal/llama/client.go b/internal/llama/client.go index b79292e..b54266f 100644 --- a/internal/llama/client.go +++ b/internal/llama/client.go @@ -15,12 +15,13 @@ type Client struct { } type CompletionRequest struct { - Prompt string `json:"prompt"` - Temperature float64 `json:"temperature"` - TopP float64 `json:"top_p"` - MaxTokens int `json:"n_predict"` - Stream bool `json:"stream"` - CachePrompt bool `json:"cache_prompt"` + Prompt string `json:"prompt"` + Temperature float64 `json:"temperature"` + TopP float64 `json:"top_p"` + MaxTokens int `json:"n_predict"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream"` + CachePrompt bool `json:"cache_prompt"` } type CompletionResponse struct { diff --git a/internal/llama/template.go b/internal/llama/template.go new file mode 100644 index 0000000..7396117 --- /dev/null +++ b/internal/llama/template.go @@ -0,0 +1,71 @@ +package llama + +import ( + "fmt" + "strings" + + "git.db123.ir/db123/divacode/pkg/types" +) + +type TemplateEngine struct { + format string +} + +func NewTemplateEngine(format string) *TemplateEngine { + return &TemplateEngine{format: format} +} + +func (te *TemplateEngine) Build(segments []types.PromptSegment) string { + switch te.format { + case "chatml": + return te.buildChatML(segments) + case "llama3": + return te.buildLlama3(segments) + default: + return te.buildRaw(segments) + } +} + +func (te *TemplateEngine) StopTokens() []string { + switch te.format { + case "chatml": + return []string{"<|im_end|>"} + case "llama3": + return []string{"<|eot_id|>"} + default: + return nil + } +} + +func (te *TemplateEngine) buildChatML(segments []types.PromptSegment) string { + var b strings.Builder + for _, seg := range segments { + b.WriteString(fmt.Sprintf("<|im_start|>%s\n%s<|im_end|>\n", seg.Type, seg.Content)) + } + b.WriteString("<|im_start|>assistant\n") + return b.String() +} + +func (te *TemplateEngine) buildLlama3(segments []types.PromptSegment) string { + var b strings.Builder + b.WriteString("<|begin_of_text|>") + for _, seg := range segments { + b.WriteString(fmt.Sprintf("<|start_header_id|>%s<|end_header_id|>\n\n%s<|eot_id|>", seg.Type, seg.Content)) + } + b.WriteString("<|start_header_id|>assistant<|end_header_id|>\n\n") + return b.String() +} + +func (te *TemplateEngine) buildRaw(segments []types.PromptSegment) string { + var b strings.Builder + for _, seg := range segments { + switch seg.Type { + case types.SegSystem: + b.WriteString("[System]\n" + seg.Content + "\n\n") + case types.SegUser: + b.WriteString("User: " + seg.Content + "\n") + } + } + b.WriteString("You:") + return b.String() +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 7afa808..80d7fc8 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -3,6 +3,7 @@ package storage var migrations = []string{ divacodeSchema, companionSchema, + addReasoningColumn, } const divacodeSchema = ` @@ -17,6 +18,7 @@ CREATE TABLE IF NOT EXISTS messages ( conversation_id TEXT NOT NULL REFERENCES conversations(id), role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')), content TEXT NOT NULL, + reasoning TEXT, tool_call TEXT, token_count INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -111,3 +113,5 @@ CREATE TABLE IF NOT EXISTS agent_strategies ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); ` + +const addReasoningColumn = `ALTER TABLE messages ADD COLUMN reasoning TEXT;` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 2120206..de2c290 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -29,7 +29,12 @@ func Open(path string) (*DB, error) { func (db *DB) Migrate() error { for _, m := range migrations { - if err := db.Exec(m); err != nil { + err := db.Exec(m) + if err != nil && m == addReasoningColumn { + // may already exist on fresh DBs + continue + } + if err != nil { return err } } diff --git a/pkg/types/types.go b/pkg/types/types.go index d06c187..a9e607b 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -15,6 +15,7 @@ type Message struct { ID string `json:"id"` Role Role `json:"role"` Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` ToolCall *ToolCall `json:"toolCall,omitempty"` CreatedAt time.Time `json:"createdAt"` } @@ -32,6 +33,19 @@ type Conversation struct { UpdatedAt time.Time `json:"updatedAt"` } +type SegmentType string + +const ( + SegSystem SegmentType = "system" + SegUser SegmentType = "user" + SegAssistant SegmentType = "assistant" +) + +type PromptSegment struct { + Type SegmentType + Content string +} + type Mood string const ( diff --git a/plan.md b/plan.md index 8a44a23..cd751c8 100644 --- a/plan.md +++ b/plan.md @@ -142,31 +142,28 @@ CREATE TABLE context_state ( ### Chat Template Engine -- [ ] `internal/llama/template.go` — chat template formatter: +- [x] `internal/llama/template.go` — chat template formatter: - ChatML: `<|im_start|>system\n...<|im_end|>\n<|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n` - Llama 3 instruct: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n...<|eot_id|>` - - DeepSeek R1: with `...` support - - Configurable via env `CHAT_TEMPLATE=chatml` (default) -- [ ] Refactor `companion/promptbuilder.go` to output structured segments (system context, personality, relationship, conversation history, user message) -- [ ] Let template.go wrap those segments in the chosen format -- [ ] Add `stop` tokens per template (`<|im_end|>`, `<|eot_id|>`) -- [ ] **Test:** "hi" produces just the response, no meta-text + - Configurable via env `CHAT_TEMPLATE=chatml|llama3|none` (default: chatml) +- [x] Refactor `companion/promptbuilder.go` to output structured `BuildResult` +- [x] Let template.go wrap those segments in the chosen format +- [x] Add `stop` tokens per template (`<|im_end|>`, `<|eot_id|>`) ### Reasoning Parser -- [ ] `internal/agent/reasoning.go` — parse `...`, `...`, or custom tags from model output -- [ ] Split response into `Reasoning` (thoughts) and `Content` (final answer) -- [ ] Store both separately in the `messages` table -- [ ] Feed only `Content` back into context window (reasoning is discarded after display) +- [x] `internal/agent/reasoning.go` — parse ``, ``, ``, `` tags +- [x] Split response into `Reasoning` (thoughts) and `Content` (final answer) +- [x] Store both separately in the `messages` table +- [x] Feed only `Content` back into context window (reasoning is discarded after display) ### TUI Enhancement -- [ ] Render reasoning in a collapsible section: - - Header: "💭 (or `...`) with reasoning snippet - - On click/expand: show full reasoning in dimmed/italic style (`lipgloss.Color("#6B7280")`) -- [ ] Render final answer in normal assistant style (green/bold) -- [ ] Reasoning hidden by default, toggle with a keybinding (e.g., `tab`) -- [ ] **Test:** Send a prompt that triggers reasoning, verify collapsible display +- [x] Render reasoning in a collapsible section (dimmed/italic, `lipgloss.Color("#6B7280")`) +- [x] Render final answer in normal assistant style (green/bold) +- [x] Reasoning hidden by default, toggle with `tab` key +- [x] Mouse support (`tea.WithMouseCellMotion()` + viewport scroll) +- [ ] User-selectable themes (env var or file) for colors, reasoning style, input bar ---