Compare commits

..

25 Commits

Author SHA1 Message Date
b09184e112 docs: add command palette, themes, sessions to Phase 2 roadmap 2026-06-10 09:51:24 +03:30
3895ca68c0 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
2026-06-10 09:51:24 +03:30
f57b42fd6e docs: refine Phase 2 — chat templates + reasoning parser + collapsible TUI thoughts section 2026-06-10 09:51:24 +03:30
9903c7b12b docs: restore detail in plan.md — schema SQL, table summary, principles, pgvector future, enriched phase descriptions 2026-06-10 09:51:24 +03:30
cd37d01cae docs: update plan.md with current status, discovered issues, and reordered phases 2026-06-10 09:51:24 +03:30
e570399b3e docs: add AGENTS.md with architecture, commands, and conventions 2026-06-10 09:51:24 +03:30
0244b8df35 fix: create database directory before opening SQLite file 2026-06-10 09:51:24 +03:30
75724f2d7c docs: add project README and coding standards 2026-06-10 09:51:14 +03:30
b8b030ef06 docs: add comprehensive 13-phase action plan for DivaCode 2026-06-10 09:50:14 +03:30
902b0c59a5 feat: add Dockerfile and docker-compose for containerized deployment 2026-06-10 09:50:14 +03:30
3c98161423 feat(types): add shared types for messages, moods, personality traits, and diary 2026-06-10 09:50:14 +03:30
0f89f5f2d6 fix: restrict gitignore patterns to avoid ignoring cmd/divad directory 2026-06-10 09:50:14 +03:30
4a8cc75793 feat(cmd): add main entrypoint wiring TUI, API, scheduler, and llama.cpp 2026-06-10 09:50:14 +03:30
68db9deea8 feat(channel): add Bubble Tea TUI chat interface and Matrix adapter stub 2026-06-10 09:50:14 +03:30
2d22523795 feat(tools): add MCP tool registry with web_fetch and web_search builtins 2026-06-10 09:50:14 +03:30
1c1b7ffe7e feat(memory): add RAG memory store and self-memory system (promises, strategies) 2026-06-10 09:50:14 +03:30
7661027dbf feat(companion): add engine orchestrator tying all sub-engines together 2026-06-10 09:50:14 +03:30
e27a10e275 feat(companion): add reflection engine, diary system, and dynamic prompt builder 2026-06-10 09:50:14 +03:30
147a51ecba feat(companion): add personality, relationship, and mood engines 2026-06-10 09:50:14 +03:30
8d99c1856b feat(agent): add core agent loop and autonomous scheduler 2026-06-10 09:50:14 +03:30
a2eae2ef52 feat(llama): add llama.cpp HTTP client for completion and embedding 2026-06-10 09:50:14 +03:30
d29b194f73 feat(storage): add SQLite wrapper and full schema migrations (15 tables) 2026-06-10 09:50:14 +03:30
57cfe4516f feat(log): add structured logging handler [LEVEL] [TIME] [FILE:LINE] 2026-06-10 09:50:14 +03:30
ec4c3a91b3 feat(config): add environment-based configuration with std lib 2026-06-10 09:50:14 +03:30
cdc699d00b chore: initialize Go module and project scaffold 2026-06-10 09:50:09 +03:30
37 changed files with 4279 additions and 1 deletions

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# llama.cpp server
LLAMACPP_URL=http://localhost:8080
MODEL_NAME=
SYSTEM_PROMPT=You are Diva, a curious and thoughtful AI companion.
TEMPERATURE=0.85
TOP_P=0.9
MAX_TOKENS=2048
CONTEXT_SIZE=8192
# SQLite
DB_PATH=./data/divacode.db
COMPANION_DB_PATH=./data/companion.db
# Matrix (optional — set ENABLED=true to activate)
MATRIX_ENABLED=false
MATRIX_HOMESERVER=
MATRIX_USER=
MATRIX_TOKEN=
# Scheduler
SCHEDULER_INTERVAL=15m
AUTO_DIARY=true
DIARY_HOUR=23
# Memory
MEMORY_TOP_K=5
MEMORY_MIN_SCORE=0.6
# Embedding
EMBEDDING_MODEL=
# Personality
TRAIT_CHANGE_MAX_DAILY=0.005
TRAIT_CHANGE_MAX_MONTHLY=0.05
# Logging
LOG_LEVEL=INFO
# HTTP API (optional)
API_ENABLED=false
API_PORT=8080

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Binaries
/divad
/build/
*.exe
*.test
# Data
/data/
# Env
.env
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Go
vendor/
*.cover.out

