Compare commits
25 Commits
9e8289dc85
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
b09184e112
|
|||
|
3895ca68c0
|
|||
|
f57b42fd6e
|
|||
|
9903c7b12b
|
|||
|
cd37d01cae
|
|||
|
e570399b3e
|
|||
|
0244b8df35
|
|||
|
75724f2d7c
|
|||
|
b8b030ef06
|
|||
|
902b0c59a5
|
|||
|
3c98161423
|
|||
|
0f89f5f2d6
|
|||
|
4a8cc75793
|
|||
|
68db9deea8
|
|||
|
2d22523795
|
|||
|
1c1b7ffe7e
|
|||
|
7661027dbf
|
|||
|
e27a10e275
|
|||
|
147a51ecba
|
|||
|
8d99c1856b
|
|||
|
a2eae2ef52
|
|||
|
d29b194f73
|
|||
|
57cfe4516f
|
|||
|
ec4c3a91b3
|
|||
|
cdc699d00b
|
41
.env.example
Normal file
41
.env.example
Normal 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
25
.gitignore
vendored
Normal 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
60
AGENTS.md
Normal 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
12
Dockerfile
Normal 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"]
|
||||||
4
LICENSE
4
LICENSE
@@ -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
|
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
|
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.
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
|
||||||
|
>>>>>>> 0a5f1d1 (chore: initialize Go module and project scaffold)
|
||||||
|
|||||||
27
Makefile
Normal file
27
Makefile
Normal 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
|
||||||
1055
The Holy Code Bible.md
Normal file
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
151
cmd/divad/main.go
Normal 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
27
docker-compose.yml
Normal 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
40
go.mod
Normal 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
96
go.sum
Normal 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
161
internal/agent/agent.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/agent/reasoning.go
Normal file
41
internal/agent/reasoning.go
Normal 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)}
|
||||||
|
}
|
||||||
69
internal/agent/scheduler.go
Normal file
69
internal/agent/scheduler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/channel/matrix.go
Normal file
33
internal/channel/matrix.go
Normal 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
220
internal/channel/tui.go
Normal 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
internal/companion/engine.go
Normal file
123
internal/companion/engine.go
Normal 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
|
||||||
|
}
|
||||||
79
internal/companion/mood.go
Normal file
79
internal/companion/mood.go
Normal 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
|
||||||
|
}
|
||||||
107
internal/companion/personality.go
Normal file
107
internal/companion/personality.go
Normal 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
|
||||||
|
}
|
||||||
67
internal/companion/promptbuilder.go
Normal file
67
internal/companion/promptbuilder.go
Normal 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()}
|
||||||
|
}
|
||||||
106
internal/companion/reflection.go
Normal file
106
internal/companion/reflection.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
121
internal/companion/relationship.go
Normal file
121
internal/companion/relationship.go
Normal 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(¤t); 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
161
internal/config/config.go
Normal 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
104
internal/llama/client.go
Normal 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
|
||||||
|
}
|
||||||
71
internal/llama/template.go
Normal file
71
internal/llama/template.go
Normal 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
125
internal/log/handler.go
Normal 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)))
|
||||||
|
}
|
||||||
94
internal/memory/self_memory.go
Normal file
94
internal/memory/self_memory.go
Normal 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
71
internal/memory/store.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
117
internal/storage/migrations.go
Normal file
117
internal/storage/migrations.go
Normal 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;`
|
||||||
59
internal/storage/sqlite.go
Normal file
59
internal/storage/sqlite.go
Normal 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
48
internal/tools/builtin.go
Normal 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
|
||||||
|
}
|
||||||
62
internal/tools/registry.go
Normal file
62
internal/tools/registry.go
Normal 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
146
opencode.json
Normal 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
53
pkg/personality/traits.go
Normal 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
58
pkg/types/types.go
Normal 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
444
plan.md
Normal 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.0–1.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
|
||||||
Reference in New Issue
Block a user