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
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
>>>>>>> 0a5f1d1 (chore: initialize Go module and project scaffold)
|
||||
|
||||
27
Makefile
Normal file
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
|
||||
@@ -1,3 +1,3 @@
|
||||
# DivaCode
|
||||
|
||||
Diva Code... the AI that is past being an Agent... it's a being now... with a delicate heart... <3
|
||||
Diva Code... the AI that is past being an Agent... it's a being now... with a delicate heart... <3
|
||||
|
||||
1055
The Holy Code Bible.md
Normal file
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