60
AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# AGENTS.md — DivaCode
## Commands
```bash
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/
```
Private proxy for deps:
```bash
GONOSUMDB=git.db123.ir GONOSUMCHECK=git.db123.ir GOPRIV=git.db123.ir \
GOPROXY=https://proxy.golang.org,direct go vet ./...
```
## Architecture
| 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**: `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** (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.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 |

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.26.4-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /divad ./cmd/divad
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=builder /divad /divad
EXPOSE 8080
ENTRYPOINT ["/divad"]

View File

@@ -16,3 +16,7 @@ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE A
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
<<<<<<< HEAD
=======
>>>>>>> 0a5f1d1 (chore: initialize Go module and project scaffold)

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
BINARY=divad
BUILD_DIR=build
.PHONY: all build run test lint vet clean
all: lint vet build
build:
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/divad
run: build
./$(BUILD_DIR)/$(BINARY)
test:
go test -race -count=1 ./...
lint:
golangci-lint run ./...
vet:
go vet ./...
clean:
rm -rf $(BUILD_DIR)/
tidy:
go mod tidy

View File

@@ -1,3 +1,3 @@
# DivaCode
Diva Code... the AI that is past being an Agent... it's a being now... with a delicate heart... <3
Diva Code... the AI that is past being an Agent... it's a being now... with a delicate heart... <3

1055
The Holy Code Bible.md Normal file

File diff suppressed because it is too large Load Diff

151
cmd/divad/main.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"git.db123.ir/db123/divacode/internal/agent"
"git.db123.ir/db123/divacode/internal/channel"
"git.db123.ir/db123/divacode/internal/companion"
"git.db123.ir/db123/divacode/internal/config"
"git.db123.ir/db123/divacode/internal/llama"
"git.db123.ir/db123/divacode/internal/log"
"git.db123.ir/db123/divacode/internal/memory"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/internal/tools"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
cfg := config.Load()
log.Init(cfg.LogLevel)
if err := cfg.Validate(); err != nil {
slog.Error("config validation failed", "error", err)
os.Exit(1)
}
slog.Info("starting divacode",
"llama_cpp_url", cfg.LlamaCppURL,
"db_path", cfg.DBPath,
"log_level", cfg.LogLevel,
)
db, err := storage.Open(cfg.DBPath)
if err != nil {
slog.Error("failed to open database", "path", cfg.DBPath, "error", err)
os.Exit(1)
}
defer db.Close()
if err := db.Migrate(); err != nil {
slog.Error("database migration failed", "error", err)
os.Exit(1)
}
slog.Info("database migrated", "path", cfg.DBPath)
llm := llama.NewClient(cfg.LlamaCppURL)
if err := llm.Health(); err != nil {
slog.Error("llama.cpp not reachable", "url", cfg.LlamaCppURL, "error", err)
os.Exit(1)
}
slog.Info("llama.cpp connected", "url", cfg.LlamaCppURL)
toolReg := tools.NewRegistry()
tools.RegisterBuiltins(toolReg)
memStore := memory.NewStore(db)
compEngine := companion.NewEngine(cfg, db)
ag := agent.New(cfg, llm, db, memStore, compEngine, toolReg)
if err := ag.NewConversation(); err != nil {
slog.Error("failed to start conversation", "error", err)
os.Exit(1)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched := agent.NewScheduler(ag, cfg.SchedulerInterval, cfg.DiaryHour)
go sched.Start(ctx)
matrix := channel.NewMatrixClient(cfg, ag)
go func() {
if err := matrix.Start(ctx); err != nil {
slog.Error("matrix client failed", "error", err)
}
}()
if cfg.APIEnabled {
go startAPI(cfg, ag)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
slog.Info("shutting down...")
sched.Stop()
cancel()
}()
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)
}
}
func startAPI(cfg *config.Config, ag *agent.Agent) {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/v1/conversations", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
if err := ag.NewConversation(); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"conversationId":"%s"}`, ag.ConversationID())
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/v1/chat", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
msg := r.FormValue("message")
if msg == "" {
http.Error(w, `{"error":"message is required"}`, http.StatusBadRequest)
return
}
resp, err := ag.HandleMessage(r.Context(), msg)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"response":"%s"}`, resp)
})
slog.Info("api server starting", "port", cfg.APIPort)
if err := http.ListenAndServe(":"+cfg.APIPort, mux); err != nil {
slog.Error("api server failed", "error", err)
}
}

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
divacode:
build: .
container_name: divacode-agent
restart: unless-stopped
ports:
- "8081:8080"
environment:
LLAMACPP_URL: ${LLAMACPP_URL:-http://host.docker.internal:8080}
DB_PATH: /data/divacode.db
COMPANION_DB_PATH: /data/companion.db
SYSTEM_PROMPT: ${SYSTEM_PROMPT:-You are Diva, a curious and thoughtful AI companion.}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
TEMPERATURE: ${TEMPERATURE:-0.85}
TOP_P: ${TOP_P:-0.9}
MAX_TOKENS: ${MAX_TOKENS:-2048}
CONTEXT_SIZE: ${CONTEXT_SIZE:-8192}
API_ENABLED: "true"
API_PORT: "8080"
SCHEDULER_INTERVAL: ${SCHEDULER_INTERVAL:-15m}
AUTO_DIARY: ${AUTO_DIARY:-true}
DIARY_HOUR: ${DIARY_HOUR:-23}
volumes:
- divacode-data:/data
volumes:
divacode-data:

40
go.mod Normal file
View File

@@ -0,0 +1,40 @@
module git.db123.ir/db123/divacode
go 1.26.4
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
modernc.org/sqlite v1.52.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

96
go.sum Normal file
View File

@@ -0,0 +1,96 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

161
internal/agent/agent.go Normal file
View File

@@ -0,0 +1,161 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
"git.db123.ir/db123/divacode/internal/companion"
"git.db123.ir/db123/divacode/internal/config"
"git.db123.ir/db123/divacode/internal/llama"
"git.db123.ir/db123/divacode/internal/memory"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/internal/tools"
"git.db123.ir/db123/divacode/pkg/types"
)
type Agent struct {
mu sync.Mutex
cfg *config.Config
llm *llama.Client
mem *memory.Store
store *storage.DB
companion *companion.Engine
tools *tools.Registry
conversationID string
messages []types.Message
}
func New(cfg *config.Config, llm *llama.Client, store *storage.DB, mem *memory.Store, comp *companion.Engine, toolReg *tools.Registry) *Agent {
return &Agent{
cfg: cfg,
llm: llm,
store: store,
mem: mem,
companion: comp,
tools: toolReg,
}
}
func (a *Agent) NewConversation() error {
a.mu.Lock()
defer a.mu.Unlock()
id := fmt.Sprintf("%x", time.Now().UnixNano())
a.conversationID = id
a.messages = nil
if err := a.store.Exec("INSERT INTO conversations (id) VALUES (?)", id); err != nil {
return fmt.Errorf("new conversation: %w", err)
}
slog.Info("new conversation", "id", id)
return nil
}
func (a *Agent) HandleMessage(ctx context.Context, content string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
userMsg := types.Message{
ID: fmt.Sprintf("%x", time.Now().UnixNano()),
Role: types.RoleUser,
Content: content,
CreatedAt: time.Now(),
}
a.messages = append(a.messages, userMsg)
a.saveMessage(userMsg)
memories, err := a.mem.Search(ctx, content, a.cfg.MemoryTopK)
if err != nil {
slog.Warn("memory search failed", "error", err)
}
buildResult, err := a.companion.BuildPrompt(ctx, &companion.PromptInput{
UserMessage: content,
RecentMessages: a.messages,
Memories: memories,
})
if err != nil {
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,
})
if err != nil {
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: parsed.Content,
Reasoning: parsed.Reasoning,
CreatedAt: time.Now(),
}
a.messages = append(a.messages, assistantMsg)
a.saveMessage(assistantMsg)
go func() {
if err := a.mem.Store(context.Background(), userMsg.Content, "conversation"); err != nil {
slog.Warn("memory store failed", "error", err)
}
}()
return parsed.Content, nil
}
func (a *Agent) Messages() []types.Message {
a.mu.Lock()
defer a.mu.Unlock()
return a.messages
}
func (a *Agent) ConversationID() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.conversationID
}
func (a *Agent) saveMessage(msg types.Message) {
toolCallJSON := ""
if msg.ToolCall != nil {
b, _ := json.Marshal(msg.ToolCall)
toolCallJSON = string(b)
}
err := a.store.Exec(
"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)
}
}

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

@@ -0,0 +1,69 @@
package agent
import (
"context"
"log/slog"
"time"
)
type Scheduler struct {
agent *Agent
interval time.Duration
diaryHour int
stopCh chan struct{}
}
func NewScheduler(agent *Agent, interval time.Duration, diaryHour int) *Scheduler {
return &Scheduler{
agent: agent,
interval: interval,
diaryHour: diaryHour,
stopCh: make(chan struct{}),
}
}
func (s *Scheduler) Start(ctx context.Context) {
slog.Info("scheduler started", "interval", s.interval)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("scheduler stopped via context")
return
case <-s.stopCh:
slog.Info("scheduler stopped via signal")
return
case <-ticker.C:
s.tick(ctx)
}
}
}
func (s *Scheduler) Stop() {
close(s.stopCh)
}
func (s *Scheduler) tick(ctx context.Context) {
mood := s.agent.companion.Mood()
lastContact := s.agent.companion.LastContactDuration()
trust := s.agent.companion.Trust()
slog.Debug("scheduler tick", "mood", mood, "lastContact", lastContact, "trust", trust)
if lastContact > 48*time.Hour && trust > 0.6 {
s.agent.companion.QueueAutonomousMessage(
"Haven't heard from you in a while. Everything okay?",
)
}
if s.agent.cfg.AutoDiary {
hour := time.Now().Hour()
if hour >= s.diaryHour || hour < 1 {
if err := s.agent.companion.WriteDiary(ctx); err != nil {
slog.Warn("diary write failed", "error", err)
}
}
}
}

View File

@@ -0,0 +1,33 @@
package channel
import (
"context"
"log/slog"
"git.db123.ir/db123/divacode/internal/agent"
"git.db123.ir/db123/divacode/internal/config"
)
type MatrixClient struct {
cfg *config.Config
agent *agent.Agent
}
func NewMatrixClient(cfg *config.Config, a *agent.Agent) *MatrixClient {
return &MatrixClient{
cfg: cfg,
agent: a,
}
}
func (mc *MatrixClient) Start(ctx context.Context) error {
if !mc.cfg.MatrixEnabled {
slog.Info("matrix channel disabled")
return nil
}
slog.Info("matrix client connecting",
"homeserver", mc.cfg.MatrixHomeserver,
"user", mc.cfg.MatrixUser,
)
return nil
}

220
internal/channel/tui.go Normal file
View File

@@ -0,0 +1,220 @@
package channel
import (
"context"
"fmt"
"log/slog"
"strings"
"git.db123.ir/db123/divacode/internal/agent"
"git.db123.ir/db123/divacode/pkg/types"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
userStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7C3AED")).
Bold(true)
assistantStyle = lipgloss.NewStyle().
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"))
)
type sessionState int
const (
stateReady sessionState = iota
stateWaiting
)
type model struct {
agent *agent.Agent
ctx context.Context
cancel context.CancelFunc
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,
reasoningExpanded: make(map[string]bool),
}
}
func (m *model) Init() tea.Cmd {
return nil
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-3)
m.viewport.YPosition = 0
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 3
}
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":
m.cancel()
return m, tea.Quit
case "enter":
if m.state == stateReady && strings.TrimSpace(m.input) != "" {
input := strings.TrimSpace(m.input)
m.input = ""
m.state = stateWaiting
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]
}
default:
if m.state == stateReady {
m.input += msg.String()
}
}
case responseMsg:
m.state = stateReady
if msg.err != nil {
m.messages = append(m.messages, types.Message{
Role: types.RoleSystem,
Content: fmt.Sprintf("error: %v", msg.err),
})
} else {
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()
}
return m, nil
}
func (m *model) View() string {
if !m.ready {
return "initializing divacode..."
}
var b strings.Builder
for _, msg := range m.messages {
prefix := "sys"
style := lipgloss.NewStyle()
switch msg.Role {
case types.RoleUser:
prefix = "you"
style = userStyle
case types.RoleAssistant:
prefix = "diva"
style = assistantStyle
default:
prefix = "system"
}
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 = "... "
}
return fmt.Sprintf("%s\n%s%s", m.viewport.View(), inputStyle.Render(prompt), m.input)
}
type responseMsg struct {
err error
}
func (m *model) sendMessage(content string) tea.Cmd {
return func() tea.Msg {
slog.Info("tui message", "content", content)
_, err := m.agent.HandleMessage(m.ctx, content)
return responseMsg{err: err}
}
}

View File

@@ -0,0 +1,123 @@
package companion
import (
"context"
"sync"
"time"
"git.db123.ir/db123/divacode/internal/config"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/pkg/personality"
"git.db123.ir/db123/divacode/pkg/types"
)
type PromptInput struct {
UserMessage string
RecentMessages []types.Message
Memories []string
}
type Engine struct {
mu sync.Mutex
cfg *config.Config
db *storage.DB
personality *PersonalityEngine
relationship *RelationshipEngine
reflection *ReflectionEngine
promptBuilder *PromptBuilder
mood *MoodEngine
lastContact time.Time
pendingAutoMsg string
}
func NewEngine(cfg *config.Config, db *storage.DB) *Engine {
return &Engine{
cfg: cfg,
db: db,
personality: NewPersonalityEngine(db),
relationship: NewRelationshipEngine(db),
reflection: NewReflectionEngine(db),
promptBuilder: NewPromptBuilder(cfg),
mood: NewMoodEngine(),
lastContact: time.Now(),
}
}
func (e *Engine) BuildPrompt(ctx context.Context, input *PromptInput) (*BuildResult, error) {
e.mu.Lock()
defer e.mu.Unlock()
traits := e.personality.Current()
rel := e.relationship.Current()
mood := e.mood.Current()
observations := e.reflection.RecentObservations(5)
result := e.promptBuilder.Build(&BuildInput{
SystemPrompt: e.cfg.SystemPrompt,
Traits: traits,
Relationship: rel,
Mood: mood,
Observations: observations,
UserMessage: input.UserMessage,
Memories: input.Memories,
Messages: input.RecentMessages,
})
e.lastContact = time.Now()
return result, nil
}
func (e *Engine) WriteDiary(ctx context.Context) error {
e.mu.Lock()
defer e.mu.Unlock()
return e.reflection.GenerateDiary(ctx, e.lastContact)
}
func (e *Engine) Mood() types.Mood {
e.mu.Lock()
defer e.mu.Unlock()
return e.mood.Current()
}
func (e *Engine) Trust() float64 {
e.mu.Lock()
defer e.mu.Unlock()
return e.relationship.Trust()
}
func (e *Engine) LastContactDuration() time.Duration {
e.mu.Lock()
defer e.mu.Unlock()
return time.Since(e.lastContact)
}
func (e *Engine) QueueAutonomousMessage(msg string) {
e.mu.Lock()
defer e.mu.Unlock()
e.pendingAutoMsg = msg
}
func (e *Engine) PopAutonomousMessage() string {
e.mu.Lock()
defer e.mu.Unlock()
msg := e.pendingAutoMsg
e.pendingAutoMsg = ""
return msg
}
func (e *Engine) Personality() *PersonalityEngine { return e.personality }
func (e *Engine) Relationship() *RelationshipEngine { return e.relationship }
func (e *Engine) Reflection() *ReflectionEngine { return e.reflection }
type BuildInput struct {
SystemPrompt string
Traits []personality.TraitValue
Relationship *personality.RelationshipMetrics
Mood types.Mood
Observations []personality.Observation
UserMessage string
Memories []string
Messages []types.Message
}

View File

@@ -0,0 +1,79 @@
package companion
import (
"math/rand"
"sync"
"time"
"git.db123.ir/db123/divacode/pkg/types"
)
type MoodEngine struct {
mu sync.Mutex
current types.Mood
updatedAt time.Time
energy float64
}
func NewMoodEngine() *MoodEngine {
return &MoodEngine{
current: types.MoodNeutral,
updatedAt: time.Now(),
energy: 1.0,
}
}
func (m *MoodEngine) Current() types.Mood {
m.mu.Lock()
defer m.mu.Unlock()
return m.current
}
func (m *MoodEngine) Set(mood types.Mood) {
m.mu.Lock()
defer m.mu.Unlock()
m.current = mood
m.updatedAt = time.Now()
}
func (m *MoodEngine) DeriveFromInteraction(success bool, topicIntensity float64) types.Mood {
m.mu.Lock()
defer m.mu.Unlock()
m.energy -= 0.05
if m.energy < 0 {
m.energy = 0
}
if !success {
m.current = types.MoodAnnoyed
} else if topicIntensity > 0.7 {
if rand.Float64() > 0.5 {
m.current = types.MoodCurious
} else {
m.current = types.MoodPlayful
}
} else if m.energy < 0.3 {
m.current = types.MoodTired
} else {
m.current = types.MoodNeutral
}
m.updatedAt = time.Now()
return m.current
}
func (m *MoodEngine) Recharge() {
m.mu.Lock()
defer m.mu.Unlock()
m.energy += 0.1
if m.energy > 1.0 {
m.energy = 1.0
}
}
func (m *MoodEngine) Energy() float64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.energy
}

View File

@@ -0,0 +1,107 @@
package companion
import (
"fmt"
"log/slog"
"sync"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/pkg/personality"
)
type PersonalityEngine struct {
mu sync.Mutex
db *storage.DB
def map[personality.Trait]personality.TraitValue
}
func NewPersonalityEngine(db *storage.DB) *PersonalityEngine {
e := &PersonalityEngine{
db: db,
def: map[personality.Trait]personality.TraitValue{
personality.TraitHumor: {Trait: personality.TraitHumor, Value: 0.5, MinValue: 0.3, MaxValue: 0.7},
personality.TraitCuriosity: {Trait: personality.TraitCuriosity, Value: 0.8, MinValue: 0.7, MaxValue: 0.9},
personality.TraitPlayfulness: {Trait: personality.TraitPlayfulness, Value: 0.5, MinValue: 0.3, MaxValue: 0.7},
personality.TraitFormality: {Trait: personality.TraitFormality, Value: 0.3, MinValue: 0.2, MaxValue: 0.5},
personality.TraitAffection: {Trait: personality.TraitAffection, Value: 0.5, MinValue: 0.3, MaxValue: 0.7},
},
}
e.seed()
return e
}
func (e *PersonalityEngine) seed() {
for _, tv := range e.def {
err := e.db.Exec(
"INSERT OR IGNORE INTO personality_traits (id, trait, value, min_value, max_value) VALUES (?, ?, ?, ?, ?)",
fmt.Sprintf("trait_%s", tv.Trait), string(tv.Trait), tv.Value, tv.MinValue, tv.MaxValue,
)
if err != nil {
slog.Warn("seed trait failed", "trait", tv.Trait, "error", err)
}
}
}
func (e *PersonalityEngine) Current() []personality.TraitValue {
e.mu.Lock()
defer e.mu.Unlock()
rows, err := e.db.Query("SELECT trait, value, min_value, max_value, updated_at FROM personality_traits")
if err != nil {
slog.Warn("read traits failed", "error", err)
return e.defaults()
}
defer rows.Close()
var traits []personality.TraitValue
for rows.Next() {
var tv personality.TraitValue
var t, updated string
if err := rows.Scan(&t, &tv.Value, &tv.MinValue, &tv.MaxValue, &updated); err != nil {
continue
}
tv.Trait = personality.Trait(t)
traits = append(traits, tv)
}
if len(traits) == 0 {
return e.defaults()
}
return traits
}
func (e *PersonalityEngine) Update(trait personality.Trait, delta float64) error {
e.mu.Lock()
defer e.mu.Unlock()
current := e.get(trait)
newVal := current.Value + delta
if newVal < current.MinValue {
newVal = current.MinValue
}
if newVal > current.MaxValue {
newVal = current.MaxValue
}
return e.db.Exec(
"UPDATE personality_traits SET value = ?, updated_at = datetime('now') WHERE trait = ?",
newVal, string(trait),
)
}
func (e *PersonalityEngine) get(trait personality.Trait) personality.TraitValue {
row := e.db.QueryRow("SELECT trait, value, min_value, max_value FROM personality_traits WHERE trait = ?", string(trait))
var tv personality.TraitValue
var t string
if err := row.Scan(&t, &tv.Value, &tv.MinValue, &tv.MaxValue); err != nil {
return e.def[trait]
}
tv.Trait = personality.Trait(t)
return tv
}
func (e *PersonalityEngine) defaults() []personality.TraitValue {
var out []personality.TraitValue
for _, tv := range e.def {
out = append(out, tv)
}
return out
}

View File

@@ -0,0 +1,67 @@
package companion
import (
"fmt"
"strings"
"time"
"git.db123.ir/db123/divacode/internal/config"
)
type PromptBuilder struct {
cfg *config.Config
}
func NewPromptBuilder(cfg *config.Config) *PromptBuilder {
return &PromptBuilder{cfg: cfg}
}
type BuildResult struct {
SystemContent string
}
func (pb *PromptBuilder) Build(in *BuildInput) *BuildResult {
var b strings.Builder
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")
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(fmt.Sprintf("\nMood: %s\n", in.Mood))
if in.Relationship != nil && in.Relationship.Familiarity > 0.1 {
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 {
b.WriteString(fmt.Sprintf("- Shared interests: %s\n", strings.Join(in.Relationship.SharedTopics, ", ")))
}
}
if len(in.Observations) > 0 {
b.WriteString("\nObservations:\n")
for _, o := range in.Observations {
b.WriteString(fmt.Sprintf("- %s\n", o.Content))
}
}
if len(in.Memories) > 0 {
b.WriteString("\nMemories:\n")
for i, m := range in.Memories {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("- %s\n", m))
}
}
return &BuildResult{SystemContent: b.String()}
}

View File

@@ -0,0 +1,106 @@
package companion
import (
"context"
"log/slog"
"sync"
"time"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/pkg/personality"
)
type ReflectionEngine struct {
mu sync.Mutex
db *storage.DB
}
func NewReflectionEngine(db *storage.DB) *ReflectionEngine {
return &ReflectionEngine{db: db}
}
func (e *ReflectionEngine) GenerateDiary(ctx context.Context, lastContact time.Time) error {
e.mu.Lock()
defer e.mu.Unlock()
rows, err := e.db.Query(
"SELECT role, content FROM messages WHERE created_at >= datetime('now', '-1 day') ORDER BY created_at",
)
if err != nil {
return err
}
defer rows.Close()
var recentConvo []string
for rows.Next() {
var role, content string
if err := rows.Scan(&role, &content); err != nil {
continue
}
recentConvo = append(recentConvo, role+": "+content)
}
if len(recentConvo) == 0 {
slog.Debug("no messages to diarize")
return nil
}
summary := "Talked about various topics."
if len(recentConvo) > 0 {
first := recentConvo[0]
if len(first) > 80 {
first = first[:80]
}
summary = "Conversations included: " + first
}
entry := personality.DiaryEntry{
ID: time.Now().Format("20060102"),
Date: time.Now().Format("2006-01-02"),
Summary: summary,
Mood: "neutral",
CreatedAt: time.Now(),
}
return e.db.Exec(
`INSERT INTO diary_entries (id, date, summary, mood, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET summary = excluded.summary, mood = excluded.mood`,
entry.ID, entry.Date, entry.Summary, entry.Mood,
)
}
func (e *ReflectionEngine) RecentObservations(limit int) []personality.Observation {
e.mu.Lock()
defer e.mu.Unlock()
rows, err := e.db.Query(
"SELECT id, content, confidence, category, applied FROM observations ORDER BY created_at DESC LIMIT ?",
limit,
)
if err != nil {
return nil
}
defer rows.Close()
var obs []personality.Observation
for rows.Next() {
var o personality.Observation
if err := rows.Scan(&o.ID, &o.Content, &o.Confidence, &o.Category, &o.Applied); err != nil {
continue
}
obs = append(obs, o)
}
return obs
}
func (e *ReflectionEngine) AddObservation(content, category string, confidence float64) error {
e.mu.Lock()
defer e.mu.Unlock()
return e.db.Exec(
`INSERT INTO observations (id, content, confidence, category, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
"obs_"+time.Now().Format("150405"), content, confidence, category,
)
}

