feat: Phase 2 — chat templates, reasoning parser, collapsible TUI, mouse support

- internal/llama/template.go: ChatML/Llama3 template engine with stop tokens
- internal/agent/reasoning.go: parse <think>/<reasoning> tags from LLM output
- companion/promptbuilder.go: output structured BuildResult, add date/time context
- agent/agent.go: wire templates + reasoning split into message loop
- storage: add reasoning TEXT column to messages table + migration
- channel/tui.go: collapsible reasoning (tab toggle), mouse scroll, polished dimmed style
- config: CHAT_TEMPLATE env var (chatml|llama3|none)
- plan.md: mark Phase 2 complete, add themes TODO
This commit is contained in:
2026-06-10 09:42:45 +03:30
parent f57b42fd6e
commit 3895ca68c0
14 changed files with 327 additions and 144 deletions

117
AGENTS.md
View File

@@ -1,91 +1,60 @@
# AGENTS.md — DivaCode # AGENTS.md — DivaCode
## Quick start ## Commands
```bash ```bash
# build make all # lint → vet → build
make build # go build -o build/divad ./cmd/divad 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) Private proxy for deps:
make run # build + run
# verify ```bash
make vet # go vet ./... GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GOPRIV=git.db123.ir \
GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GOPRIV=git.db123.ir GOPROXY=https://proxy.golang.org,direct go vet ./... GOPROXY=https://proxy.golang.org,direct go vet ./...
``` ```
## Architecture ## Architecture
``` | Layer | Dir | Role |
cmd/divad/main.go # entrypoint: wires agent, llama, companion, TUI, API, scheduler | ---------- | -------------------------------- | ---------------------------------------------------------------------------- |
internal/agent/ # core loop: message → memory search → prompt → llama.cpp → save → respond | Entrypoint | `cmd/divad/main.go` | Wires agent, llama, companion, TUI, API, scheduler |
internal/agent/scheduler.go # autonomous tick: mood check, diary write, proactive message | Agent | `internal/agent/` | Message loop: receive → memory search → prompt → llama → save → respond |
internal/companion/ # orchestrates personality, relationship, reflection, mood, prompt builder | Companion | `internal/companion/` | Personality (5 traits), relationship, reflection, mood, prompt builder |
engine.go # BuildPrompt: assembles dynamic prompt from all sub-engines | Memory | `internal/memory/` | RAG store + self-memory (promises, strategies) |
personality.go # 5 bounded traits in SQLite (humor, curiosity, playfulness, formality, affection) | Inference | `internal/llama/` | llama.cpp HTTP client (`POST /completion`, `POST /embedding`, `GET /health`) |
relationship.go # familiarity, trust, shared topics, inside jokes | Storage | `internal/storage/` | SQLite wrapper, auto-creates `data/`, auto-migrates on startup |
reflection.go # diary entries, observations | Channel | `internal/channel/` | Bubble Tea TUI + Matrix stub |
promptbuilder.go # generates dynamic system prompt section | Tools | `internal/tools/` | MCP tool registry + builtins (`web_fetch`, `web_search` stub) |
mood.go # mood derivation from recent interaction + energy level | Config | `internal/config/` | All env vars via std lib `os.Getenv` only |
internal/memory/ # RAG store + self-memory (promises, strategies, mistakes) | Types | `pkg/types/`, `pkg/personality/` | Shared data types |
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
```
## Key constraints ## 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`. - **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). Schema in `internal/storage/migrations.go` — 15 tables across two schema groups. `data/` dir auto-created on startup. - **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.
- **llama.cpp**: required at runtime, connected via HTTP. Default `http://localhost:8080`. Agent exits if health check fails. - **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. - **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`. - **HTTP API** (enabled via `API_ENABLED=true`): std lib `net/http` only. Endpoints: `GET /health`, `POST /v1/chat`, `POST /v1/conversations`.
- **TUI quirks**: Bubble Tea with `tea.WithAltScreen()`. Keys: enter to send, backspace to edit, Ctrl+C/Ctrl+Q to quit. - **TUI**: Bubble Tea with `tea.WithAltScreen()`. 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. - **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") ## Conventions (from "The Holy Code Bible")
| Context | Rule | | Context | Rule |
| ------------------------- | -------------------------------------------------------------- | | ------------------------- | ----------------------------------------------- |
| exported Go identifiers | PascalCase | | Exported Go identifiers | PascalCase |
| unexported Go identifiers | camelCase | | Unexported Go identifiers | camelCase |
| Go file names | snake_case (`user_handler.go`) | | Go file names | `snake_case.go` |
| Error wrapping | `fmt.Errorf("context: %w", err)` | | Error wrapping | `fmt.Errorf("context: %w", err)` |
| Error checking | always check, no silent discards | | Error checking | always check, no silent discards |
| Config | env vars only via `os.Getenv`, never `caarlos0/env` or similar | | Config | env vars only via `os.Getenv` |
| HTTP API | std lib `net/http` only, no third-party routers | | 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
```

