feat(agent): integrate observation, DAG compression, and new tools

Wire the new memory subsystems into the agent loop:
- Observational memory monitors interactions passively
- DAG compressor manages context window budget
- Skill, retrieval, and focus tools registered at startup
- State manager supports KV-backed persistence via delegate
- Session manager gains observation hooks and DAG integration
- Cron service manages periodic observation reflection
- Remove legacy pkg/agent/memory.go (superseded by delegate)
This commit is contained in:
ZanzyTHEbar 2026-02-18 15:54:20 +00:00
parent 6da3c30866
commit 09678e50cf
9 changed files with 597 additions and 234 deletions

View file

@ -580,7 +580,11 @@ func gatewayCmd() {
// Setup cron tool and service // Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout) var cronOpts []cron.CronOption
if del := agentLoop.MemoryDelegate(); del != nil {
cronOpts = append(cronOpts, cron.WithCronDelegate(del, "picoclaw"))
}
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cronOpts...)
heartbeatService := heartbeat.NewHeartbeatService( heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
@ -1141,11 +1145,10 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json") return filepath.Join(home, ".picoclaw", "config.json")
} }
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService { func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cronOpts ...cron.CronOption) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json") cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service cronService := cron.NewCronService(cronStorePath, nil, cronOpts...)
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool // Create and register CronTool
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout) cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout)

View file