View File

@@ -0,0 +1,121 @@
package companion
import (
"log/slog"
"sync"
"git.db123.ir/db123/divacode/internal/storage"
"git.db123.ir/db123/divacode/pkg/personality"
)
type RelationshipEngine struct {
mu sync.Mutex
db *storage.DB
}
func NewRelationshipEngine(db *storage.DB) *RelationshipEngine {
e := &RelationshipEngine{db: db}
e.seed()
return e
}
func (e *RelationshipEngine) seed() {
defaults := map[string]float64{
"familiarity": 0.0,
"trust": 0.3,
}
for metric, val := range defaults {
err := e.db.Exec(
"INSERT OR IGNORE INTO relationship_metrics (id, metric, value) VALUES (?, ?, ?)",
"rel_"+metric, metric, val,
)
if err != nil {
slog.Warn("seed metric failed", "metric", metric, "error", err)
}
}
}
func (e *RelationshipEngine) Current() *personality.RelationshipMetrics {
e.mu.Lock()
defer e.mu.Unlock()
rm := &personality.RelationshipMetrics{}
row := e.db.QueryRow("SELECT value FROM relationship_metrics WHERE metric = 'familiarity'")
row.Scan(&rm.Familiarity)
row = e.db.QueryRow("SELECT value FROM relationship_metrics WHERE metric = 'trust'")
row.Scan(&rm.Trust)
topics, _ := e.db.Query("SELECT topic FROM shared_topics ORDER BY count DESC LIMIT 20")
if topics != nil {
defer topics.Close()
for topics.Next() {
var t string
topics.Scan(&t)
rm.SharedTopics = append(rm.SharedTopics, t)
}
}
jokes, _ := e.db.Query("SELECT joke FROM inside_jokes LIMIT 20")
if jokes != nil {
defer jokes.Close()
for jokes.Next() {
var j string
jokes.Scan(&j)
rm.InsideJokes = append(rm.InsideJokes, j)
}
}
return rm
}
func (e *RelationshipEngine) Trust() float64 {
var v float64
err := e.db.QueryRow("SELECT value FROM relationship_metrics WHERE metric = 'trust'").Scan(&v)
if err != nil {
return 0.3
}
return v
}
func (e *RelationshipEngine) UpdateFamiliarity(delta float64) error {
return e.adjust("familiarity", delta)
}
func (e *RelationshipEngine) UpdateTrust(delta float64) error {
return e.adjust("trust", delta)
}
func (e *RelationshipEngine) AddSharedTopic(topic string) error {
return e.db.Exec(
`INSERT INTO shared_topics (id, topic, count, last_discussed)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(topic) DO UPDATE SET count = count + 1, last_discussed = datetime('now')`,
"topic_"+topic, topic,
)
}
func (e *RelationshipEngine) adjust(metric string, delta float64) error {
e.mu.Lock()
defer e.mu.Unlock()
var current float64
row := e.db.QueryRow("SELECT value FROM relationship_metrics WHERE metric = ?", metric)
if err := row.Scan(&current); err != nil {
current = 0
}
newVal := current + delta
if newVal < 0 {
newVal = 0
}
if newVal > 1 {
newVal = 1
}
return e.db.Exec(
"UPDATE relationship_metrics SET value = ?, updated_at = datetime('now') WHERE metric = ?",
newVal, metric,
)
}