View File

@@ -96,7 +96,7 @@ func main() {
cancel() 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 { if _, err := p.Run(); err != nil {
slog.Error("tui error", "error", err) slog.Error("tui error", "error", err)
os.Exit(1) os.Exit(1)

View File

@@ -72,7 +72,7 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro
slog.Warn("memory search failed", "error", err) 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, UserMessage: content,
RecentMessages: a.messages, RecentMessages: a.messages,
Memories: memories, Memories: memories,
@@ -81,11 +81,30 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro
return "", fmt.Errorf("build prompt: %w", err) 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{ resp, err := a.llm.Complete(&llama.CompletionRequest{
Prompt: prompt, Prompt: prompt,
Temperature: a.cfg.Temperature, Temperature: a.cfg.Temperature,
TopP: a.cfg.TopP, TopP: a.cfg.TopP,
MaxTokens: a.cfg.MaxTokens, MaxTokens: a.cfg.MaxTokens,
Stop: tmpl.StopTokens(),
Stream: false, Stream: false,
CachePrompt: true, CachePrompt: true,
}) })
@@ -93,10 +112,13 @@ func (a *Agent) HandleMessage(ctx context.Context, content string) (string, erro
return "", fmt.Errorf("llm complete: %w", err) return "", fmt.Errorf("llm complete: %w", err)
} }
parsed := ParseResponse(resp.Content)
assistantMsg := types.Message{ assistantMsg := types.Message{
ID: fmt.Sprintf("%x", time.Now().UnixNano()), ID: fmt.Sprintf("%x", time.Now().UnixNano()),
Role: types.RoleAssistant, Role: types.RoleAssistant,
Content: resp.Content, Content: parsed.Content,
Reasoning: parsed.Reasoning,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
a.messages = append(a.messages, assistantMsg) 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 { func (a *Agent) Messages() []types.Message {
@@ -130,8 +152,8 @@ func (a *Agent) saveMessage(msg types.Message) {
toolCallJSON = string(b) toolCallJSON = string(b)
} }
err := a.store.Exec( err := a.store.Exec(
"INSERT INTO messages (id, conversation_id, role, content, tool_call, created_at) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO messages (id, conversation_id, role, content, reasoning, tool_call, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
msg.ID, a.conversationID, string(msg.Role), msg.Content, toolCallJSON, msg.CreatedAt.Format(time.RFC3339), msg.ID, a.conversationID, string(msg.Role), msg.Content, msg.Reasoning, toolCallJSON, msg.CreatedAt.Format(time.RFC3339),
) )
if err != nil { if err != nil {
slog.Warn("failed to save message", "error", err) slog.Warn("failed to save message", "error", err)

View File

@@ -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
}{
{"<reasoning>", "</reasoning>"},
{"<reason>", "</reason>"},
{"<thinking>", "</thinking>"},
{"<think>", "</think>"},
{"", ""},
}
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)}
}

View File

@@ -23,6 +23,22 @@ var (
Foreground(lipgloss.Color("#10B981")). Foreground(lipgloss.Color("#10B981")).
Bold(true) 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(). inputStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#374151")) BorderForeground(lipgloss.Color("#374151"))
@@ -40,20 +56,23 @@ type model struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
viewport viewport.Model viewport viewport.Model
input string input string
state sessionState state sessionState
messages []types.Message messages []types.Message
ready bool reasoningExpanded map[string]bool
reasoningOrder []string
ready bool
} }
func NewTUIModel(a *agent.Agent) tea.Model { func NewTUIModel(a *agent.Agent) tea.Model {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
return &model{ return &model{
agent: a, agent: a,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
state: stateReady, 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 return m, nil
case tea.MouseMsg:
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "ctrl+q": 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) 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": case "backspace":
if len(m.input) > 0 { if len(m.input) > 0 {
m.input = m.input[:len(m.input)-1] 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), Content: fmt.Sprintf("error: %v", msg.err),
}) })
} else { } 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() m.viewport.GotoBottom()
} }
@@ -134,14 +182,26 @@ func (m *model) View() string {
default: default:
prefix = "system" 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()) m.viewport.SetContent(b.String())
prompt := "> " prompt := "> "
if m.state == stateWaiting { if m.state == stateWaiting {
prompt = "waiting... " prompt = "... "
} }
return fmt.Sprintf("%s\n%s%s", m.viewport.View(), inputStyle.Render(prompt), m.input) return fmt.Sprintf("%s\n%s%s", m.viewport.View(), inputStyle.Render(prompt), m.input)