@ -19,9 +19,12 @@ import (
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore // Legacy file-based memory memoryStore memory.Memory // 3-tier MemGPT memory (may be nil)
memoryStore memory.Memory // New 3-tier MemGPT memory (may be nil) delegate memory.MemoryDelegate // Direct delegate for document loading (may be nil)
tools *tools.ToolRegistry // Direct reference to tool registry tools *tools.ToolRegistry // Direct reference to tool registry
observationBlock string // Pre-rendered observation block for prompt injection
knowledgeBlock string // Pre-rendered knowledge block from Focus completions
dagBlock string // Pre-rendered DAG compressed history
} }
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
@ -42,7 +45,6 @@ func NewContextBuilder(workspace string) *ContextBuilder {
return &ContextBuilder{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
} }
} }
@ -56,6 +58,29 @@ func (cb *ContextBuilder) SetMemoryStore(ms memory.Memory) {
cb.memoryStore = ms cb.memoryStore = ms
} }
// SetDelegate sets the memory delegate for document loading.
func (cb *ContextBuilder) SetDelegate(del memory.MemoryDelegate) {
cb.delegate = del
}
func (cb *ContextBuilder) SkillsLoader() *skills.SkillsLoader {
return cb.skillsLoader
}
func (cb *ContextBuilder) SetObservationBlock(block string) {
cb.observationBlock = block
}
// SetKnowledgeBlock sets the pre-rendered knowledge block from completed Focus sessions.
func (cb *ContextBuilder) SetKnowledgeBlock(block string) {
cb.knowledgeBlock = block
}
// SetDAGBlock sets the pre-rendered DAG compressed history for prompt injection.
func (cb *ContextBuilder) SetDAGBlock(block string) {
cb.dagBlock = block
}
func (cb *ContextBuilder) getIdentity() string { func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)") now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
@ -76,8 +101,6 @@ You are picoclaw, a helpful AI assistant.
## Workspace ## Workspace
Your workspace is at: %s Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md - Skills: %s/skills/{skill-name}/SKILL.md
%s %s
@ -88,8 +111,10 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, 3. **Memory** - Use the memory tool to store important facts, preferences, and decisions.
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
4. **Context Management** - You MUST consolidate your context to stay effective during long tasks. Use start_focus at the beginning of any investigation or multi-step task. After 10-15 tool calls, call complete_focus with a summary of what you learned and accomplished. This compresses your working context and persists knowledge for future reference. Failing to consolidate will degrade your performance as context grows.`,
now, runtime, workspacePath, workspacePath, toolsSection)
} }
func (cb *ContextBuilder) buildToolsSection() string { func (cb *ContextBuilder) buildToolsSection() string {
@ -136,10 +161,17 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
%s`, skillsSummary)) %s`, skillsSummary))
} }
// Legacy file-based memory context // Observation block (stable prefix for prompt cache alignment)
memoryContext := cb.memory.GetMemoryContext() if cb.observationBlock != "" {
if memoryContext != "" { parts = append(parts, "# Observations\n\n"+cb.observationBlock)
parts = append(parts, "# Memory\n\n"+memoryContext) }
if cb.knowledgeBlock != "" {
parts = append(parts, cb.knowledgeBlock)
}
if cb.dagBlock != "" {
parts = append(parts, "# Conversation History (Compressed)\n\n"+cb.dagBlock)
} }
// 3-tier MemGPT working context injection // 3-tier MemGPT working context injection
@ -155,6 +187,20 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
} }
func (cb *ContextBuilder) LoadBootstrapFiles() string { func (cb *ContextBuilder) LoadBootstrapFiles() string {
if cb.delegate != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
docs, err := cb.delegate.ListDocumentsByCategory(ctx, "picoclaw", "bootstrap")
if err == nil && len(docs) > 0 {
var result string
for _, doc := range docs {
result += fmt.Sprintf("## %s\n\n%s\n\n", doc.Name, doc.Content)
}
return result
}
}
bootstrapFiles := []string{ bootstrapFiles := []string{
"AGENTS.md", "AGENTS.md",
"SOUL.md", "SOUL.md",

View file

@ -23,9 +23,12 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy" picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/memory/dag"
"github.com/sipeed/picoclaw/pkg/memory/delegate" "github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/memory/observation"
memstore "github.com/sipeed/picoclaw/pkg/memory/store" memstore "github.com/sipeed/picoclaw/pkg/memory/store"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -46,6 +49,9 @@ type AgentLoop struct {
contextBuilder *ContextBuilder contextBuilder *ContextBuilder
tools *tools.ToolRegistry tools *tools.ToolRegistry
memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (nil if init failed) memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (nil if init failed)
memDelegate memory.MemoryDelegate // DB delegate (nil if memory disabled)
obsManager *observation.Manager // Observational memory (nil if memory disabled)
activeSessionKey atomic.Value // Current session key for tool access
running atomic.Bool running atomic.Bool
summarizing sync.Map // Tracks which sessions are currently being summarized summarizing sync.Map // Tracks which sessions are currently being summarized
summarizeFailures sync.Map // Tracks consecutive summarization failures per session (string -> int) summarizeFailures sync.Map // Tracks consecutive summarization failures per session (string -> int)
@ -138,17 +144,19 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
subagentTool := tools.NewSubagentTool(subagentManager) subagentTool := tools.NewSubagentTool(subagentManager)
toolsRegistry.Register(subagentTool) toolsRegistry.Register(subagentTool)
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
// Create state manager for atomic state persistence
stateManager := state.NewManager(workspace)
// Create context builder and set tools registry // Create context builder and set tools registry
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
contextBuilder.SetToolsRegistry(toolsRegistry) contextBuilder.SetToolsRegistry(toolsRegistry)
// Progressive skill disclosure tools (skill_search → skill_read → skill_traverse)
sl := contextBuilder.SkillsLoader()
toolsRegistry.Register(tools.NewSkillSearchTool(sl))
toolsRegistry.Register(tools.NewSkillReadTool(sl))
toolsRegistry.Register(tools.NewSkillTraverseTool(sl))
// Initialize 3-tier MemGPT memory system // Initialize 3-tier MemGPT memory system
var ms *memstore.MemoryStore var ms *memstore.MemoryStore
var memDelegate memory.MemoryDelegate
if cfg.Memory.Enabled { if cfg.Memory.Enabled {
memDBPath := filepath.Join(workspace, "memory", "picoclaw.db") memDBPath := filepath.Join(workspace, "memory", "picoclaw.db")
os.MkdirAll(filepath.Dir(memDBPath), 0755) os.MkdirAll(filepath.Dir(memDBPath), 0755)
@ -163,13 +171,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
map[string]interface{}{"error": err.Error()}) map[string]interface{}{"error": err.Error()})
del.Close() del.Close()
} else { } else {
memDelegate = del
offloadThreshold := cfg.Memory.OffloadThresholdTokens offloadThreshold := cfg.Memory.OffloadThresholdTokens
if offloadThreshold <= 0 { if offloadThreshold <= 0 {
offloadThreshold = 4000 offloadThreshold = 4000
} }
chunker := memstore.NewMarkdownChunker(memstore.DefaultMarkdownChunkerConfig()) chunker := memstore.NewMarkdownChunker(memstore.DefaultMarkdownChunkerConfig())
// Create embedding provider from config (nil = FTS5-only search)
embedder, embErr := memstore.NewEmbedderFromConfig(cfg.Memory.Embedding, cfg.Providers) embedder, embErr := memstore.NewEmbedderFromConfig(cfg.Memory.Embedding, cfg.Providers)
if embErr != nil { if embErr != nil {
logger.WarnCF("agent", "Failed to create embedding provider, archival search will use FTS5 only", logger.WarnCF("agent", "Failed to create embedding provider, archival search will use FTS5 only",
@ -185,10 +193,29 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
memTool := NewMemGPTTool(ms, "picoclaw", "default") memTool := NewMemGPTTool(ms, "picoclaw", "default")
toolsRegistry.Register(memTool) toolsRegistry.Register(memTool)
// One-time migration of file-based sessions into recall memory // Agentic retrieval tools (keyword_search → semantic_search → chunk_read)
sessionsDir := filepath.Join(workspace, "sessions") toolsRegistry.Register(tools.NewKeywordSearchTool(ms, "picoclaw"))
if _, migErr := memory.MigrateFileSessions(context.Background(), del, "picoclaw", sessionsDir); migErr != nil { toolsRegistry.Register(tools.NewSemanticSearchTool(ms, "picoclaw"))
logger.WarnCF("agent", "Session migration failed (non-fatal)", toolsRegistry.Register(tools.NewChunkReadTool(ms, "picoclaw"))
contextBuilder.SetDelegate(del)
// One-time migrations
mctx := context.Background()
if migErr := memory.MigrateState(mctx, workspace, del, "picoclaw"); migErr != nil {
logger.WarnCF("agent", "State KV migration failed (non-fatal)",
map[string]interface{}{"error": migErr.Error()})
}
if migErr := memory.MigrateDocuments(mctx, workspace, del, "picoclaw"); migErr != nil {
logger.WarnCF("agent", "Document migration failed (non-fatal)",
map[string]interface{}{"error": migErr.Error()})
}
if migErr := memory.MigrateLongTermMemory(mctx, workspace, del, "picoclaw"); migErr != nil {
logger.WarnCF("agent", "Long-term memory migration failed (non-fatal)",
map[string]interface{}{"error": migErr.Error()})
}
if migErr := memory.MigrateDailyNotes(mctx, workspace, del, "picoclaw"); migErr != nil {
logger.WarnCF("agent", "Daily notes migration failed (non-fatal)",
map[string]interface{}{"error": migErr.Error()}) map[string]interface{}{"error": migErr.Error()})
} }
} }
@ -197,6 +224,21 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
logger.InfoCF("agent", "Memory system disabled by config", nil) logger.InfoCF("agent", "Memory system disabled by config", nil)
} }
// Create state manager -- use delegate-backed KV when memory system is active
var stateOpts []state.Option
if memDelegate != nil {
stateOpts = append(stateOpts, state.WithDelegate(memDelegate))
}
stateManager := state.NewManager(workspace, stateOpts...)
// Create session manager -- use delegate for DB persistence when available
sessionsDir := filepath.Join(workspace, "sessions")
var sessionOpts []session.SessionOption
if memDelegate != nil {
sessionOpts = append(sessionOpts, session.WithSessionDelegate(memDelegate, "picoclaw"))
}
sessionsManager := session.NewSessionManager(sessionsDir, sessionOpts...)
// Register meta-tools for progressive disclosure (tool_search + tool_call) // Register meta-tools for progressive disclosure (tool_search + tool_call)
toolsRegistry.RegisterMetaTools() toolsRegistry.RegisterMetaTools()
@ -212,7 +254,28 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
map[string]interface{}{"gateway_tools": toolsRegistry.ListVisible()}) map[string]interface{}{"gateway_tools": toolsRegistry.ListVisible()})
} }
return &AgentLoop{ // Initialize observation manager if memory is enabled
var obsManager *observation.Manager
if memDelegate != nil {
callModelFn := func(ctx context.Context, prompt string) (string, error) {
temp := 0.3
maxTokens := int64(1024)
resp, err := model.Generate(ctx, fantasy.Call{
Prompt: fantasy.Prompt{
fantasy.NewUserMessage(prompt),
},
Temperature: &temp,
MaxOutputTokens: &maxTokens,
})
if err != nil {
return "", err
}
return resp.Content.Text(), nil
}
obsManager = observation.NewManager(memDelegate, "picoclaw", callModelFn, observation.DefaultManagerConfig())
}
al := &AgentLoop{
bus: msgBus, bus: msgBus,
languageModel: model, languageModel: model,
workspace: workspace, workspace: workspace,
@ -224,9 +287,26 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, model fantasy.Lang
contextBuilder: contextBuilder, contextBuilder: contextBuilder,
tools: toolsRegistry, tools: toolsRegistry,
memoryStore: ms, memoryStore: ms,
memDelegate: memDelegate,
obsManager: obsManager,
summarizing: sync.Map{}, summarizing: sync.Map{},
cfg: cfg, cfg: cfg,
} }
// Register focus tools (start_focus / complete_focus) when memory is available.
// The sessionKeyFn closure reads the activeSessionKey set at the start of each agent turn.
if memDelegate != nil {
sessionKeyFn := func() string {
if v := al.activeSessionKey.Load(); v != nil {
return v.(string)
}
return ""
}
toolsRegistry.Register(tools.NewStartFocusTool(memDelegate, sessionsManager, sessionKeyFn))
toolsRegistry.Register(tools.NewCompleteFocusTool(memDelegate, sessionsManager, sessionKeyFn))
}
return al
} }
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
@ -450,6 +530,8 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// It handles context building, Fantasy agent creation, tool execution, and response handling. // It handles context building, Fantasy agent creation, tool execution, and response handling.
// When opts.Streaming is true, delegates to runAgentLoopStreaming for real-time token delivery. // When opts.Streaming is true, delegates to runAgentLoopStreaming for real-time token delivery.
func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) { func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
al.activeSessionKey.Store(opts.SessionKey)
if opts.Streaming { if opts.Streaming {
return al.runAgentLoopStreaming(ctx, opts) return al.runAgentLoopStreaming(ctx, opts)
} }
@ -466,13 +548,29 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
// 1. Update tool contexts // 1. Update tool contexts
al.updateToolContexts(opts.Channel, opts.ChatID) al.updateToolContexts(opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat) // 2. Load observation block for system prompt injection
if al.obsManager != nil {
block := al.obsManager.LoadBlock(ctx, opts.SessionKey)
al.contextBuilder.SetObservationBlock(block)
}
// 2b. Load knowledge block from completed Focus sessions
if al.memDelegate != nil {
kb := tools.LoadKnowledgeBlock(ctx, al.memDelegate, opts.SessionKey)
al.contextBuilder.SetKnowledgeBlock(kb)
}
// 3. Build messages with DAG compression (skip history for heartbeat)
var history []messages.Message var history []messages.Message
var summary string var summary string
if !opts.NoHistory { if !opts.NoHistory {
history = al.sessions.GetHistory(opts.SessionKey) history = al.sessions.GetHistory(opts.SessionKey)
summary = al.sessions.GetSummary(opts.SessionKey) summary = al.sessions.GetSummary(opts.SessionKey)
} }
// 3a. DAG compression: compress old history, keep raw tail
history = al.applyDAGCompression(history)
builtMsgs := al.contextBuilder.BuildMessages( builtMsgs := al.contextBuilder.BuildMessages(
history, history,
summary, summary,
@ -482,7 +580,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
opts.ChatID, opts.ChatID,
) )
// 3. Save user message to session // 3b. Save user message to session
al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Split built messages into system prompt, conversation history, and current user prompt. // 4. Split built messages into system prompt, conversation history, and current user prompt.
@ -542,13 +640,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
return "", fmt.Errorf("agent Generate failed: %w", err) return "", fmt.Errorf("agent Generate failed: %w", err)
} }
// 9. Save all step messages to session // 9. Save all step messages to session and audit tool calls
stepCount := len(result.Steps) stepCount := len(result.Steps)
for _, step := range result.Steps { for _, step := range result.Steps {
stepMsgs := picofantasy.StepToMessages(step) stepMsgs := picofantasy.StepToMessages(step)
for _, m := range stepMsgs { for _, m := range stepMsgs {
al.sessions.AddFullMessage(opts.SessionKey, m) al.sessions.AddFullMessage(opts.SessionKey, m)
} }
al.auditStep(ctx, step, opts.SessionKey)
} }
// 10. Extract final text // 10. Extract final text
@ -567,6 +666,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID) al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID)
} }
// 13b. Trigger async observation if tail exceeds token threshold
if al.obsManager != nil {
tail := al.sessionsToMessagePairs(opts.SessionKey)
al.obsManager.MaybeObserveAsync(ctx, opts.SessionKey, tail)
}
// 14. Optional: send response via bus // 14. Optional: send response via bus
if opts.SendResponse { if opts.SendResponse {
al.bus.PublishOutbound(bus.OutboundMessage{ al.bus.PublishOutbound(bus.OutboundMessage{
@ -605,19 +710,35 @@ func (al *AgentLoop) runAgentLoopStreaming(ctx context.Context, opts processOpti
// 1. Update tool contexts // 1. Update tool contexts
al.updateToolContexts(opts.Channel, opts.ChatID) al.updateToolContexts(opts.Channel, opts.ChatID)
// 2. Build messages // 2. Load observation block for system prompt injection
if al.obsManager != nil {
block := al.obsManager.LoadBlock(ctx, opts.SessionKey)
al.contextBuilder.SetObservationBlock(block)
}
// 2b. Load knowledge block from completed Focus sessions
if al.memDelegate != nil {
kb := tools.LoadKnowledgeBlock(ctx, al.memDelegate, opts.SessionKey)
al.contextBuilder.SetKnowledgeBlock(kb)
}
// 3. Build messages with DAG compression
var history []messages.Message var history []messages.Message
var summary string var summary string
if !opts.NoHistory { if !opts.NoHistory {
history = al.sessions.GetHistory(opts.SessionKey) history = al.sessions.GetHistory(opts.SessionKey)
summary = al.sessions.GetSummary(opts.SessionKey) summary = al.sessions.GetSummary(opts.SessionKey)
} }
// 3a. DAG compression
history = al.applyDAGCompression(history)
builtMsgs := al.contextBuilder.BuildMessages(history, summary, opts.UserMessage, nil, opts.Channel, opts.ChatID) builtMsgs := al.contextBuilder.BuildMessages(history, summary, opts.UserMessage, nil, opts.Channel, opts.ChatID)
// 3. Save user message // 4. Save user message
al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Split into system/history/user // 5. Split into system/history/user
systemPrompt := "" systemPrompt := ""
var historyMsgs []messages.Message var historyMsgs []messages.Message
userPrompt := opts.UserMessage userPrompt := opts.UserMessage
@ -677,12 +798,12 @@ func (al *AgentLoop) runAgentLoopStreaming(ctx context.Context, opts processOpti
return nil return nil
}, },
// Save each step's messages to session as they complete
OnStepFinish: func(step fantasy.StepResult) error { OnStepFinish: func(step fantasy.StepResult) error {
stepMsgs := picofantasy.StepToMessages(step) stepMsgs := picofantasy.StepToMessages(step)
for _, m := range stepMsgs { for _, m := range stepMsgs {
al.sessions.AddFullMessage(opts.SessionKey, m) al.sessions.AddFullMessage(opts.SessionKey, m)
} }
al.auditStep(ctx, step, opts.SessionKey)
return nil return nil
}, },
@ -719,6 +840,12 @@ func (al *AgentLoop) runAgentLoopStreaming(ctx context.Context, opts processOpti
al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID) al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID)
} }
// 12b. Trigger async observation if tail exceeds token threshold
if al.obsManager != nil {
tail := al.sessionsToMessagePairs(opts.SessionKey)
al.obsManager.MaybeObserveAsync(ctx, opts.SessionKey, tail)
}
// 13. Log response // 13. Log response
stepCount := len(result.Steps) stepCount := len(result.Steps)
responsePreview := utils.Truncate(finalContent, 120) responsePreview := utils.Truncate(finalContent, 120)
@ -735,6 +862,35 @@ func (al *AgentLoop) runAgentLoopStreaming(ctx context.Context, opts processOpti
// runLLMIteration — DELETED. Replaced by Fantasy's internal agent loop. // runLLMIteration — DELETED. Replaced by Fantasy's internal agent loop.
// auditStep logs tool calls from a Fantasy step result to the audit log.
func (al *AgentLoop) auditStep(ctx context.Context, step fantasy.StepResult, sessionKey string) {
if al.memDelegate == nil {
return
}
toolCalls := step.Content.ToolCalls()
if len(toolCalls) == 0 {
return
}
for _, tc := range toolCalls {
entry := &memory.AuditEntry{
ID: ids.New(),
AgentID: "picoclaw",
SessionKey: sessionKey,
Action: "tool_call",
Target: tc.ToolName,
Input: tc.Input,
}
aCtx, cancel := context.WithTimeout(ctx, time.Second)
if err := al.memDelegate.InsertAuditEntry(aCtx, entry); err != nil {
logger.WarnCF("agent", "Failed to log audit entry",
map[string]interface{}{"tool": tc.ToolName, "error": err.Error()})
}
cancel()
}
}
// updateToolContexts updates the context for tools that need channel/chatID info. // updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(channel, chatID string) { func (al *AgentLoop) updateToolContexts(channel, chatID string) {
// Use ContextualTool interface instead of type assertions // Use ContextualTool interface instead of type assertions
@ -839,6 +995,11 @@ func (al *AgentLoop) forceCompression(sessionKey string) {
}) })
} }
// MemoryDelegate returns the active memory delegate (nil if memory system is disabled).
func (al *AgentLoop) MemoryDelegate() memory.MemoryDelegate {
return al.memDelegate
}
// GetStartupInfo returns information about loaded tools and skills for logging. // GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]interface{} { func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info := make(map[string]interface{}) info := make(map[string]interface{})
@ -1013,6 +1174,72 @@ func (al *AgentLoop) callModel(ctx context.Context, prompt string) (string, erro
return resp.Content.Text(), nil return resp.Content.Text(), nil
} }
// sessionsToMessagePairs converts the session history to observation.MessagePair
// for token estimation by the observation manager.
func (al *AgentLoop) sessionsToMessagePairs(sessionKey string) []observation.MessagePair {
history := al.sessions.GetHistory(sessionKey)
pairs := make([]observation.MessagePair, len(history))
for i, m := range history {
pairs[i] = observation.MessagePair{Role: m.Role, Content: m.Content}
}
return pairs
}
// applyDAGCompression compresses old history into a DAG summary block and
// returns only the tail messages that should be passed as raw conversation.
// The compressed portion is injected into the system prompt via contextBuilder.
func (al *AgentLoop) applyDAGCompression(history []messages.Message) []messages.Message {
const minHistoryForDAG = 16
if len(history) < minHistoryForDAG {
al.contextBuilder.SetDAGBlock("")
return history
}
budget := dag.ComputeBudget(al.contextWindow, dag.DefaultBudgetConfig())
tailCount := dag.TailMessageCount(budget.RawTail)
if tailCount >= len(history) {
al.contextBuilder.SetDAGBlock("")
return history
}
// Split: compress old, keep tail raw
compressible := history[:len(history)-tailCount]
tail := history[len(history)-tailCount:]
// Tool-call-aware: don't split on a "tool" message
for len(tail) > 0 && tail[0].Role == "tool" && len(compressible) > 0 {
tail = append([]messages.Message{compressible[len(compressible)-1]}, tail...)
compressible = compressible[:len(compressible)-1]
}
if len(compressible) == 0 {
al.contextBuilder.SetDAGBlock("")
return history
}
dagMsgs := make([]dag.Message, len(compressible))
for i, m := range compressible {
dagMsgs[i] = dag.Message{Role: m.Role, Content: m.Content}
}
compressor := dag.NewCompressor(dag.DefaultCompressorConfig())
d := compressor.Compress(dagMsgs)
rendered := dag.RenderDAGForBudget(d, budget.DAGSummaries)
al.contextBuilder.SetDAGBlock(rendered)
logger.DebugCF("agent", "DAG compression applied",
map[string]interface{}{
"total_msgs": len(history),
"compressed_msgs": len(compressible),
"tail_msgs": len(tail),
"dag_nodes": len(d.Nodes),
})
return tail
}
// estimateTokens estimates the number of tokens in a message list. // estimateTokens estimates the number of tokens in a message list.
func (al *AgentLoop) estimateTokens(msgs []messages.Message) int { func (al *AgentLoop) estimateTokens(msgs []messages.Message) int {
totalChars := 0 totalChars := 0

View file

@ -1,161 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"fmt"
"os"
"path/filepath"
"time"
)
// MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
type MemoryStore struct {
workspace string
memoryDir string
memoryFile string
}
// NewMemoryStore creates a new MemoryStore with the given workspace path.
// It ensures the memory directory exists.
func NewMemoryStore(workspace string) *MemoryStore {
memoryDir := filepath.Join(workspace, "memory")
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists
os.MkdirAll(memoryDir, 0755)
return &MemoryStore{
workspace: workspace,
memoryDir: memoryDir,
memoryFile: memoryFile,
}
}
// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
func (ms *MemoryStore) getTodayFile() string {
today := time.Now().Format("20060102") // YYYYMMDD
monthDir := today[:6] // YYYYMM
filePath := filepath.Join(ms.memoryDir, monthDir, today+".md")
return filePath
}
// ReadLongTerm reads the long-term memory (MEMORY.md).
// Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadLongTerm() string {
if data, err := os.ReadFile(ms.memoryFile); err == nil {
return string(data)
}
return ""
}
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
}
// ReadToday reads today's daily note.
// Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadToday() string {
todayFile := ms.getTodayFile()
if data, err := os.ReadFile(todayFile); err == nil {
return string(data)
}
return ""
}
// AppendToday appends content to today's daily note.
// If the file doesn't exist, it creates a new file with a date header.
func (ms *MemoryStore) AppendToday(content string) error {
todayFile := ms.getTodayFile()
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0755)
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
existingContent = string(data)
}
var newContent string
if existingContent == "" {
// Add header for new day
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
newContent = header + content
} else {
// Append to existing content
newContent = existingContent + "\n" + content
}
return os.WriteFile(todayFile, []byte(newContent), 0644)
}
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
var notes []string
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i)
dateStr := date.Format("20060102") // YYYYMMDD
monthDir := dateStr[:6] // YYYYMM
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil {
notes = append(notes, string(data))
}
}
if len(notes) == 0 {
return ""
}
// Join with separator
var result string
for i, note := range notes {
if i > 0 {
result += "\n\n---\n\n"
}
result += note
}
return result
}
// GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes.
func (ms *MemoryStore) GetMemoryContext() string {
var parts []string
// Long-term memory
longTerm := ms.ReadLongTerm()
if longTerm != "" {
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
}
// Recent daily notes (last 3 days)
recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
}
if len(parts) == 0 {
return ""
}
// Join parts with separator
var result string
for i, part := range parts {
if i > 0 {
result += "\n\n---\n\n"
}
result += part
}
return fmt.Sprintf("# Memory\n\n%s", result)
}

View file

@ -1,6 +1,7 @@
package cron package cron
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -12,6 +13,7 @@ import (
"time" "time"
"github.com/adhocore/gronx" "github.com/adhocore/gronx"
"github.com/sipeed/picoclaw/pkg/memory"
) )
type CronSchedule struct { type CronSchedule struct {
@ -57,6 +59,19 @@ type CronStore struct {
type JobHandler func(job *CronJob) (string, error) type JobHandler func(job *CronJob) (string, error)
// CronOption configures a CronService.
type CronOption func(*CronService)
// WithCronDelegate injects a memory delegate for KV-backed cron store persistence.
func WithCronDelegate(del memory.MemoryDelegate, agentID string) CronOption {
return func(cs *CronService) {
cs.delegate = del
cs.agentID = agentID
}
}
const cronStoreKVKey = "cron:store"
type CronService struct { type CronService struct {
storePath string storePath string
store *CronStore store *CronStore
@ -65,15 +80,19 @@ type CronService struct {
running bool running bool
stopChan chan struct{} stopChan chan struct{}
gronx *gronx.Gronx gronx *gronx.Gronx
delegate memory.MemoryDelegate
agentID string
} }
func NewCronService(storePath string, onJob JobHandler) *CronService { func NewCronService(storePath string, onJob JobHandler, opts ...CronOption) *CronService {
cs := &CronService{ cs := &CronService{
storePath: storePath, storePath: storePath,
onJob: onJob, onJob: onJob,
gronx: gronx.New(), gronx: gronx.New(),
} }
// Initialize and load store on creation for _, opt := range opts {
opt(cs)
}
cs.loadStore() cs.loadStore()
return cs return cs
} }
@ -318,6 +337,10 @@ func (cs *CronService) loadStore() error {
Jobs: []CronJob{}, Jobs: []CronJob{},
} }
if cs.delegate != nil {
return cs.loadStoreFromDelegate()
}
data, err := os.ReadFile(cs.storePath) data, err := os.ReadFile(cs.storePath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -329,7 +352,22 @@ func (cs *CronService) loadStore() error {
return json.Unmarshal(data, cs.store) return json.Unmarshal(data, cs.store)
} }
func (cs *CronService) loadStoreFromDelegate() error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
val, err := cs.delegate.GetKV(ctx, cs.agentID, cronStoreKVKey)
if err != nil || val == "" {
return nil
}
return json.Unmarshal([]byte(val), cs.store)
}
func (cs *CronService) saveStoreUnsafe() error { func (cs *CronService) saveStoreUnsafe() error {
if cs.delegate != nil {
return cs.saveStoreToDelegate()
}
dir := filepath.Dir(cs.storePath) dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
@ -343,6 +381,16 @@ func (cs *CronService) saveStoreUnsafe() error {
return os.WriteFile(cs.storePath, data, 0600) return os.WriteFile(cs.storePath, data, 0600)
} }
func (cs *CronService) saveStoreToDelegate() error {
data, err := json.Marshal(cs.store)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return cs.delegate.UpsertKV(ctx, cs.agentID, cronStoreKVKey, string(data))
}
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) { func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() defer cs.mu.Unlock()

View file

@ -43,9 +43,9 @@ type HeartbeatService struct {
stopChan chan struct{} stopChan chan struct{}
} }
// NewHeartbeatService creates a new heartbeat service // NewHeartbeatService creates a new heartbeat service.
func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService { // stateOpts are forwarded to the internal state.Manager.
// Apply minimum interval func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool, stateOpts ...state.Option) *HeartbeatService {
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 { if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
intervalMinutes = minIntervalMinutes intervalMinutes = minIntervalMinutes
} }
@ -58,7 +58,7 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H
workspace: workspace, workspace: workspace,
interval: time.Duration(intervalMinutes) * time.Minute, interval: time.Duration(intervalMinutes) * time.Minute,
enabled: enabled, enabled: enabled,
state: state.NewManager(workspace), state: state.NewManager(workspace, stateOpts...),
} }
} }

View file

@ -1,6 +1,7 @@
package session package session
import ( import (
"context"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
@ -9,7 +10,9 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/cache" "github.com/sipeed/picoclaw/pkg/cache"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
) )
@ -33,26 +36,44 @@ type SessionManagerConfig struct {
SessionTTL time.Duration SessionTTL time.Duration
} }
// SessionOption configures a SessionManager.
type SessionOption func(*SessionManager)
// WithSessionDelegate injects a memory delegate for DB-backed session persistence.
// When set, sessions persist through recall_items instead of JSON files.
func WithSessionDelegate(del memory.MemoryDelegate, agentID string) SessionOption {
return func(sm *SessionManager) {
sm.delegate = del
sm.agentID = agentID
}
}
type SessionManager struct { type SessionManager struct {
sessions map[string]*Session // primary store (always authoritative) sessions map[string]*Session // primary store (always authoritative)
lru *cache.LRU[string, bool] // tracks access order; value is just a presence flag lru *cache.LRU[string, bool] // tracks access order; value is just a presence flag
mu sync.RWMutex mu sync.RWMutex
storage string storage string
cfg SessionManagerConfig cfg SessionManagerConfig
delegate memory.MemoryDelegate
agentID string
} }
func NewSessionManager(storage string) *SessionManager { func NewSessionManager(storage string, opts ...SessionOption) *SessionManager {
return NewSessionManagerWithConfig(storage, SessionManagerConfig{}) return NewSessionManagerWithConfig(storage, SessionManagerConfig{}, opts...)
} }
// NewSessionManagerWithConfig creates a SessionManager with LRU cache settings. // NewSessionManagerWithConfig creates a SessionManager with LRU cache settings.
func NewSessionManagerWithConfig(storage string, cfg SessionManagerConfig) *SessionManager { func NewSessionManagerWithConfig(storage string, cfg SessionManagerConfig, opts ...SessionOption) *SessionManager {
sm := &SessionManager{ sm := &SessionManager{
sessions: make(map[string]*Session), sessions: make(map[string]*Session),
storage: storage, storage: storage,
cfg: cfg, cfg: cfg,
} }
for _, opt := range opts {
opt(sm)
}
if cfg.MaxCachedSessions > 0 { if cfg.MaxCachedSessions > 0 {
sm.lru = cache.New(cache.Options[string, bool]{ sm.lru = cache.New(cache.Options[string, bool]{
MaxSize: cfg.MaxCachedSessions, MaxSize: cfg.MaxCachedSessions,
@ -63,7 +84,9 @@ func NewSessionManagerWithConfig(storage string, cfg SessionManagerConfig) *Sess
}) })
} }
if storage != "" { if sm.delegate != nil {
sm.loadSessionsFromDelegate()
} else if storage != "" {
os.MkdirAll(storage, 0755) os.MkdirAll(storage, 0755)
sm.loadSessions() sm.loadSessions()
} }
@ -71,6 +94,41 @@ func NewSessionManagerWithConfig(storage string, cfg SessionManagerConfig) *Sess
return sm return sm
} }
func (sm *SessionManager) loadSessionsFromDelegate() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
items, err := sm.delegate.ListRecallItems(ctx, sm.agentID, "", 500, 0)
if err != nil {
logger.WarnCF("session", "Failed to load sessions from delegate",
map[string]interface{}{"error": err.Error()})
return
}
for _, item := range items {
if !strings.Contains(item.Tags, "session-message") {
continue
}
session := sm.sessions[item.SessionKey]
if session == nil {
session = &Session{
Key: item.SessionKey,
Messages: []messages.Message{},
Created: item.CreatedAt,
Updated: item.CreatedAt,
}
sm.sessions[item.SessionKey] = session
}
session.Messages = append(session.Messages, messages.Message{
Role: item.Role,
Content: item.Content,
})
if item.CreatedAt.After(session.Updated) {
session.Updated = item.CreatedAt
}
}
}
// touchLRU records an access in the LRU tracker, which may evict cold sessions. // touchLRU records an access in the LRU tracker, which may evict cold sessions.
func (sm *SessionManager) touchLRU(key string) { func (sm *SessionManager) touchLRU(key string) {
if sm.lru != nil { if sm.lru != nil {
@ -181,6 +239,10 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg messages.Message
session.Updated = time.Now() session.Updated = time.Now()
sm.touchLRU(sessionKey) sm.touchLRU(sessionKey)
if sm.delegate != nil {
sm.persistMessageToDelegate(sessionKey, msg)
}
// Hard cap: prevent unbounded growth if summarization keeps failing. // Hard cap: prevent unbounded growth if summarization keeps failing.
// Keep last 50 messages when we exceed 200. // Keep last 50 messages when we exceed 200.
const hardCap = 200 const hardCap = 200
@ -196,6 +258,31 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg messages.Message
} }
} }
func (sm *SessionManager) persistMessageToDelegate(sessionKey string, msg messages.Message) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
now := time.Now()
item := &memory.RecallItem{
ID: ids.New(),
AgentID: sm.agentID,
SessionKey: sessionKey,
Role: msg.Role,
Sector: memory.SectorEpisodic,
Importance: 0.5,
Salience: 0.5,
DecayRate: 0.01,
Content: msg.Content,
Tags: "session-message",
CreatedAt: now,
UpdatedAt: now,
}
if err := sm.delegate.InsertRecallItem(ctx, item); err != nil {
logger.WarnCF("session", "Failed to persist message to delegate",
map[string]interface{}{"session": sessionKey, "error": err.Error()})
}
}
func (sm *SessionManager) GetHistory(key string) []messages.Message { func (sm *SessionManager) GetHistory(key string) []messages.Message {
sm.mu.RLock() sm.mu.RLock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
@ -340,6 +427,9 @@ func sanitizeFilename(key string) string {
return strings.ReplaceAll(key, ":", "_") return strings.ReplaceAll(key, ":", "_")
} }
func (sm *SessionManager) Save(key string) error { func (sm *SessionManager) Save(key string) error {
if sm.delegate != nil {
return nil
}
if sm.storage == "" { if sm.storage == "" {
return nil return nil
} }

View file

@ -1,11 +1,13 @@
package session package session
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/messages" "github.com/sipeed/picoclaw/pkg/messages"
) )
@ -189,3 +191,64 @@ func TestCleanupStale(t *testing.T) {
t.Errorf("expected active session to remain") t.Errorf("expected active session to remain")
} }
} }
func TestSessionManager_DelegatePersistence(t *testing.T) {
del, err := delegate.NewLibSQLInMemory()
if err != nil {
t.Fatalf("NewLibSQLInMemory: %v", err)
}
if err := del.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
defer del.Close()
sm := NewSessionManager("", WithSessionDelegate(del, "test-agent"))
key := "delegate-session"
sm.AddMessage(key, "user", "hello from delegate")
sm.AddMessage(key, "assistant", "hi back")
history := sm.GetHistory(key)
if len(history) != 2 {
t.Fatalf("expected 2 messages in-memory, got %d", len(history))
}
items, err := del.ListRecallItems(context.Background(), "test-agent", key, 100, 0)
if err != nil {
t.Fatalf("ListRecallItems: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 recall items in DB, got %d", len(items))
}
if items[0].Content != "hello from delegate" {
t.Errorf("expected first item content 'hello from delegate', got %q", items[0].Content)
}
}
func TestSessionManager_DelegateSaveIsNoop(t *testing.T) {
del, err := delegate.NewLibSQLInMemory()
if err != nil {
t.Fatalf("NewLibSQLInMemory: %v", err)
}
if err := del.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
defer del.Close()
tmpDir := t.TempDir()
sm := NewSessionManager(tmpDir, WithSessionDelegate(del, "test-agent"))
key := "telegram:999"
sm.AddMessage(key, "user", "test")
if err := sm.Save(key); err != nil {
t.Fatalf("Save: %v", err)
}
entries, _ := os.ReadDir(tmpDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
t.Errorf("delegate mode should not write JSON files, found %s", e.Name())
}
}
}

View file

@ -1,6 +1,7 @@
package state package state
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -8,8 +9,12 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/memory"
) )
const kvAgentID = "picoclaw"
// State represents the persistent state for a workspace. // State represents the persistent state for a workspace.
// It includes information about the last active channel/chat. // It includes information about the last active channel/chat.
type State struct { type State struct {
@ -23,64 +28,87 @@ type State struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
// Option configures a Manager.
type Option func(*Manager)
// WithDelegate injects a memory delegate for KV-backed persistence.
// When set, state is stored in the agent_kv table instead of on disk.
func WithDelegate(del memory.MemoryDelegate) Option {
return func(m *Manager) { m.delegate = del }
}
// Manager manages persistent state with atomic saves. // Manager manages persistent state with atomic saves.
// When a delegate is present, state persists through agent_kv.
// Otherwise, it falls back to file-based atomic JSON writes.
type Manager struct { type Manager struct {
workspace string workspace string
state *State state *State
mu sync.RWMutex mu sync.RWMutex
stateFile string stateFile string
delegate memory.MemoryDelegate
} }
// NewManager creates a new state manager for the given workspace. // NewManager creates a new state manager for the given workspace.
func NewManager(workspace string) *Manager { func NewManager(workspace string, opts ...Option) *Manager {
sm := &Manager{
workspace: workspace,
state: &State{},
}
for _, opt := range opts {
opt(sm)
}
if sm.delegate != nil {
sm.loadFromDelegate()
return sm
}
stateDir := filepath.Join(workspace, "state") stateDir := filepath.Join(workspace, "state")
stateFile := filepath.Join(stateDir, "state.json") stateFile := filepath.Join(stateDir, "state.json")
oldStateFile := filepath.Join(workspace, "state.json") oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
os.MkdirAll(stateDir, 0755) os.MkdirAll(stateDir, 0755)
sm.stateFile = stateFile
sm := &Manager{
workspace: workspace,
stateFile: stateFile,
state: &State{},
}
// Try to load from new location first
if _, err := os.Stat(stateFile); os.IsNotExist(err) { if _, err := os.Stat(stateFile); os.IsNotExist(err) {
// New file doesn't exist, try migrating from old location
if data, err := os.ReadFile(oldStateFile); err == nil { if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil { if err := json.Unmarshal(data, sm.state); err == nil {
// Migrate to new location
sm.saveAtomic() sm.saveAtomic()
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile) log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
} }
} }
} else { } else {
// Load from new location
sm.load() sm.load()
} }
return sm return sm
} }
func (sm *Manager) loadFromDelegate() {
ctx := context.Background()
if v, err := sm.delegate.GetKV(ctx, kvAgentID, "state:last_channel"); err == nil && v != "" {
sm.state.LastChannel = v
}
if v, err := sm.delegate.GetKV(ctx, kvAgentID, "state:last_chat_id"); err == nil && v != "" {
sm.state.LastChatID = v
}
if v, err := sm.delegate.GetKV(ctx, kvAgentID, "state:timestamp"); err == nil && v != "" {
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
sm.state.Timestamp = t
}
}
}
// SetLastChannel atomically updates the last channel and saves the state. // SetLastChannel atomically updates the last channel and saves the state.
// This method uses a temp file + rename pattern for atomic writes,
// ensuring that the state file is never corrupted even if the process crashes.
func (sm *Manager) SetLastChannel(channel string) error { func (sm *Manager) SetLastChannel(channel string) error {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
// Update state
sm.state.LastChannel = channel sm.state.LastChannel = channel
sm.state.Timestamp = time.Now() sm.state.Timestamp = time.Now()
// Atomic save using temp file + rename return sm.persist()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
} }
// SetLastChatID atomically updates the last chat ID and saves the state. // SetLastChatID atomically updates the last chat ID and saves the state.
@ -88,15 +116,34 @@ func (sm *Manager) SetLastChatID(chatID string) error {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
// Update state
sm.state.LastChatID = chatID sm.state.LastChatID = chatID
sm.state.Timestamp = time.Now() sm.state.Timestamp = time.Now()
// Atomic save using temp file + rename return sm.persist()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
} }
// persist writes the current state to the delegate (KV) or file.
// Must be called with the lock held.
func (sm *Manager) persist() error {
if sm.delegate != nil {
return sm.persistToDelegate()
}
return sm.saveAtomic()
}
func (sm *Manager) persistToDelegate() error {
ctx := context.Background()
ts := sm.state.Timestamp.Format(time.RFC3339Nano)
if err := sm.delegate.UpsertKV(ctx, kvAgentID, "state:last_channel", sm.state.LastChannel); err != nil {
return fmt.Errorf("upsert last_channel: %w", err)
}
if err := sm.delegate.UpsertKV(ctx, kvAgentID, "state:last_chat_id", sm.state.LastChatID); err != nil {
return fmt.Errorf("upsert last_chat_id: %w", err)
}
if err := sm.delegate.UpsertKV(ctx, kvAgentID, "state:timestamp", ts); err != nil {
return fmt.Errorf("upsert timestamp: %w", err)
}
return nil return nil
} }