161
internal/config/config.go Normal file
View File

@@ -0,0 +1,161 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
// llama.cpp server
LlamaCppURL string
Model string
SystemPrompt string
Temperature float64
TopP float64
MaxTokens int
ContextSize int
// SQLite
DBPath string
CompanionDB string
// Matrix
MatrixEnabled bool
MatrixHomeserver string
MatrixUser string
MatrixToken string
// Scheduler
SchedulerInterval time.Duration
AutoDiary bool
DiaryHour int
// Memory
MemoryTopK int
MemoryMinScore float64
// Chat template
ChatTemplate string
// Embedding
EmbeddingModel string
// Personality
TraitChangeMaxDaily float64
TraitChangeMaxMonthly float64
// Logging
LogLevel string
// API
APIEnabled bool
APIPort string
}
func Load() *Config {
return &Config{
LlamaCppURL: getEnv("LLAMACPP_URL", "http://localhost:8080"),
Model: getEnv("MODEL_NAME", ""),
SystemPrompt: getEnv("SYSTEM_PROMPT", "You are Diva, a curious and thoughtful AI companion."),
Temperature: getEnvFloat("TEMPERATURE", 0.85),
TopP: getEnvFloat("TOP_P", 0.9),
MaxTokens: getEnvInt("MAX_TOKENS", 2048),
ContextSize: getEnvInt("CONTEXT_SIZE", 8192),
DBPath: getEnv("DB_PATH", "./data/divacode.db"),
CompanionDB: getEnv("COMPANION_DB_PATH", "./data/companion.db"),
MatrixEnabled: getEnvBool("MATRIX_ENABLED", false),
MatrixHomeserver: getEnv("MATRIX_HOMESERVER", ""),
MatrixUser: getEnv("MATRIX_USER", ""),
MatrixToken: getEnv("MATRIX_TOKEN", ""),
SchedulerInterval: getEnvDuration("SCHEDULER_INTERVAL", 15*time.Minute),
AutoDiary: getEnvBool("AUTO_DIARY", true),
DiaryHour: getEnvInt("DIARY_HOUR", 23),
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),
TraitChangeMaxMonthly: getEnvFloat("TRAIT_CHANGE_MAX_MONTHLY", 0.05),
LogLevel: getEnv("LOG_LEVEL", "INFO"),
APIEnabled: getEnvBool("API_ENABLED", false),
APIPort: getEnv("API_PORT", "8080"),
}
}
func (c *Config) Validate() error {
if c.LlamaCppURL == "" {
return fmt.Errorf("LLAMACPP_URL is required")
}
if c.DBPath == "" {
return fmt.Errorf("DB_PATH is required")
}
if c.CompanionDB == "" {
return fmt.Errorf("COMPANION_DB_PATH is required")
}
if c.SystemPrompt == "" {
return fmt.Errorf("SYSTEM_PROMPT is required")
}
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
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func getEnvFloat(key string, fallback float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
func getEnvDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}

104
internal/llama/client.go Normal file
View File

@@ -0,0 +1,104 @@
package llama
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
}
type CompletionRequest struct {
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 {
Content string `json:"content"`
Tokens int `json:"tokens_predicted"`
Timings struct {
PredictedPerSecond float64 `json:"predicted_per_second"`
} `json:"timings"`
}
type EmbeddingRequest struct {
Content string `json:"content"`
}
type EmbeddingResponse struct {
Embedding []float64 `json:"embedding"`
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
func (c *Client) Health() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("llama.cpp health check failed: %s", resp.Status)
}
return nil
}
func (c *Client) Complete(req *CompletionRequest) (*CompletionResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Post(c.baseURL+"/completion", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("llama.cpp completion failed (%d): %s", resp.StatusCode, string(data))
}
var res CompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
return &res, nil
}
func (c *Client) Embed(content string) ([]float64, error) {
body, err := json.Marshal(EmbeddingRequest{Content: content})
if err != nil {
return nil, err
}
resp, err := c.httpClient.Post(c.baseURL+"/embedding", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("llama.cpp embedding failed (%d): %s", resp.StatusCode, string(data))
}
var res EmbeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
return res.Embedding, nil
}

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

