feat(memory): implement SQLite-backed session store

Add SQLiteStore using modernc.org/sqlite (pure Go, zero CGo) with:

- WAL journal mode for concurrent read/write on embedded devices
- Single-connection serialization to prevent write conflicts
- Transactional message insert with auto-incrementing sequence
- ToolCalls stored as JSON column (always read/written with message)
- Auto-creation of session rows on first message

Connection tuned for embedded use: 512KB cache, NORMAL synchronous
mode, 5s busy timeout, foreign keys enabled for cascade deletes.
This commit is contained in:
xiaoen 2026-02-24 18:19:11 +08:00
parent fe3a10dae7
commit ed44c8fdc7
3 changed files with 403 additions and 0 deletions

9
go.mod
View file

@ -18,12 +18,21 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
modernc.org/sqlite v1.46.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (

47
go.sum
View file

@ -30,6 +30,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
@ -62,6 +64,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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=
@ -71,6 +75,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@ -88,8 +94,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
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/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -105,6 +115,8 @@ github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbD
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@ -161,10 +173,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -208,6 +224,7 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
@ -233,6 +250,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -259,3 +278,31 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/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.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=

347
pkg/memory/sqlite.go Normal file
View file

@ -0,0 +1,347 @@
package memory
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
"github.com/sipeed/picoclaw/pkg/providers"
)
// SQLiteStore implements Store backed by a SQLite database.
type SQLiteStore struct {
db *sql.DB
}
// Open creates or opens a SQLite database at dbPath and returns a ready-to-use SQLiteStore.
func Open(ctx context.Context, dbPath string) (*SQLiteStore, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("memory: create directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("memory: open database: %w", err)
}
// Single connection — serializes all operations for safety.
db.SetMaxOpenConns(1)
// Apply PRAGMAs for embedded-friendly performance.
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA busy_timeout=5000",
"PRAGMA synchronous=NORMAL",
"PRAGMA foreign_keys=ON",
"PRAGMA cache_size=-512",
}
for _, p := range pragmas {
if _, err := db.ExecContext(ctx, p); err != nil {
db.Close()
return nil, fmt.Errorf("memory: pragma %q: %w", p, err)
}
}
s := &SQLiteStore{db: db}
if err := s.ensureSchema(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *SQLiteStore) ensureSchema(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS sessions (
key TEXT PRIMARY KEY,
summary TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT NOT NULL REFERENCES sessions(key) ON DELETE CASCADE,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls_json TEXT,
tool_call_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(session_key, seq)
);`
if _, err := s.db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("memory: create schema: %w", err)
}
return nil
}
// ensureSession inserts a session row if it doesn't already exist.
// Must be called inside a transaction.
func ensureSession(ctx context.Context, tx *sql.Tx, sessionKey string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO sessions (key, summary, created_at, updated_at) VALUES (?, '', ?, ?)`,
sessionKey, now, now,
)
return err
}
// touchSession updates the updated_at timestamp. Must be called inside a transaction.
func touchSession(ctx context.Context, tx *sql.Tx, sessionKey string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := tx.ExecContext(ctx,
`UPDATE sessions SET updated_at = ? WHERE key = ?`,
now, sessionKey,
)
return err
}
// nextSeq returns the next sequence number for a session. Must be called inside a transaction.
func nextSeq(ctx context.Context, tx *sql.Tx, sessionKey string) (int, error) {
var maxSeq sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT MAX(seq) FROM messages WHERE session_key = ?`,
sessionKey,
).Scan(&maxSeq)
if err != nil {
return 0, err
}
if maxSeq.Valid {
return int(maxSeq.Int64) + 1, nil
}
return 1, nil
}
func (s *SQLiteStore) AddMessage(ctx context.Context, sessionKey, role, content string) error {
return s.AddFullMessage(ctx, sessionKey, providers.Message{
Role: role,
Content: content,
})
}
func (s *SQLiteStore) AddFullMessage(ctx context.Context, sessionKey string, msg providers.Message) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: begin tx: %w", err)
}
defer tx.Rollback()
err = ensureSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: ensure session: %w", err)
}
seq, err := nextSeq(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: next seq: %w", err)
}
var toolCallsJSON *string
if len(msg.ToolCalls) > 0 {
var data []byte
data, err = json.Marshal(msg.ToolCalls)
if err != nil {
return fmt.Errorf("memory: marshal tool calls: %w", err)
}
str := string(data)
toolCallsJSON = &str
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = tx.ExecContext(ctx,
`INSERT INTO messages (session_key, seq, role, content, tool_calls_json, tool_call_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sessionKey, seq, msg.Role, msg.Content, toolCallsJSON, msg.ToolCallID, now,
)
if err != nil {
return fmt.Errorf("memory: insert message: %w", err)
}
err = touchSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: touch session: %w", err)
}
return tx.Commit()
}
func (s *SQLiteStore) GetHistory(ctx context.Context, sessionKey string) ([]providers.Message, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT role, content, tool_calls_json, tool_call_id
FROM messages
WHERE session_key = ?
ORDER BY seq ASC`,
sessionKey,
)
if err != nil {
return nil, fmt.Errorf("memory: query messages: %w", err)
}
defer rows.Close()
var messages []providers.Message
for rows.Next() {
var (
role string
content string
toolCallsJSON sql.NullString
toolCallID string
)
if err := rows.Scan(&role, &content, &toolCallsJSON, &toolCallID); err != nil {
return nil, fmt.Errorf("memory: scan message: %w", err)
}
msg := providers.Message{
Role: role,
Content: content,
ToolCallID: toolCallID,
}
if toolCallsJSON.Valid && toolCallsJSON.String != "" {
if err := json.Unmarshal([]byte(toolCallsJSON.String), &msg.ToolCalls); err != nil {
return nil, fmt.Errorf("memory: unmarshal tool calls: %w", err)
}
}
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: rows iteration: %w", err)
}
if messages == nil {
messages = []providers.Message{}
}
return messages, nil
}
func (s *SQLiteStore) GetSummary(ctx context.Context, sessionKey string) (string, error) {
var summary string
err := s.db.QueryRowContext(ctx,
`SELECT summary FROM sessions WHERE key = ?`,
sessionKey,
).Scan(&summary)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("memory: get summary: %w", err)
}
return summary, nil
}
func (s *SQLiteStore) SetSummary(ctx context.Context, sessionKey, summary string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: begin tx: %w", err)
}
defer tx.Rollback()
err = ensureSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: ensure session: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = tx.ExecContext(ctx,
`UPDATE sessions SET summary = ?, updated_at = ? WHERE key = ?`,
summary, now, sessionKey,
)
if err != nil {
return fmt.Errorf("memory: set summary: %w", err)
}
return tx.Commit()
}
func (s *SQLiteStore) TruncateHistory(ctx context.Context, sessionKey string, keepLast int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: begin tx: %w", err)
}
defer tx.Rollback()
if keepLast <= 0 {
_, err = tx.ExecContext(ctx,
`DELETE FROM messages WHERE session_key = ?`,
sessionKey,
)
} else {
_, err = tx.ExecContext(ctx,
`DELETE FROM messages WHERE session_key = ? AND id NOT IN (
SELECT id FROM messages WHERE session_key = ? ORDER BY seq DESC LIMIT ?
)`,
sessionKey, sessionKey, keepLast,
)
}
if err != nil {
return fmt.Errorf("memory: truncate history: %w", err)
}
err = touchSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: touch session: %w", err)
}
return tx.Commit()
}
func (s *SQLiteStore) SetHistory(ctx context.Context, sessionKey string, history []providers.Message) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: begin tx: %w", err)
}
defer tx.Rollback()
err = ensureSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: ensure session: %w", err)
}
// Delete all existing messages for this session.
_, err = tx.ExecContext(ctx,
`DELETE FROM messages WHERE session_key = ?`, sessionKey,
)
if err != nil {
return fmt.Errorf("memory: delete old messages: %w", err)
}
// Insert new messages with sequential seq numbers.
now := time.Now().UTC().Format(time.RFC3339)
for i, msg := range history {
var toolCallsJSON *string
if len(msg.ToolCalls) > 0 {
var data []byte
data, err = json.Marshal(msg.ToolCalls)
if err != nil {
return fmt.Errorf("memory: marshal tool calls: %w", err)
}
str := string(data)
toolCallsJSON = &str
}
_, err = tx.ExecContext(ctx,
`INSERT INTO messages (session_key, seq, role, content, tool_calls_json, tool_call_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sessionKey, i+1, msg.Role, msg.Content, toolCallsJSON, msg.ToolCallID, now,
)
if err != nil {
return fmt.Errorf("memory: insert message %d: %w", i, err)
}
}
err = touchSession(ctx, tx, sessionKey)
if err != nil {
return fmt.Errorf("memory: touch session: %w", err)
}
return tx.Commit()
}
func (s *SQLiteStore) Close() error {
return s.db.Close()
}