View File

@@ -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() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -54,7 +54,7 @@ func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (string, e
mood := e.mood.Current() mood := e.mood.Current()
observations := e.reflection.RecentObservations(5) observations := e.reflection.RecentObservations(5)
prompt := e.promptBuilder.Build(&BuildInput{ result := e.promptBuilder.Build(&BuildInput{
SystemPrompt: e.cfg.SystemPrompt, SystemPrompt: e.cfg.SystemPrompt,
Traits: traits, Traits: traits,
Relationship: rel, Relationship: rel,
@@ -66,7 +66,7 @@ func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (string, e
}) })
e.lastContact = time.Now() e.lastContact = time.Now()
return prompt, nil return result, nil
} }
func (e *Engine) WriteDiary(ctx context.Context) error { func (e *Engine) WriteDiary(ctx context.Context) error {

View File

@@ -3,9 +3,9 @@ package companion
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"git.db123.ir/db123/divacode/internal/config" "git.db123.ir/db123/divacode/internal/config"
"git.db123.ir/db123/divacode/pkg/types"
) )
type PromptBuilder struct { type PromptBuilder struct {
@@ -16,26 +16,29 @@ func NewPromptBuilder(cfg *config.Config) *PromptBuilder {
return &PromptBuilder{cfg: cfg} 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 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(in.SystemPrompt)
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString("### Current Personality\n") b.WriteString("Current Personality:\n")
for _, t := range in.Traits { for _, t := range in.Traits {
label := string(t.Trait) label := string(t.Trait)
pct := int(t.Value * 100) pct := int(t.Value * 100)
b.WriteString(fmt.Sprintf("- %s: %d%%\n", label, pct)) b.WriteString(fmt.Sprintf("- %s: %d%%\n", label, pct))
} }
b.WriteString("\n### Current Mood\n") b.WriteString(fmt.Sprintf("\nMood: %s\n", in.Mood))
b.WriteString(string(in.Mood))
b.WriteString("\n")
if in.Relationship != nil && in.Relationship.Familiarity > 0.1 { 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("- Familiarity: %d%%\n", int(in.Relationship.Familiarity*100)))
b.WriteString(fmt.Sprintf("- Trust: %d%%\n", int(in.Relationship.Trust*100))) b.WriteString(fmt.Sprintf("- Trust: %d%%\n", int(in.Relationship.Trust*100)))
if len(in.Relationship.SharedTopics) > 0 { if len(in.Relationship.SharedTopics) > 0 {
@@ -44,14 +47,14 @@ func (pb *PromptBuilder) Build(in *BuildInput) string {
} }
if len(in.Observations) > 0 { if len(in.Observations) > 0 {
b.WriteString("\n### Recent Observations\n") b.WriteString("\nObservations:\n")
for _, o := range in.Observations { for _, o := range in.Observations {
b.WriteString(fmt.Sprintf("- %s\n", o.Content)) b.WriteString(fmt.Sprintf("- %s\n", o.Content))
} }
} }
if len(in.Memories) > 0 { if len(in.Memories) > 0 {
b.WriteString("\n### Relevant Memories\n") b.WriteString("\nMemories:\n")
for i, m := range in.Memories { for i, m := range in.Memories {
if i >= 5 { if i >= 5 {
break break
@@ -60,19 +63,5 @@ func (pb *PromptBuilder) Build(in *BuildInput) string {
} }
} }
b.WriteString("\n### Recent Conversation\n") return &BuildResult{SystemContent: b.String()}
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()
} }

View File

@@ -36,6 +36,9 @@ type Config struct {
MemoryTopK int MemoryTopK int
MemoryMinScore float64 MemoryMinScore float64
// Chat template
ChatTemplate string
// Embedding // Embedding
EmbeddingModel string EmbeddingModel string
@@ -76,6 +79,8 @@ func Load() *Config {
MemoryTopK: getEnvInt("MEMORY_TOP_K", 5), MemoryTopK: getEnvInt("MEMORY_TOP_K", 5),
MemoryMinScore: getEnvFloat("MEMORY_MIN_SCORE", 0.6), MemoryMinScore: getEnvFloat("MEMORY_MIN_SCORE", 0.6),
ChatTemplate: getEnv("CHAT_TEMPLATE", "chatml"),
EmbeddingModel: getEnv("EMBEDDING_MODEL", ""), EmbeddingModel: getEnv("EMBEDDING_MODEL", ""),
TraitChangeMaxDaily: getEnvFloat("TRAIT_CHANGE_MAX_DAILY", 0.005), TraitChangeMaxDaily: getEnvFloat("TRAIT_CHANGE_MAX_DAILY", 0.005),
@@ -104,6 +109,11 @@ func (c *Config) Validate() error {
if c.DiaryHour < 0 || c.DiaryHour > 23 { if c.DiaryHour < 0 || c.DiaryHour > 23 {
return fmt.Errorf("DIARY_HOUR must be 0-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 return nil
} }

View File

@@ -15,12 +15,13 @@ type Client struct {
} }
type CompletionRequest struct { type CompletionRequest struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"` TopP float64 `json:"top_p"`
MaxTokens int `json:"n_predict"` MaxTokens int `json:"n_predict"`
Stream bool `json:"stream"` Stop []string `json:"stop,omitempty"`
CachePrompt bool `json:"cache_prompt"` Stream bool `json:"stream"`
CachePrompt bool `json:"cache_prompt"`
} }
type CompletionResponse struct { type CompletionResponse struct {

View File

@@ -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()
}

View File

@@ -3,6 +3,7 @@ package storage
var migrations = []string{ var migrations = []string{
divacodeSchema, divacodeSchema,
companionSchema, companionSchema,
addReasoningColumn,
} }
const divacodeSchema = ` const divacodeSchema = `
@@ -17,6 +18,7 @@ CREATE TABLE IF NOT EXISTS messages (
conversation_id TEXT NOT NULL REFERENCES conversations(id), conversation_id TEXT NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')), role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')),
content TEXT NOT NULL, content TEXT NOT NULL,
reasoning TEXT,
tool_call TEXT, tool_call TEXT,
token_count INTEGER, token_count INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
` `
const addReasoningColumn = `ALTER TABLE messages ADD COLUMN reasoning TEXT;`

View File

@@ -29,7 +29,12 @@ func Open(path string) (*DB, error) {
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
for _, m := range migrations { 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 return err
} }
} }

View File

@@ -15,6 +15,7 @@ type Message struct {
ID string `json:"id"` ID string `json:"id"`
Role Role `json:"role"` Role Role `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
ToolCall *ToolCall `json:"toolCall,omitempty"` ToolCall *ToolCall `json:"toolCall,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
@@ -32,6 +33,19 @@ type Conversation struct {
UpdatedAt time.Time `json:"updatedAt"` 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 type Mood string
const ( const (

31
plan.md
View File

@@ -142,31 +142,28 @@ CREATE TABLE context_state (
### Chat Template Engine ### 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` - 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|>` - 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|llama3|none` (default: chatml)
- Configurable via env `CHAT_TEMPLATE=chatml` (default) - [x] Refactor `companion/promptbuilder.go` to output structured `BuildResult`
- [ ] Refactor `companion/promptbuilder.go` to output structured segments (system context, personality, relationship, conversation history, user message) - [x] Let template.go wrap those segments in the chosen format
- [ ] Let template.go wrap those segments in the chosen format - [x] Add `stop` tokens per template (`<|im_end|>`, `<|eot_id|>`)
- [ ] Add `stop` tokens per template (`<|im_end|>`, `<|eot_id|>`)
- [ ] **Test:** "hi" produces just the response, no meta-text
### Reasoning Parser ### Reasoning Parser
- [ ] `internal/agent/reasoning.go` — parse `...`, `...`, or custom tags from model output - [x] `internal/agent/reasoning.go` — parse `<think>`, `<reasoning>`, `<reason>`, `<thinking>` tags
- [ ] Split response into `Reasoning` (thoughts) and `Content` (final answer) - [x] Split response into `Reasoning` (thoughts) and `Content` (final answer)
- [ ] Store both separately in the `messages` table - [x] Store both separately in the `messages` table
- [ ] Feed only `Content` back into context window (reasoning is discarded after display) - [x] Feed only `Content` back into context window (reasoning is discarded after display)
### TUI Enhancement ### TUI Enhancement
- [ ] Render reasoning in a collapsible section: - [x] Render reasoning in a collapsible section (dimmed/italic, `lipgloss.Color("#6B7280")`)
- Header: "💭 (or `...`) with reasoning snippet - [x] Render final answer in normal assistant style (green/bold)
- On click/expand: show full reasoning in dimmed/italic style (`lipgloss.Color("#6B7280")`) - [x] Reasoning hidden by default, toggle with `tab` key
- [ ] Render final answer in normal assistant style (green/bold) - [x] Mouse support (`tea.WithMouseCellMotion()` + viewport scroll)
- [ ] Reasoning hidden by default, toggle with a keybinding (e.g., `tab`) - [ ] User-selectable themes (env var or file) for colors, reasoning style, input bar
- [ ] **Test:** Send a prompt that triggers reasoning, verify collapsible display
--- ---