125
internal/log/handler.go Normal file
View File

@@ -0,0 +1,125 @@
package log
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"runtime"
"strings"
"sync"
)
type Handler struct {
mu sync.Mutex
w io.Writer
level slog.Level
attrs []slog.Attr
groups []string
}
func New(w io.Writer, level slog.Level) *Handler {
return &Handler{w: w, level: level}
}
func (h *Handler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level
}
func (h *Handler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
level := r.Level.String()
switch r.Level {
case slog.LevelDebug:
level = "DEBUG"
case slog.LevelInfo:
level = "INFO"
case slog.LevelWarn:
level = "WARN"
case slog.LevelError:
level = "ERROR"
}
_, file, line, ok := runtime.Caller(4)
if !ok {
file = "???"
}
short := file
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' {
short = file[i+1:]
break
}
}
t := r.Time.Format("2006-01-02 15:04:05.000")
var b strings.Builder
b.WriteString(fmt.Sprintf("[%s] [%s] [%s:%d]", level, t, short, line))
if len(h.groups) > 0 {
b.WriteString(" [" + strings.Join(h.groups, ".") + "]")
}
if r.Message != "" {
b.WriteString(" " + r.Message)
}
r.Attrs(func(a slog.Attr) bool {
b.WriteString(fmt.Sprintf(" %s=%v", a.Key, a.Value.Any()))
return true
})
for _, a := range h.attrs {
b.WriteString(fmt.Sprintf(" %s=%v", a.Key, a.Value.Any()))
}
b.WriteString("\n")
_, err := h.w.Write([]byte(b.String()))
return err
}
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := h.clone()
h2.attrs = append(h2.attrs, attrs...)
return h2
}
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(h2.groups, name)
return h2
}
func (h *Handler) clone() *Handler {
return &Handler{
w: h.w,
level: h.level,
attrs: append([]slog.Attr{}, h.attrs...),
groups: append([]string{}, h.groups...),
}
}
func Init(level string) {
var l slog.Level
switch strings.ToUpper(level) {
case "DEBUG":
l = slog.LevelDebug
case "INFO":
l = slog.LevelInfo
case "WARN":
l = slog.LevelWarn
case "ERROR":
l = slog.LevelError
default:
l = slog.LevelInfo
}
slog.SetDefault(slog.New(New(os.Stderr, l)))
}

View File

@@ -0,0 +1,94 @@
package memory
import (
"time"
"git.db123.ir/db123/divacode/internal/storage"
)
type SelfMemory struct {
db *storage.DB
}
type Promise struct {
ID string
Promise string
Context string
Fulfilled bool
CreatedAt time.Time
Deadline *time.Time
}
type Strategy struct {
ID string
Situation string
Strategy string
Outcome string
CreatedAt time.Time
}
func NewSelfMemory(db *storage.DB) *SelfMemory {
return &SelfMemory{db: db}
}
func (sm *SelfMemory) AddPromise(promise, context string, deadline *time.Time) error {
id := time.Now().Format("150405.000")
return sm.db.Exec(
"INSERT INTO agent_promises (id, promise, context, deadline) VALUES (?, ?, ?, ?)",
id, promise, context, deadline,
)
}
func (sm *SelfMemory) FulfillPromise(id string) error {
return sm.db.Exec("UPDATE agent_promises SET fulfilled = 1 WHERE id = ?", id)
}
func (sm *SelfMemory) ActivePromises() ([]Promise, error) {
rows, err := sm.db.Query(
"SELECT id, promise, context, fulfilled, created_at FROM agent_promises WHERE fulfilled = 0 ORDER BY created_at DESC",
)
if err != nil {
return nil, err
}
defer rows.Close()
var promises []Promise
for rows.Next() {
var p Promise
var created string
if err := rows.Scan(&p.ID, &p.Promise, &p.Context, &p.Fulfilled, &created); err != nil {
continue
}
promises = append(promises, p)
}
return promises, nil
}
func (sm *SelfMemory) AddStrategy(situation, strategy, outcome string) error {
id := time.Now().Format("150405.000")
return sm.db.Exec(
"INSERT INTO agent_strategies (id, situation, strategy, outcome) VALUES (?, ?, ?, ?)",
id, situation, strategy, outcome,
)
}
func (sm *SelfMemory) StrategiesByOutcome(outcome string) ([]Strategy, error) {
rows, err := sm.db.Query(
"SELECT id, situation, strategy, outcome FROM agent_strategies WHERE outcome = ? ORDER BY created_at DESC",
outcome,
)
if err != nil {
return nil, err
}
defer rows.Close()
var strategies []Strategy
for rows.Next() {
var s Strategy
if err := rows.Scan(&s.ID, &s.Situation, &s.Strategy, &s.Outcome); err != nil {
continue
}
strategies = append(strategies, s)
}
return strategies, nil
}

71
internal/memory/store.go Normal file
View File

@@ -0,0 +1,71 @@
package memory
import (
"context"
"log/slog"
"sync"
"time"
"git.db123.ir/db123/divacode/internal/storage"
)
type Store struct {
mu sync.Mutex
db *storage.DB
}
func NewStore(db *storage.DB) *Store {
return &Store{db: db}
}
func (s *Store) Store(ctx context.Context, content string, source string) error {
s.mu.Lock()
defer s.mu.Unlock()
importance := 0.5
if len(content) > 100 {
importance = 0.7
}
id := time.Now().Format("150405.000000000")
return s.db.Exec(
"INSERT INTO memories (id, content, importance, source, created_at) VALUES (?, ?, ?, ?, datetime('now'))",
id, content, importance, source,
)
}
func (s *Store) Search(ctx context.Context, query string, topK int) ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows, err := s.db.Query(
`SELECT content FROM memories
WHERE content LIKE ?
ORDER BY importance DESC, created_at DESC
LIMIT ?`,
"%"+query+"%", topK,
)
if err != nil {
return nil, err
}
defer rows.Close()
var results []string
for rows.Next() {
var content string
if err := rows.Scan(&content); err != nil {
continue
}
results = append(results, content)
}
slog.Debug("memory search", "query", query, "results", len(results))
return results, nil
}
func (s *Store) UpdateImportance(ctx context.Context, id string, importance float64) error {
return s.db.Exec(
"UPDATE memories SET importance = ?, last_accessed = datetime('now') WHERE id = ?",
importance, id,
)
}

