orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "sync"
7 "time"
8)
9
10// Turn is one exchange. Follow-ups need the previous question and answer to be
11// rewritten into something a search engine can take.
12type Turn struct {
13 Question string
14 Answer string
15}
16
17// Sessions keeps conversations in memory and never on disk.
18//
19// This is the one place a question exists in readable form, and it is the
20// reason it stays in RAM: the database holds pages and HMACed search keys
21// precisely so that restarting the process is enough to forget what was asked.
22type Sessions struct {
23 mu sync.Mutex
24 data map[string]*conversation
25}
26
27type conversation struct {
28 turns []Turn
29 seen time.Time
30}
31
32const (
33 sessionTTL = 2 * time.Hour
34 maxTurnsKept = 4
35)
36
37func NewSessions() *Sessions {
38 s := &Sessions{data: map[string]*conversation{}}
39 go s.expire()
40 return s
41}
42
43func NewSessionID() string {
44 b := make([]byte, 16)
45 rand.Read(b)
46 return hex.EncodeToString(b)
47}
48
49func (s *Sessions) History(id string) []Turn {
50 s.mu.Lock()
51 defer s.mu.Unlock()
52 c, ok := s.data[id]
53 if !ok {
54 return nil
55 }
56 c.seen = time.Now()
57 return append([]Turn(nil), c.turns...)
58}
59
60func (s *Sessions) Append(id string, t Turn) {
61 s.mu.Lock()
62 defer s.mu.Unlock()
63 c, ok := s.data[id]
64 if !ok {
65 c = &conversation{}
66 s.data[id] = c
67 }
68 c.turns = append(c.turns, t)
69 if len(c.turns) > maxTurnsKept {
70 c.turns = c.turns[len(c.turns)-maxTurnsKept:]
71 }
72 c.seen = time.Now()
73}
74
75func (s *Sessions) Reset(id string) {
76 s.mu.Lock()
77 defer s.mu.Unlock()
78 delete(s.data, id)
79}
80
81func (s *Sessions) expire() {
82 for range time.Tick(10 * time.Minute) {
83 s.mu.Lock()
84 for id, c := range s.data {
85 if time.Since(c.seen) > sessionTTL {
86 delete(s.data, id)
87 }
88 }
89 s.mu.Unlock()
90 }
91}