picoclaw/pkg/agent/turn_store.go
mingzhi1 b29d13dcbb refactor: 3-phase runtime loop with CoT system and memory hierarchy
Architecture:
- Phase 1 (Analyse): lightweight pre-LLM for intent/tag extraction + CoT generation
- Phase 2 (Execute): LLM iteration loop with tool handling (from loop.go)
- Phase 3 (Reflect): post-LLM scoring, async processors, slash commands

Features:
- CoT template registry (8 built-in) with self-learning feedback loop
- SQLite-backed TurnStore for turn persistence
- Active Context: per-session file/error tracking
- Instant Memory: dynamic context window from TurnStore
- Memory Digest: batch background worker for long-term memory extraction
- Cross-platform shell commands for /shell
- Turn quality scoring for context prioritization

Design docs: runtime_loop_design.md, runtime_loop_task.md, runtime_builtin_cmd.md
2026-03-03 13:09:01 +08:00

300 lines
9 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
_ "modernc.org/sqlite"
)
// TurnRecord captures everything that happened during a single completed turn.
// It is persisted to turns.db for use by MemoryDigest and instant-memory assembly.
type TurnRecord struct {
ID string // ULID or time-based unique ID
Ts int64 // Unix timestamp (seconds)
ChannelKey string // "channel:chatID"
Score int // Phase 3 CalcTurnScore result
Intent string // Phase 1 detected intent
Tags []string // Phase 1 detected tags
Tokens int // rough token estimate (chars / 3)
Status string // "pending" | "processed" | "archived"
UserMsg string // original user message
Reply string // assistant final response
ToolCalls []ToolCallRecord // serialised as JSON in DB
}
// TurnStore manages persistent Turn storage in SQLite.
// The DB lives at {workspace}/turns.db, mirroring the memory.db pattern.
type TurnStore struct {
db *sql.DB
}
const turnsDDL = `
CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY,
ts INTEGER NOT NULL,
channel_key TEXT NOT NULL DEFAULT '',
score INTEGER NOT NULL DEFAULT 0,
intent TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
tokens INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
user_msg TEXT NOT NULL DEFAULT '',
reply TEXT NOT NULL DEFAULT '',
tool_calls TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_turns_status ON turns(status);
CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts);
CREATE INDEX IF NOT EXISTS idx_turns_channel ON turns(channel_key);
CREATE INDEX IF NOT EXISTS idx_turns_score ON turns(score);
`
// NewTurnStore creates (or opens) turns.db in the given workspace directory.
func NewTurnStore(workspace string) (*TurnStore, error) {
if err := os.MkdirAll(workspace, 0o755); err != nil {
return nil, fmt.Errorf("turn_store: mkdir %s: %w", workspace, err)
}
dbPath := filepath.Join(workspace, "turns.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("turn_store: open %s: %w", dbPath, err)
}
if _, err := db.Exec(turnsDDL); err != nil {
db.Close()
return nil, fmt.Errorf("turn_store: init schema: %w", err)
}
return &TurnStore{db: db}, nil
}
// Close shuts down the underlying DB connection.
func (s *TurnStore) Close() error {
if s.db != nil {
return s.db.Close()
}
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func marshalJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "[]"
}
return string(b)
}
func unmarshalTags(raw string) []string {
var tags []string
_ = json.Unmarshal([]byte(raw), &tags)
return tags
}
func unmarshalToolCalls(raw string) []ToolCallRecord {
var tcs []ToolCallRecord
_ = json.Unmarshal([]byte(raw), &tcs)
return tcs
}
// estimateTokens gives a cheap estimate: characters / 3.
func estimateTokens(r TurnRecord) int {
chars := len(r.UserMsg) + len(r.Reply)
for _, tc := range r.ToolCalls {
chars += len(tc.Name) + len(tc.Error)
}
if chars < 3 {
return 1
}
return chars / 3
}
// NewTurnID generates a time-sortable unique ID without external dependencies.
// Format: unixMilli-randomSuffix using millisecond precision.
func NewTurnID() string {
return fmt.Sprintf("%d-%d", time.Now().UnixMilli(), time.Now().Nanosecond()%1_000_000)
}
// ---------------------------------------------------------------------------
// Writes
// ---------------------------------------------------------------------------
// Insert persists a TurnRecord to the DB.
// The record's ID and Ts are set if empty/zero.
func (s *TurnStore) Insert(r TurnRecord) error {
if r.ID == "" {
r.ID = NewTurnID()
}
if r.Ts == 0 {
r.Ts = time.Now().Unix()
}
if r.Status == "" {
r.Status = "pending"
}
if r.Tokens == 0 {
r.Tokens = estimateTokens(r)
}
tagsJSON := marshalJSON(r.Tags)
tcJSON := marshalJSON(r.ToolCalls)
_, err := s.db.Exec(`
INSERT INTO turns (id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`,
r.ID, r.Ts, r.ChannelKey, r.Score, r.Intent,
tagsJSON, r.Tokens, r.Status, r.UserMsg, r.Reply, tcJSON,
)
if err != nil {
return fmt.Errorf("turn_store: insert %s: %w", r.ID, err)
}
logger.DebugCF("turn_store", "Turn inserted",
map[string]any{"id": r.ID, "score": r.Score, "tokens": r.Tokens, "status": r.Status})
return nil
}
// SetStatus updates the status of a turn by ID.
func (s *TurnStore) SetStatus(id, status string) error {
_, err := s.db.Exec("UPDATE turns SET status = ? WHERE id = ?", status, id)
return err
}
// ---------------------------------------------------------------------------
// Queries — used by MemoryDigest and instant-memory assembly
// ---------------------------------------------------------------------------
// QueryPending returns up to limit turns with status = 'pending', ordered oldest first.
func (s *TurnStore) QueryPending(limit int) ([]TurnRecord, error) {
rows, err := s.db.Query(`
SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
FROM turns WHERE status = 'pending'
ORDER BY ts ASC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTurns(rows)
}
// QueryByScore returns all turns with score >= highThreshold (always_keep),
// ordered by ts ASC.
func (s *TurnStore) QueryByScore(highThreshold int) ([]TurnRecord, error) {
rows, err := s.db.Query(`
SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
FROM turns WHERE score >= ? AND status != 'archived'
ORDER BY ts ASC`, highThreshold)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTurns(rows)
}
// QueryByTags returns turns whose tags JSON contains at least one of the given tags
// and score > 0, ordered by ts ASC.
func (s *TurnStore) QueryByTags(tags []string) ([]TurnRecord, error) {
if len(tags) == 0 {
return nil, nil
}
// Build LIKE conditions for simple JSON array matching.
conds := make([]string, 0, len(tags))
args := make([]any, 0, len(tags)*2)
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" {
continue
}
conds = append(conds, `(tags LIKE ? OR tags LIKE ?)`)
args = append(args, `%"`+t+`"%`, `%'`+t+`'%`)
}
if len(conds) == 0 {
return nil, nil
}
// Append non-archived filter.
query := fmt.Sprintf(`
SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
FROM turns
WHERE score > 0 AND status != 'archived' AND (%s)
ORDER BY ts ASC`, strings.Join(conds, " OR "))
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTurns(rows)
}
// QueryRecent returns the n most-recent non-archived turns for a channelKey,
// ordered by ts ASC (oldest first, so they can be appended naturally).
func (s *TurnStore) QueryRecent(channelKey string, n int) ([]TurnRecord, error) {
rows, err := s.db.Query(`
SELECT id, ts, channel_key, score, intent, tags, tokens, status, user_msg, reply, tool_calls
FROM turns
WHERE channel_key = ? AND status != 'archived'
ORDER BY ts DESC LIMIT ?`, channelKey, n)
if err != nil {
return nil, err
}
defer rows.Close()
turns, err := scanTurns(rows)
if err != nil {
return nil, err
}
// Reverse to ascending order.
for i, j := 0, len(turns)-1; i < j; i, j = i+1, j-1 {
turns[i], turns[j] = turns[j], turns[i]
}
return turns, nil
}
// ArchiveOldProcessed marks processed turns older than olderThanDays as 'archived'.
// At most 100 rows are archived per call to limit lock time.
func (s *TurnStore) ArchiveOldProcessed(olderThanDays int) error {
cutoff := time.Now().AddDate(0, 0, -olderThanDays).Unix()
_, err := s.db.Exec(`
UPDATE turns SET status = 'archived'
WHERE id IN (
SELECT id FROM turns
WHERE status = 'processed' AND ts < ?
ORDER BY ts ASC LIMIT 100
)`, cutoff)
return err
}
// ---------------------------------------------------------------------------
// Internal scanner
// ---------------------------------------------------------------------------
func scanTurns(rows *sql.Rows) ([]TurnRecord, error) {
var out []TurnRecord
for rows.Next() {
var r TurnRecord
var tagsJSON, tcJSON string
if err := rows.Scan(
&r.ID, &r.Ts, &r.ChannelKey, &r.Score, &r.Intent,
&tagsJSON, &r.Tokens, &r.Status,
&r.UserMsg, &r.Reply, &tcJSON,
); err != nil {
return out, err
}
r.Tags = unmarshalTags(tagsJSON)
r.ToolCalls = unmarshalToolCalls(tcJSON)
out = append(out, r)
}
return out, rows.Err()
}