View File

@@ -0,0 +1,117 @@
package storage
var migrations = []string{
divacodeSchema,
companionSchema,
addReasoningColumn,
}
const divacodeSchema = `
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
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'))
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE TABLE IF NOT EXISTS context_state (
conversation_id TEXT PRIMARY KEY REFERENCES conversations(id),
token_count INTEGER NOT NULL DEFAULT 0,
summary TEXT
);
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
embedding BLOB,
source TEXT,
importance REAL DEFAULT 0.5,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT
);
CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
`
const companionSchema = `
CREATE TABLE IF NOT EXISTS personality_traits (
id TEXT PRIMARY KEY,
trait TEXT NOT NULL UNIQUE,
value REAL NOT NULL DEFAULT 0.5,
min_value REAL NOT NULL DEFAULT 0.0,
max_value REAL NOT NULL DEFAULT 1.0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS relationship_metrics (
id TEXT PRIMARY KEY,
metric TEXT NOT NULL UNIQUE,
value REAL NOT NULL DEFAULT 0.0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS shared_topics (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 1,
last_discussed TEXT
);
CREATE TABLE IF NOT EXISTS inside_jokes (
id TEXT PRIMARY KEY,
joke TEXT NOT NULL,
context TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS diary_entries (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
summary TEXT NOT NULL,
mood TEXT,
topics TEXT,
observations TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS observations (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
confidence REAL DEFAULT 0.5,
category TEXT,
applied INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS agent_promises (
id TEXT PRIMARY KEY,
promise TEXT NOT NULL,
context TEXT,
fulfilled INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deadline TEXT
);
CREATE TABLE IF NOT EXISTS agent_strategies (
id TEXT PRIMARY KEY,
situation TEXT NOT NULL,
strategy TEXT NOT NULL,
outcome TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
const addReasoningColumn = `ALTER TABLE messages ADD COLUMN reasoning TEXT;`

View File

@@ -0,0 +1,59 @@
package storage
import (
"database/sql"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
}
func Open(path string) (*DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return &DB{db}, nil
}
func (db *DB) Migrate() error {
for _, m := range migrations {
err := db.Exec(m)
if err != nil && m == addReasoningColumn {
// may already exist on fresh DBs
continue
}
if err != nil {
return err
}
}
return nil
}
func (db *DB) Exec(query string, args ...interface{}) error {
_, err := db.DB.Exec(query, args...)
return err
}
func (db *DB) Query(query string, args ...interface{}) (*sql.Rows, error) {
return db.DB.Query(query, args...)
}
func (db *DB) QueryRow(query string, args ...interface{}) *sql.Row {
return db.DB.QueryRow(query, args...)
}
func (db *DB) Close() error {
return db.DB.Close()
}

48
internal/tools/builtin.go Normal file
View File

@@ -0,0 +1,48 @@
package tools
import (
"context"
"io"
"net/http"
"strings"
"time"
)
func RegisterBuiltins(r *Registry) {
r.Register(&Tool{
Name: "web_fetch",
Description: "Fetch content from a URL",
Parameters: []Param{
{Name: "url", Type: "string", Required: true, Description: "URL to fetch"},
},
Execute: webFetch,
})
r.Register(&Tool{
Name: "web_search",
Description: "Search the web for information",
Parameters: []Param{
{Name: "query", Type: "string", Required: true, Description: "search query"},
},
Execute: webSearch,
})
}
func webFetch(ctx context.Context, args string) (string, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(strings.TrimSpace(args))
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func webSearch(ctx context.Context, args string) (string, error) {
return "Web search not yet implemented. Query: " + args, nil
}

View File

@@ -0,0 +1,62 @@
package tools
import (
"context"
"sync"
)
type ToolFunc func(ctx context.Context, args string) (string, error)
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters []Param `json:"parameters"`
Execute ToolFunc `json:"-"`
}
type Param struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Description string `json:"description"`
}
type Registry struct {
mu sync.Mutex
tools map[string]*Tool
}
func NewRegistry() *Registry {
return &Registry{tools: make(map[string]*Tool)}
}
func (r *Registry) Register(t *Tool) {
r.mu.Lock()
defer r.mu.Unlock()
r.tools[t.Name] = t
}
func (r *Registry) Get(name string) (*Tool, bool) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.tools[name]
return t, ok
}
func (r *Registry) List() []*Tool {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*Tool, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
return out
}
func (r *Registry) Execute(ctx context.Context, name, args string) (string, error) {
t, ok := r.Get(name)
if !ok {
return "", nil
}
return t.Execute(ctx, args)
}

146
opencode.json Normal file
View File

@@ -0,0 +1,146 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"llama.cpp": {
"npm": "@ai-sdk/openai-compatible",
"name": "llama-server (local)",
"options": {
"baseURL": "http://127.0.0.1:8082/v1"
},
"models": {
"coder": {
"name": "coder",
"limit": {
"context": 32000,
"output": 65536
}
}
}
}
},
"lsp": true,
"formatter": {
"gofmt": {
"command": ["gofmt", "-w", "$FILE"],
"extensions": [".go"]
},
"prettier": {
"command": ["prettier", "--write", "$FILE"],
"extensions": [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".vue",
".html",
".htm",
".css",
".scss",
".less",
".sass",
".json",
".jsonc",
".yaml",
".yml",
".md",
".mdx"
]
},
"php-cs-fixer": {
"command": ["php-cs-fixer", "fix", "$FILE"],
"extensions": [".php"]
}
},
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest"],
"enabled": false
},
"postgres": {
"type": "local",
"command": ["uvx", "postgres-mcp"],
"enabled": false,
"environment": {
"DATABASE_URI": "postgresql://user:pass@localhost:5432/mydb"
}
},
"mariadb": {
"type": "local",
"command": ["npx", "-y", "@oleander/mcp-server-mariadb"],
"enabled": false,
"environment": {
"MARIADB_HOST": "127.0.0.1",
"MARIADB_PORT": "3306",
"MARIADB_USER": "root",
"MARIADB_PASS": "",
"MARIADB_DB": "mydb"
}
},
"redis": {
"type": "local",
"command": ["uvx", "redis-mcp-server"],
"enabled": false,
"environment": {
"REDIS_URL": "redis://localhost:6379"
}
},
"sqlite": {
"type": "local",
"command": ["npx", "-y", "mcp-sqlite", "./data/dev.db"],
"enabled": false
},
"docker": {
"type": "local",
"command": ["uvx", "mcp-server-docker"],
"enabled": false
},
"gitea": {
"type": "local",
"command": ["uvx", "gitea-mcp"],
"enabled": false,
"environment": {
"GITEA_URL": "https://git.db1232.ir",
"GITEA_TOKEN": "your-personal-access-token"
}
},
"matrix": {
"type": "local",
"command": ["npx", "-y", "@iflow-mcp/mjknowles-matrix-mcp-server"],
"enabled": false,
"environment": {
"MATRIX_HOMESERVER_URL": "https://tempchat.db123.ir",
"MATRIX_ACCESS_TOKEN": "your-access-token"
}
},
"fallow": {
"type": "local",
"command": ["npx", "-y", "--package", "fallow", "fallow-mcp"],
"enabled": false
}
},
"permission": {
"read": "allow",
"glob": "allow",
"grep": "allow",
"list": "allow",
"lsp": "allow",
"webfetch": "allow",
"websearch": "allow",
"question": "allow",
"todowrite": "allow",
"edit": "ask",
"write": "ask",
"task": "ask",
"external_directory": "ask",
"skill": "ask",
"doom_loop": "deny",
"bash": {
"*": "ask",
"git push*": "deny",
"rm -rf*": "deny"
}
}
}

53
pkg/personality/traits.go Normal file
View File

@@ -0,0 +1,53 @@
package personality
import "time"
type Trait string
const (
TraitHumor Trait = "humor"
TraitCuriosity Trait = "curiosity"
TraitPlayfulness Trait = "playfulness"
TraitFormality Trait = "formality"
TraitAffection Trait = "affection"
)
type TraitValue struct {
Trait Trait `json:"trait"`
Value float64 `json:"value"`
MinValue float64 `json:"minValue"`
MaxValue float64 `json:"maxValue"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PersonalityProfile struct {
Traits []TraitValue `json:"traits"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RelationshipMetrics struct {
Familiarity float64 `json:"familiarity"`
Trust float64 `json:"trust"`
SharedTopics []string `json:"sharedTopics"`
InsideJokes []string `json:"insideJokes"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DiaryEntry struct {
ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
Summary string `json:"summary"`
Mood string `json:"mood"`
Topics []string `json:"topics"`
Observations []string `json:"observations"`
CreatedAt time.Time `json:"createdAt"`
}
type Observation struct {
ID string `json:"id"`
Content string `json:"content"`
Confidence float64 `json:"confidence"`
Category string `json:"category"`
Applied bool `json:"applied"`
CreatedAt time.Time `json:"createdAt"`
}

58
pkg/types/types.go Normal file
View File

@@ -0,0 +1,58 @@
package types
import "time"
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleSystem Role = "system"
RoleTool Role = "tool"
)
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"`
}
type ToolCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
Result string `json:"result,omitempty"`
}
type Conversation struct {
ID string `json:"id"`
Messages []Message `json:"messages"`
CreatedAt time.Time `json:"createdAt"`
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 (
MoodNeutral Mood = "neutral"
MoodCurious Mood = "curious"
MoodPlayful Mood = "playful"
MoodThoughtful Mood = "thoughtful"
MoodAnnoyed Mood = "annoyed"
MoodTired Mood = "tired"
)

444
plan.md Normal file
View File

@@ -0,0 +1,444 @@
# DivaCode — AI Being Platform Action Plan
**Language:** Go
**Inference:** llama.cpp (HTTP server), OpenAI-compatible API planned
**Storage:** SQLite (primary), PostgreSQL + pgvector (future)
**Inspiration:** OpenHer, Agent Diva, nanobot
---
## Current Status (2026-06-10)
**Builds:** yes
**Runs:** yes (requires llama.cpp on :8080)
**Known issues:**
- Prompt template is raw text — model dumps reasoning/self-correction meta-text instead of just the response
- No chat template configurability (ChatML, Llama 3 instruct, etc.)
- TUI is basic (no streaming, no word-wrap polish)
- Memory store uses keyword `LIKE` search, not real embeddings
- OpenAI-compatible adapter not yet built
- Matrix adapter is a stub
---
## Architecture Overview
```
User
├── TUI (Bubble Tea)
└── Matrix (future)
┌─────┴──────────────────────────────┐
│ Core Agent Loop │
│ (internal/agent/) │
│ - Message routing │
│ - Tool invocation │
│ - Context management │
└─────┬──────────────────────────────┘
┌─────┴──────────────────────────────┐
│ Companion Layer │
│ (internal/companion/) │
│ ├─ Personality (5 bounded traits) │
│ ├─ Relationship (familiarity,trust)│
│ ├─ Reflection (diary,observations)│
│ ├─ Mood (6 states + energy) │
│ └─ Prompt Composer (dynamic ctx) │
└─────┬──────────────────────────────┘
┌─────┴──────────────────────────────┐
│ Memory Layer │
│ (internal/memory/) │
│ ├─ Short-term (ephemeral context) │
│ ├─ Long-term (SQLite → pgvector) │
│ ├─ Episodic / Diary (sqlite) │
│ └─ Self-memory (promises,strats) │
└─────┬──────────────────────────────┘
┌─────┴──────────────────────────────┐
│ Inference (internal/llama/) │
│ ├─ llama.cpp HTTP client │
│ └─ OpenAI-compatible (planned) │
└────────────────────────────────────┘
Skills / Tools:
├─ web_fetch
├─ web_search (stub)
└─ MCP protocol (future)
├─ Obsidian vault
├─ Gitea
├─ Actual Budget
└─ Custom MCP tools
```
---
## Phase 0 — Foundation & Scaffolding
**Status: COMPLETE**
- [x] Go module: `git.db123.ir/db123/divacode`
- [x] Project structure per Holy Code Bible conventions
- [x] `internal/config/` — std lib `os.Getenv` only
- [x] `internal/storage/``modernc.org/sqlite` (pure Go, no CGO), auto-creates `data/` dir
- [x] `internal/llama/client.go` — HTTP client for `POST /completion`, `POST /embedding`, `GET /health`
- [x] `.env.example`, `Makefile`, `.gitignore`, `Dockerfile`, `docker-compose.yml`
- [x] `AGENTS.md` with architecture, commands, conventions
- [x] `pkg/types/types.go` — Message, Role, Mood, ToolCall types
- [x] `pkg/personality/traits.go` — TraitValue, RelationshipMetrics, DiaryEntry, Observation types
---
## Phase 1 — Core Agent Loop
**Status: COMPLETE** (minor issues remain)
- [x] `internal/agent/agent.go` — message loop: receive → memory search → prompt → llama → save → respond
- [x] `internal/agent/scheduler.go` — background goroutine, configurable tick (default 15m), mood check, diary trigger, autonomous message queue
- [x] `cmd/divad/main.go` — wires everything: config, DB, llama health, TUI, scheduler, Matrix, API
- [x] Conversation persistence to SQLite (`conversations`, `messages` tables with indexes)
- [x] Bubble Tea TUI with viewport + input bar, Ctrl+C/Ctrl+Q quit
- [x] HTTP API (std lib `net/http`): `GET /health`, `POST /v1/chat`, `POST /v1/conversations`
- [ ] Prompt template is raw headers — model outputs self-correction/next-action meta-text instead of just the response
- [ ] No token tracking — context window grows unbounded
- [ ] TUI doesn't stream tokens (waits for full completion)
**Schema (`internal/storage/migrations.go`):**
```sql
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')),
content TEXT NOT NULL,
tool_call TEXT,
token_count INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE TABLE context_state (
conversation_id TEXT PRIMARY KEY REFERENCES conversations(id),
token_count INTEGER NOT NULL DEFAULT 0,
summary TEXT
);
```
---
## Phase 2 — Prompt Templating & Reasoning-Aware TUI
**Goal:** Fix model output with proper chat templates. Parse reasoning/thoughts from the response and render them beautifully in the TUI — dimmed, collapsible, like OpenCode.
### Chat Template Engine
- [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|>`
- 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
- [x] `internal/agent/reasoning.go` — parse `<think>`, `<reasoning>`, `<reason>`, `<thinking>` 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
- [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)
### Command Palette & Themes
- [ ] `internal/channel/palette.go` — fuzzy-findable command palette overlay (Ctrl+P):
- Modal popup with search/filter input
- Renders a filtered list of commands with descriptions
- Enter to execute, Esc to dismiss
- [ ] `/help` — show available commands with descriptions
- [ ] `/new` — start a new conversation (alias: `/clear`)
- [ ] `/exit` — quit the TUI (alias: `/quit`, `/q`)
- [ ] `/thinking` — toggle visibility of reasoning/thinking blocks in the conversation
- [ ] `/themes` — list and switch between color themes
- [ ] `/sessions` — list and switch between conversations (alias: `/resume`)
- [ ] `internal/channel/theme.go` — theme engine:
- JSON-based theme config with `defs` + `theme` color map
- Built-in themes: `default`, `tokyonight`, `catppuccin`, `gruvbox`, `nord`, `matrix`
- Uses `THEME` env var or `/themes` command to switch
- Hues: `primary`, `secondary`, `accent`, `text`, `textMuted`, `background`, `border`, `success`, `error`
- [ ] `internal/channel/sessions.go` — session management:
- List existing conversations from SQLite
- Switch agent's active conversation
- Show per-session message count and last activity
---
## Phase 3 — Long-Term Memory (RAG)
**Goal:** Embedding-based retrieval so the agent recalls facts across sessions.
- [ ] `internal/memory/embed.go` — call llama.cpp `POST /embedding` to generate vectors
- [ ] SQLite vector storage: store embeddings as BLOB in `memories` table
- [ ] `internal/memory/store.go` — upgrade `Search()` from keyword `LIKE` to cosine similarity scan
- [ ] Temporal decay — older memories scored lower (weight = importance \* recency_factor)
- [ ] Importance scoring — user flags ("remember this"), emotional content, repeat mentions boost score
```sql
CREATE TABLE memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
embedding BLOB, -- float32 vector
source TEXT, -- 'conversation', 'reflection', 'user_input'
importance REAL DEFAULT 0.5, -- 0.01.0
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT
);
CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
```
- [ ] **Future:** Migrate to PostgreSQL + pgvector for production-scale vector search
- [ ] **Test:** Store info, restart agent, ask about stored info → verify recall
---
## Phase 4 — OpenAI-Compatible Adapter
**Goal:** Swap inference to any OpenAI-compatible API (OpenAI, vLLM, Ollama, Groq, etc.).
- [ ] `internal/llama/provider.go` — interface:
```go
type Provider interface {
Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error)
Embed(ctx context.Context, content string) ([]float64, error)
Health() error
}
```
- [ ] Rename `internal/llama/``internal/inference/`
- [ ] `llama.go` — implements Provider (existing llama.cpp client wrapped)
- [ ] `openai.go` — implements Provider via OpenAI-compatible REST API (`/v1/chat/completions`, `/v1/embeddings`)
- [ ] Config: `INFERENCE_PROVIDER=llama|openai`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`
- [ ] Both providers produce same interface, switchable via env var
- [ ] **Test:** Same conversation, swap provider, verify behavior is consistent
---
## Phase 5 — Episodic Diary & Reflection
**Goal:** Daily diary entries + periodic self-reflection for narrative continuity. The diary is the emotional continuity layer.
**Status: CODE DONE** — needs LLM integration for real summaries.
- [x] `internal/companion/reflection.go``GenerateDiary`, `RecentObservations`, `AddObservation`
- [x] `diary_entries` table:
```sql
CREATE TABLE diary_entries (
id TEXT PRIMARY KEY,
date TEXT NOT NULL, -- YYYY-MM-DD
summary TEXT NOT NULL,
mood TEXT,
topics TEXT, -- JSON array
observations TEXT, -- JSON array
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- [x] `observations` table:
```sql
CREATE TABLE observations (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
confidence REAL DEFAULT 0.5,
category TEXT, -- 'topic_interest', 'interaction_style', 'mood_pattern'
applied INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- [ ] Connect scheduler's nightly diary trigger to LLM-generated summaries (currently writes stub text)
- [ ] Reflection → Personality update pipeline:
- Diary entries accumulate → periodic analysis → trait drift candidates
- Approval process: observations → candidates → bounded update
- Max 0.5% trait change/day, max 5%/month — slow evolution feels human, fast feels schizophrenic
- [ ] **Test:** Simulate conversations across multiple "days", verify diary entries, verify trait drift remains within bounds
---
## Phase 6 — MCP Tools & Integrations
**Goal:** Extensible tool system for web search, file access, external services.
**Status: PARTIAL**
- [x] `internal/tools/registry.go` — register/list/execute tools with parameter definitions
- [x] `internal/tools/builtin.go``web_fetch` (working), `web_search` (stub)
- [ ] MCP protocol client (`internal/tools/mcp.go`):
- Connect to MCP servers via stdio or TCP
- List available tools with parameters
- Execute tool calls, parse results into context
- [ ] Tool call loop in agent:
- LLM decides to call tool → agent executes → result appended to context → LLM generates final response
- [ ] MCP-managed integrations:
- Obsidian vault (via `enquire-mcp` or custom)
- Gitea/Forgejo operations
- Actual Budget (spending tracking, categorization)
- [ ] **Test:** `web_fetch` tool, verify agent can search, summarize, cite sources
---
## Phase 7 — Matrix Integration
**Goal:** Agent lives as a Matrix bot, accessible via DM. Matrix feels like `@diva:homeserver.tld` instead of `Bot #4321`.
**Status: STUB**
- [x] `internal/channel/matrix.go` — config + start stub (disabled by default)
- [ ] Full Matrix bot with `mautrix-go`:
- Listen for invitations, join rooms
- Handle DMs with typing indicators, read receipts
- Message parsing & sending
- Bot identity: `@diva:homeserver.tld`
- [ ] Config: homeserver URL, bot user token in env
- [ ] TUI and Matrix run concurrently — messages sync between both
- [ ] **Test:** Send DM to bot, verify response, verify cross-session memory
---
## Phase 8 — Self-Memory & Identity
**Goal:** Agent remembers its own state — promises, strategies, mistakes. "Bidirectional memory."
**Status: CODE DONE**
- [x] `internal/memory/self_memory.go`:
```go
type Promise struct {
ID string
Promise string
Context string
Fulfilled bool
CreatedAt time.Time
Deadline *time.Time
}
type Strategy struct {
ID string
Situation string
Strategy string
Outcome string
CreatedAt time.Time
}
```
- [x] `agent_promises` table (id, promise, context, fulfilled, created_at, deadline)
- [x] `agent_strategies` table (id, situation, strategy, outcome, created_at)
- [ ] Wire into Prompt Composer: recent promises, relevant strategies added to context
- [ ] **Test:** "What did you promise me yesterday?" → verify recall from self-memory
---
## Phase 9 — Safety Guardrails & Identity Stability
**Goal:** Prevent personality drift, maintain core safety rules.
- [ ] `internal/companion/safety.go`:
- Core Identity (never changes): values, communication style, boundaries, role definition
- State (dynamic): mood, interests, current projects, relationship opinions
- Separation: identity config (env/read-only) vs state (SQLite/writable)
- [ ] Personality safety rules:
- Traits bounded within configured min/max at all times
- Core safety rules override any personality state
- Permission policies cannot be modified by personality
- No tool access changes via personality evolution
- [ ] Prompt injection protection:
- User messages wrapped in separator tokens
- System prompt templates with strict, non-overridable sections
- Input sanitization at the agent boundary
- [ ] **Test:** Attempt prompt injection, verify guardrails hold
---
## Phase 10 — Polish: Streaming TUI, Token Tracking, Observability
**Goal:** Production-quality UX and operations.
- [ ] Token streaming in TUI — char-by-char from llama.cpp SSE or chunked response
- [ ] Context window manager:
- Track total token count per conversation
- Sliding window when context exceeds limit
- Auto-summarize oldest messages to stay within context
- [ ] `POST /v1/chat` streaming via SSE in API mode
- [ ] Structured JSON logging via `log/slog` (already done — custom handler with `[LEVEL] [TIME] [FILE:LINE]`)
- [ ] Prometheus metrics (`GET /metrics`)
- [ ] Graceful shutdown — drain in-flight requests, save agent state
- [ ] **Test:** Full restart cycle, verify all state restored
---
## Phase 11 — Future Enhancements (Optional)
- [ ] Voice interface: whisper.cpp (STT) + piper/XTTS (TTS)
- [ ] VRM/Live2D avatar rendering (like Open-LLM-VTuber, Soul of Waifu)
- [ ] Multi-user support with per-user relationship tracking
- [ ] Multi-room Matrix support
- [ ] Plugin system for community-contributed tools
- [ ] WebUI dashboard (like nanobot's WebUI)
- [ ] Mobile companion via Matrix (any Matrix client works)
- [ ] Home Assistant integration for physical automation
- [ ] PostgreSQL + pgvector migration for production-scale vector search
---
## Database Summary
Two separate SQLite databases (clean separation of concerns):
| Database | Purpose | Tables |
| -------------- | --------------- | ------------------------------------------------------- |
| `divacode.db` | Core agent data | `conversations`, `messages`, `context_state` |
| `divacode.db` | Memory | `memories` |
| `companion.db` | Personality | `personality_traits` |
| `companion.db` | Relationship | `relationship_metrics`, `shared_topics`, `inside_jokes` |
| `companion.db` | Reflection | `diary_entries`, `observations` |
| `companion.db` | Self-memory | `agent_promises`, `agent_strategies` |
**Future:** `divacode.db` may be replaced by PostgreSQL + pgvector for production deployments with many users or large memory stores.
---
## Key Principles
1. **Build the smallest loop first** — get message → memory → LLM → response → diary → reflection working before adding complexity
2. **Personality is data, not prompt text** — traits stored in SQLite, composed dynamically by the prompt builder
3. **Memory ≠ Personality** — factual memory (Engram-style RAG) is separate from relationship/emotional state
4. **Slow evolution** — max 0.5% trait change/day, max 5%/month. Fast evolution feels schizophrenic, slow feels human
5. **Core Identity is stable** — identity.yaml (or env config) never changes much; state.json (SQLite) changes constantly
6. **Diary before reflection** — the nightly diary is the emotional continuity layer, more important than vector search
7. **Autonomous messaging** — the scheduler is what makes the character feel alive, not the LLM intelligence
8. **No secrets in code** — all config via env vars, `.env` in `.gitignore`
9. **No config libraries** — std lib `os.Getenv` only, never `caarlos0/env` or similar
10. **No HTTP routers** — std lib `net/http` only, no third-party routers (chi, gorilla, etc.)
11. **Error handling** — wrap errors with `fmt.Errorf("context: %w", err)`, never silent discards
12. **Test as you go**`go test -race ./...` before every push
13. **Output readable code** — explain _why_, never _what_. No comments for self-documenting code