memoryweave/go/internal/storage/sqlite.go

739 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 织忆 MemoryWeave — SQLite 持久存储后端CGO实现 LanceDB 接口)
package storage
/*
#cgo LDFLAGS: -lsqlite3
#include <sqlite3.h>
#include <stdlib.h>
*/
import "C"
import (
"bytes"
"encoding/binary"
"fmt"
"sort"
"sync"
"time"
"unsafe"
"github.com/xiaoxue/memoryweave/internal/models"
)
// SQLiteClient CGO 直连 SQLite3实现 LanceDB 接口。
// 生产环境持久化存储,替代 MemLanceClient。
type SQLiteClient struct {
mu sync.RWMutex
db *C.sqlite3
path string
}
func NewSQLiteClient(dbPath string) (*SQLiteClient, error) {
if dbPath == "" {
dbPath = "/var/lib/zhiyi/data/memoryweave.db"
}
cPath := C.CString(dbPath)
defer C.free(unsafe.Pointer(cPath))
var db *C.sqlite3
rc := C.sqlite3_open(cPath, &db)
if rc != C.SQLITE_OK {
errMsg := C.GoString(C.sqlite3_errmsg(db))
C.sqlite3_close(db)
return nil, fmt.Errorf("sqlite open: %s", errMsg)
}
// 性能优化
C.sqlite3_exec(db, C.CString("PRAGMA journal_mode=WAL"), nil, nil, nil)
C.sqlite3_exec(db, C.CString("PRAGMA synchronous=NORMAL"), nil, nil, nil)
C.sqlite3_exec(db, C.CString("PRAGMA busy_timeout=5000"), nil, nil, nil)
C.sqlite3_exec(db, C.CString("PRAGMA cache_size=-20000"), nil, nil, nil)
sc := &SQLiteClient{db: db, path: dbPath}
if err := sc.migrate(); err != nil {
C.sqlite3_close(db)
return nil, err
}
return sc, nil
}
func (sc *SQLiteClient) Close() error {
sc.mu.Lock()
defer sc.mu.Unlock()
if sc.db != nil {
C.sqlite3_close(sc.db)
sc.db = nil
}
return nil
}
// ─── migration ────────────────────────────────────────
func (sc *SQLiteClient) migrate() error {
sqls := []string{
`CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
namespace TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
vector BLOB,
tier TEXT NOT NULL DEFAULT 'normal',
importance REAL DEFAULT 1.0,
quality_score REAL DEFAULT 0.0,
recall_count INTEGER DEFAULT 0,
useful_count INTEGER DEFAULT 0,
not_useful_count INTEGER DEFAULT 0,
freshness TEXT DEFAULT 'fresh',
version INTEGER DEFAULT 1,
version_history TEXT DEFAULT '[]',
source TEXT DEFAULT '',
volatile_flag INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
depends_on TEXT DEFAULT '[]',
derived_from TEXT DEFAULT '',
last_recalled_at TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS episodes (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
namespace TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
distilled_to TEXT DEFAULT '',
distill_status TEXT DEFAULT 'pending',
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS tombstones (
id TEXT PRIMARY KEY,
original_id TEXT NOT NULL,
content_snapshot TEXT DEFAULT '',
namespace TEXT DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
merged_into TEXT DEFAULT '',
deleted_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS version_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_id TEXT NOT NULL,
version INTEGER NOT NULL,
content TEXT NOT NULL,
source TEXT DEFAULT '',
reason TEXT DEFAULT '',
timestamp TEXT NOT NULL,
FOREIGN KEY (memory_id) REFERENCES memories(id)
)`,
`CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace)`,
`CREATE INDEX IF NOT EXISTS idx_memories_deleted ON memories(is_deleted)`,
`CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_version_history_mem ON version_history(memory_id)`,
}
for _, s := range sqls {
if err := sc.exec(s); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}
// ─── CGO helpers ──────────────────────────────────────
func (sc *SQLiteClient) exec(sql string) error {
cSQL := C.CString(sql)
defer C.free(unsafe.Pointer(cSQL))
var errMsg *C.char
rc := C.sqlite3_exec(sc.db, cSQL, nil, nil, &errMsg)
if rc != C.SQLITE_OK {
msg := C.GoString(errMsg)
C.sqlite3_free(unsafe.Pointer(errMsg))
return fmt.Errorf("sqlite: %s", msg)
}
return nil
}
func (sc *SQLiteClient) execWithArgs(sql string, args ...interface{}) error {
cSQL := C.CString(sql)
defer C.free(unsafe.Pointer(cSQL))
var stmt *C.sqlite3_stmt
rc := C.sqlite3_prepare_v2(sc.db, cSQL, C.int(len(sql)), &stmt, nil)
if rc != C.SQLITE_OK {
return fmt.Errorf("sqlite prepare: %s", C.GoString(C.sqlite3_errmsg(sc.db)))
}
defer C.sqlite3_finalize(stmt)
for i, arg := range args {
idx := C.int(i + 1)
switch v := arg.(type) {
case nil:
C.sqlite3_bind_null(stmt, idx)
case int:
C.sqlite3_bind_int64(stmt, idx, C.sqlite3_int64(v))
case int64:
C.sqlite3_bind_int64(stmt, idx, C.sqlite3_int64(v))
case float64:
C.sqlite3_bind_double(stmt, idx, C.double(v))
case bool:
if v {
C.sqlite3_bind_int(stmt, idx, 1)
} else {
C.sqlite3_bind_int(stmt, idx, 0)
}
case string:
cStr := C.CString(v)
C.sqlite3_bind_text(stmt, idx, cStr, C.int(len(v)), (*[0]byte)(C.free))
case []byte:
if len(v) == 0 {
C.sqlite3_bind_null(stmt, idx)
} else {
C.sqlite3_bind_blob(stmt, idx, unsafe.Pointer(&v[0]), C.int(len(v)), nil)
}
default:
return fmt.Errorf("unsupported arg type %T", arg)
}
}
rc = C.sqlite3_step(stmt)
if rc != C.SQLITE_DONE && rc != C.SQLITE_ROW {
return fmt.Errorf("sqlite step: %s", C.GoString(C.sqlite3_errmsg(sc.db)))
}
return nil
}
type sqliteRow map[string]interface{}
func (sc *SQLiteClient) query(sql string) ([]sqliteRow, error) {
cSQL := C.CString(sql)
defer C.free(unsafe.Pointer(cSQL))
var stmt *C.sqlite3_stmt
rc := C.sqlite3_prepare_v2(sc.db, cSQL, C.int(len(sql)), &stmt, nil)
if rc != C.SQLITE_OK {
return nil, fmt.Errorf("sqlite prepare: %s", C.GoString(C.sqlite3_errmsg(sc.db)))
}
defer C.sqlite3_finalize(stmt)
var rows []sqliteRow
for {
rc = C.sqlite3_step(stmt)
if rc == C.SQLITE_DONE {
break
}
if rc != C.SQLITE_ROW {
return nil, fmt.Errorf("sqlite step: %s", C.GoString(C.sqlite3_errmsg(sc.db)))
}
colCount := int(C.sqlite3_column_count(stmt))
row := make(sqliteRow, colCount)
for i := 0; i < colCount; i++ {
name := C.GoString(C.sqlite3_column_name(stmt, C.int(i)))
colType := C.sqlite3_column_type(stmt, C.int(i))
switch colType {
case C.SQLITE_INTEGER:
row[name] = int64(C.sqlite3_column_int64(stmt, C.int(i)))
case C.SQLITE_FLOAT:
row[name] = float64(C.sqlite3_column_double(stmt, C.int(i)))
case C.SQLITE_TEXT:
row[name] = C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, C.int(i)))))
case C.SQLITE_BLOB:
n := int(C.sqlite3_column_bytes(stmt, C.int(i)))
ptr := C.sqlite3_column_blob(stmt, C.int(i))
blob := make([]byte, n)
if n > 0 {
copy(blob, (*[1 << 30]byte)(ptr)[:n])
}
row[name] = blob
case C.SQLITE_NULL:
row[name] = nil
}
}
rows = append(rows, row)
}
return rows, nil
}
// ─── vector helpers ───────────────────────────────────
func vectorToBlob(v []float32) []byte {
buf := new(bytes.Buffer)
for _, f := range v {
binary.Write(buf, binary.LittleEndian, f)
}
return buf.Bytes()
}
func blobToVector(b []byte) []float32 {
if len(b) == 0 {
return nil
}
vec := make([]float32, len(b)/4)
buf := bytes.NewReader(b)
for i := range vec {
binary.Read(buf, binary.LittleEndian, &vec[i])
}
return vec
}
// ─── LanceDB 接口 ─────────────────────────────────────
func (sc *SQLiteClient) InsertEpisode(agentID, namespace, content, category string) (string, error) {
id := fmt.Sprintf("ep_%d", time.Now().UnixNano())
now := time.Now().Format(time.RFC3339)
sc.mu.Lock()
defer sc.mu.Unlock()
return id, sc.execWithArgs(
"INSERT INTO episodes(id,agent_id,namespace,content,category,created_at) VALUES(?,?,?,?,?,?)",
id, agentID, namespace, content, category, now,
)
}
func (sc *SQLiteClient) InsertMemory(m models.MemoryRecord) error {
now := time.Now().Format(time.RFC3339)
vecBlob := vectorToBlob(m.Vector)
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.execWithArgs(
`INSERT INTO memories(id,agent_id,namespace,content,category,vector,tier,importance,quality_score,
source,depends_on,derived_from,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`,
m.ID, m.AgentID, m.Namespace, m.Content, m.Category, vecBlob, m.Tier,
1.0, m.QualityScore, "", "[]", "", now, now,
)
}
func (sc *SQLiteClient) Insert(table string, record any) error {
sc.mu.Lock()
defer sc.mu.Unlock()
now := time.Now().Format(time.RFC3339)
switch r := record.(type) {
case models.MemoryRecord:
return sc.InsertMemory(r)
case models.EpisodeRecord:
return sc.execWithArgs(
"INSERT INTO episodes(id,agent_id,namespace,content,category,distilled_to,distill_status,created_at) VALUES(?,?,?,?,?,?,?,?)",
r.ID, r.AgentID, r.Namespace, r.Content, r.Category, "", "pending", now,
)
case models.TombstoneRecord:
delTime := r.CreatedAt.Format(time.RFC3339)
return sc.execWithArgs(
"INSERT INTO tombstones(id,original_id,content_snapshot,namespace,reason,merged_into,deleted_at) VALUES(?,?,?,?,?,?,?)",
r.ID, r.OriginalID, "", "", r.Reason, "", delTime,
)
default:
return fmt.Errorf("sqlite: unsupported record type %T", record)
}
}
func (sc *SQLiteClient) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
// 加载所有未被删除的记录 + 向量 (按 importance 加权排序)
query := "SELECT id, content, category, namespace, vector, importance, created_at FROM memories WHERE is_deleted=0"
if namespaceFilter != "" {
query += " AND namespace='" + sanitizeIdent(namespaceFilter) + "'"
}
rows, err := sc.query(query)
if err != nil {
return nil, err
}
type candidate struct {
rec models.MemoryRecord
score float64
}
var candidates []candidate
for _, row := range rows {
vecBlob, _ := row["vector"].([]byte)
candVec := blobToVector(vecBlob)
sim := cosineSim(vector, candVec)
importance, _ := row["importance"].(float64)
if importance <= 0 { importance = 1.0 }
createdAt, _ := time.Parse(time.RFC3339, strVal(row, "created_at"))
candidates = append(candidates, candidate{
rec: models.MemoryRecord{
ID: strVal(row, "id"),
Content: strVal(row, "content"),
Category: strVal(row, "category"),
Namespace: strVal(row, "namespace"),
CreatedAt: createdAt,
},
score: sim * importance, // importance 加权
})
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
if topK > len(candidates) {
topK = len(candidates)
}
results := make([]models.MemoryRecord, topK)
for i := 0; i < topK; i++ {
results[i] = candidates[i].rec
}
return results, nil
}
func (sc *SQLiteClient) GetTopByQuality(agentID string, limit int) ([]models.MemoryRecord, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
rows, err := sc.query(
"SELECT id,content,category,namespace,created_at FROM memories WHERE is_deleted=0 ORDER BY quality_score DESC LIMIT " + itoa(limit),
)
if err != nil {
return nil, err
}
return sc.rowsToMemories(rows), nil
}
func (sc *SQLiteClient) Stats() (map[string]interface{}, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
var memCount, epCount, tombCount int64
rows, _ := sc.query("SELECT COUNT(*) as cnt FROM memories WHERE is_deleted=0")
if len(rows) > 0 {
memCount, _ = rows[0]["cnt"].(int64)
}
rows, _ = sc.query("SELECT COUNT(*) as cnt FROM episodes")
if len(rows) > 0 {
epCount, _ = rows[0]["cnt"].(int64)
}
rows, _ = sc.query("SELECT COUNT(*) as cnt FROM tombstones")
if len(rows) > 0 {
tombCount, _ = rows[0]["cnt"].(int64)
}
return map[string]interface{}{
"memory_count": memCount,
"episode_count": epCount,
"tombstone_count": tombCount,
"backend": "sqlite3 (CGO)",
"db_path": sc.path,
}, nil
}
func (sc *SQLiteClient) SoftDelete(id, reason string) error {
sc.mu.Lock()
defer sc.mu.Unlock()
now := time.Now().Format(time.RFC3339)
if err := sc.execWithArgs(
"UPDATE memories SET is_deleted=1, updated_at=? WHERE id=?",
now, id,
); err != nil {
return err
}
tombID := fmt.Sprintf("tomb_%d", time.Now().UnixNano())
return sc.execWithArgs(
"INSERT INTO tombstones(id,original_id,reason,deleted_at) VALUES(?,?,?,?)",
tombID, id, reason, now,
)
}
func (sc *SQLiteClient) GetVersionHistory(id string) ([]map[string]interface{}, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
rows, err := sc.query(
"SELECT version,content,source,reason,timestamp FROM version_history WHERE memory_id=? ORDER BY version DESC",
)
if err != nil {
return nil, err
}
// 用笨办法实现参数化查询避免重写 query 函数
if len(rows) == 0 {
return nil, nil
}
// 直接重新查询带参数
cSQL := C.CString("SELECT version,content,source,reason,timestamp FROM version_history WHERE memory_id=? ORDER BY version DESC")
defer C.free(unsafe.Pointer(cSQL))
var stmt *C.sqlite3_stmt
C.sqlite3_prepare_v2(sc.db, cSQL, -1, &stmt, nil)
defer C.sqlite3_finalize(stmt)
cID := C.CString(id)
defer C.free(unsafe.Pointer(cID))
C.sqlite3_bind_text(stmt, 1, cID, C.int(len(id)), (*[0]byte)(C.free))
var result []map[string]interface{}
for C.sqlite3_step(stmt) == C.SQLITE_ROW {
result = append(result, map[string]interface{}{
"version": int64(C.sqlite3_column_int64(stmt, 0)),
"content": C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 1)))),
"source": C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 2)))),
"reason": C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 3)))),
"timestamp": C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 4)))),
})
}
return result, nil
}
func (sc *SQLiteClient) GetCandidatesForForgetting() ([]map[string]interface{}, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
rows, err := sc.query(
"SELECT id, content, namespace, category, last_recalled_at, recall_count, tier, importance, created_at FROM memories WHERE is_deleted=0 AND tier!='core' ORDER BY last_recalled_at ASC LIMIT 200",
)
if err != nil {
return nil, err
}
var results []map[string]interface{}
for _, r := range rows {
results = append(results, map[string]interface{}{
"id": r["id"],
"content": r["content"],
"namespace": r["namespace"],
"category": r["category"],
"last_recalled_at": r["last_recalled_at"],
"recall_count": r["recall_count"],
"tier": r["tier"],
"importance": r["importance"],
"created_at": r["created_at"],
})
}
return results, nil
}
// G7.2: GetSkillCandidates 返回适合结晶为 skill 的记忆候选
// 条件: recall_count >= minRecalls, quality_score >= 0.7, tier != 'core'
func (sc *SQLiteClient) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
sql := `SELECT id, agent_id, namespace, content, category,
recall_count, quality_score, importance, tier,
last_recalled_at, created_at, updated_at
FROM memories
WHERE is_deleted = 0 AND tier != 'core'
AND recall_count >= ? AND quality_score >= 0.7
ORDER BY recall_count DESC, quality_score DESC
LIMIT ?`
cSQL := C.CString(sql)
defer C.free(unsafe.Pointer(cSQL))
var stmt *C.sqlite3_stmt
rc := C.sqlite3_prepare_v2(sc.db, cSQL, C.int(len(sql)), &stmt, nil)
if rc != C.SQLITE_OK {
return nil, fmt.Errorf("prepare skill candidates: %s", C.GoString(C.sqlite3_errmsg(sc.db)))
}
defer C.sqlite3_finalize(stmt)
C.sqlite3_bind_int(stmt, 1, C.int(minRecalls))
C.sqlite3_bind_int(stmt, 2, C.int(limit))
var results []models.MemoryRecord
for {
rc = C.sqlite3_step(stmt)
if rc == C.SQLITE_DONE {
break
}
if rc != C.SQLITE_ROW {
break
}
rec := models.MemoryRecord{
ID: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 0)))),
AgentID: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 1)))),
Namespace: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 2)))),
Content: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 3)))),
Category: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 4)))),
RecallCount: int(C.sqlite3_column_int64(stmt, 5)),
QualityScore: float64(C.sqlite3_column_double(stmt, 6)),
Importance: float64(C.sqlite3_column_double(stmt, 7)),
Tier: C.GoString((*C.char)(unsafe.Pointer(C.sqlite3_column_text(stmt, 8)))),
}
if lastRecalled := C.sqlite3_column_text(stmt, 9); lastRecalled != nil {
rec.LastRecalledAt, _ = time.Parse(time.RFC3339, C.GoString((*C.char)(unsafe.Pointer(lastRecalled))))
}
if createdAt := C.sqlite3_column_text(stmt, 10); createdAt != nil {
rec.CreatedAt, _ = time.Parse(time.RFC3339, C.GoString((*C.char)(unsafe.Pointer(createdAt))))
}
if updatedAt := C.sqlite3_column_text(stmt, 11); updatedAt != nil {
rec.UpdatedAt, _ = time.Parse(time.RFC3339, C.GoString((*C.char)(unsafe.Pointer(updatedAt))))
}
results = append(results, rec)
}
return results, nil
}
func (sc *SQLiteClient) Backup(path string) error {
sc.mu.RLock()
defer sc.mu.RUnlock()
// SQLite 在线备份 API
var pBackup *C.sqlite3_backup
cDestPath := C.CString(path)
defer C.free(unsafe.Pointer(cDestPath))
var destDB *C.sqlite3
rc := C.sqlite3_open(cDestPath, &destDB)
if rc != C.SQLITE_OK {
return fmt.Errorf("backup open dest: %s", C.GoString(C.sqlite3_errmsg(destDB)))
}
defer C.sqlite3_close(destDB)
pBackup = C.sqlite3_backup_init(destDB, C.CString("main"), sc.db, C.CString("main"))
if pBackup == nil {
return fmt.Errorf("backup init: %s", C.GoString(C.sqlite3_errmsg(destDB)))
}
C.sqlite3_backup_step(pBackup, -1)
C.sqlite3_backup_finish(pBackup)
return nil
}
func (sc *SQLiteClient) GetAuditLog(limit int) ([]map[string]interface{}, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
rows, err := sc.query(
"SELECT id,original_id,reason,deleted_at FROM tombstones ORDER BY deleted_at DESC LIMIT " + itoa(limit),
)
if err != nil {
return nil, err
}
var results []map[string]interface{}
for _, r := range rows {
results = append(results, map[string]interface{}{
"id": r["id"],
"original_id": r["original_id"],
"reason": r["reason"],
"deleted_at": r["deleted_at"],
})
}
return results, nil
}
func (sc *SQLiteClient) IncrementUseful(id string) {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.execWithArgs(
"UPDATE memories SET useful_count=useful_count+1, recall_count=recall_count+1, updated_at=? WHERE id=?",
time.Now().Format(time.RFC3339), id,
)
}
func (sc *SQLiteClient) IncrementNotUseful(id string) {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.execWithArgs(
"UPDATE memories SET not_useful_count=not_useful_count+1, updated_at=? WHERE id=?",
time.Now().Format(time.RFC3339), id,
)
}
func (sc *SQLiteClient) UpdateMemoryContent(id, newContent, source string) error {
sc.mu.Lock()
defer sc.mu.Unlock()
now := time.Now().Format(time.RFC3339)
// 记录版本历史
rows, _ := sc.query("SELECT version FROM memories WHERE id='" + sanitizeIdent(id) + "'")
version := int64(1)
if len(rows) > 0 {
if v, ok := rows[0]["version"].(int64); ok {
version = v + 1
}
}
sc.execWithArgs(
"INSERT INTO version_history(memory_id,version,content,source,reason,timestamp) VALUES(?,?,?,?,?,?)",
id, version, newContent, source, "corrected", now,
)
return sc.execWithArgs(
"UPDATE memories SET content=?, source=?, version=?, updated_at=? WHERE id=?",
newContent, source, version, now, id,
)
}
func (sc *SQLiteClient) Update(table, id string, fields map[string]any) error {
sc.mu.Lock()
defer sc.mu.Unlock()
if table != "memories" {
return nil
}
now := time.Now().Format(time.RFC3339)
if _, ok := fields["recall_count"]; ok {
return sc.execWithArgs(
"UPDATE memories SET recall_count=recall_count+1, updated_at=? WHERE id=?",
now, id,
)
}
if _, ok := fields["useful_count"]; ok {
return sc.execWithArgs(
"UPDATE memories SET useful_count=useful_count+1, updated_at=? WHERE id=?",
now, id,
)
}
// generic update
for k, v := range fields {
if err := sc.execWithArgs(
"UPDATE memories SET "+sanitizeIdent(k)+"=?, updated_at=? WHERE id=?",
v, now, id,
); err != nil {
return err
}
}
return nil
}
// ─── helpers ──────────────────────────────────────────
func strVal(row sqliteRow, key string) string {
if v, ok := row[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (sc *SQLiteClient) rowsToMemories(rows []sqliteRow) []models.MemoryRecord {
results := make([]models.MemoryRecord, 0, len(rows))
for _, row := range rows {
createdAt, _ := time.Parse(time.RFC3339, strVal(row, "created_at"))
results = append(results, models.MemoryRecord{
ID: strVal(row, "id"),
Content: strVal(row, "content"),
Category: strVal(row, "category"),
Namespace: strVal(row, "namespace"),
CreatedAt: createdAt,
})
}
return results
}
func itoa(n int) string {
if n <= 0 {
return "10"
}
return fmt.Sprintf("%d", n)
}
func sanitizeIdent(s string) string {
// 简单防注入:仅允许字母、数字、下划线
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return "'invalid'"
}
}
return s
}