feat(memory): add goose down-migration, memory CLI, and session migration hook

- LibSQLDelegate.MigrateDown() for testing migration round-trips
- Integration test: down→up round-trip validates schema recreation
- CLI `picoclaw memory migrate-sessions` for manual session import
- CLI `picoclaw memory db-status` for database inspection
This commit is contained in:
ZanzyTHEbar 2026-02-18 13:21:33 +00:00
parent d0ff8b3f22
commit 3ab8196925
3 changed files with 163 additions and 0 deletions

View file

@ -33,6 +33,8 @@ import (
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
picomemory "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/memory/delegate"
"github.com/sipeed/picoclaw/pkg/migrate" "github.com/sipeed/picoclaw/pkg/migrate"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
@ -191,6 +193,8 @@ func main() {
fmt.Printf("Unknown skills command: %s\n", subcommand) fmt.Printf("Unknown skills command: %s\n", subcommand)
skillsHelp() skillsHelp()
} }
case "memory":
memoryCmd()
case "version", "--version", "-v": case "version", "--version", "-v":
printVersion() printVersion()
default: default:
@ -212,6 +216,7 @@ func printHelp() {
fmt.Println(" status Show picoclaw status") fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
fmt.Println(" memory Memory system management (db status, session migration)")
fmt.Println(" skills Manage skills (install, list, remove)") fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
} }
@ -701,6 +706,112 @@ func gatewayCmd() {
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
} }
func memoryCmd() {
if len(os.Args) < 3 {
memoryHelp()
return
}
sub := os.Args[2]
switch sub {
case "migrate-sessions":
memoryMigrateSessions()
case "db-status":
memoryDBStatus()
case "--help", "-h":
memoryHelp()
default:
fmt.Printf("Unknown memory command: %s\n", sub)
memoryHelp()
}
}
func memoryHelp() {
fmt.Println("\nMemory system management")
fmt.Println()
fmt.Println("Usage: picoclaw memory <subcommand>")
fmt.Println()
fmt.Println("Subcommands:")
fmt.Println(" migrate-sessions Import file-based sessions into recall memory")
fmt.Println(" db-status Show migration version and table counts")
}
func memoryMigrateSessions() {
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.Memory.Enabled {
fmt.Println("Memory system is disabled in config. Enable it first.")
os.Exit(1)
}
workspace := cfg.WorkspacePath()
memDBPath := cfg.Memory.DBPath
if memDBPath == "" {
memDBPath = filepath.Join(workspace, "memory", "picoclaw.db")
}
os.MkdirAll(filepath.Dir(memDBPath), 0755)
del, err := delegate.NewFromConfig(cfg.Memory, memDBPath)
if err != nil {
fmt.Printf("Error creating memory delegate: %v\n", err)
os.Exit(1)
}
defer del.Close()
ctx := context.Background()
if err := del.Init(ctx); err != nil {
fmt.Printf("Error initializing memory schema: %v\n", err)
os.Exit(1)
}
sessionsDir := filepath.Join(workspace, "sessions")
stats, err := picomemory.MigrateFileSessions(ctx, del, "picoclaw", sessionsDir)
if err != nil {
fmt.Printf("Migration error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Session migration complete:\n")
fmt.Printf(" Sessions found: %d\n", stats.SessionsFound)
fmt.Printf(" Sessions migrated: %d\n", stats.SessionsMigrated)
fmt.Printf(" Items created: %d\n", stats.ItemsCreated)
fmt.Printf(" Errors: %d\n", stats.Errors)
}
func memoryDBStatus() {
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
memDBPath := cfg.Memory.DBPath
if memDBPath == "" {
memDBPath = filepath.Join(workspace, "memory", "picoclaw.db")
}
fi, err := os.Stat(memDBPath)
if err != nil {
fmt.Printf("Memory database not found: %s\n", memDBPath)
return
}
fmt.Printf("Memory database: %s\n", memDBPath)
fmt.Printf("Size: %.1f KB\n", float64(fi.Size())/1024)
fmt.Printf("Embedding dims: %d\n", cfg.Memory.EmbeddingDims)
if cfg.Memory.Sync.SyncURL != "" {
fmt.Printf("Turso replica: %s\n", cfg.Memory.Sync.SyncURL)
} else {
fmt.Println("Mode: local-only")
}
}
func statusCmd() { func statusCmd() {
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {

View file

@ -165,6 +165,19 @@ func (d *LibSQLDelegate) Init(ctx context.Context) error {
return nil return nil
} }
// MigrateDown rolls back all migrations. Intended for testing only.
func (d *LibSQLDelegate) MigrateDown(ctx context.Context) error {
mctx := migrations.WithEmbeddingDims(ctx, d.embeddingDims)
provider, err := goose.NewProvider(goose.DialectSQLite3, d.db, nil)
if err != nil {
return fmt.Errorf("create migration provider: %w", err)
}
if _, err := provider.DownTo(mctx, 0); err != nil {
return fmt.Errorf("migration down: %w", err)
}
return nil
}
// EmbeddingDims returns the configured embedding vector dimensions. // EmbeddingDims returns the configured embedding vector dimensions.
func (d *LibSQLDelegate) EmbeddingDims() int { return d.embeddingDims } func (d *LibSQLDelegate) EmbeddingDims() int { return d.embeddingDims }

View file

@ -41,6 +41,45 @@ func TestIntegration_GooseMigrationIdempotent(t *testing.T) {
require.NoError(t, del.Init(ctx), "idempotent re-Init") require.NoError(t, del.Init(ctx), "idempotent re-Init")
} }
func TestIntegration_GooseMigration_DownUpRoundTrip(t *testing.T) {
del, err := delegate.NewLibSQLInMemory()
require.NoError(t, err)
defer del.Close()
ctx := context.Background()
require.NoError(t, del.Init(ctx), "initial up")
item := &memory.RecallItem{
AgentID: testAgent,
SessionKey: testSession,
Role: "user",
Sector: memory.SectorEpisodic,
Content: "pre-migration item",
}
chunker := memstore.NewMarkdownChunker(memstore.DefaultMarkdownChunkerConfig())
store := memstore.New(del, chunker, nil, memstore.DefaultConfig())
require.NoError(t, store.StoreRecall(ctx, item))
require.NoError(t, del.MigrateDown(ctx), "down migration should succeed")
require.NoError(t, del.Init(ctx), "re-up after down should succeed")
fetched, err := store.GetRecall(ctx, item.ID)
require.NoError(t, err)
assert.Nil(t, fetched, "data should be gone after down+up round-trip")
newItem := &memory.RecallItem{
AgentID: testAgent,
SessionKey: testSession,
Role: "user",
Sector: memory.SectorEpisodic,
Content: "post-migration item",
}
require.NoError(t, store.StoreRecall(ctx, newItem), "should be able to write after re-migration")
assert.False(t, newItem.ID.IsZero())
}
func TestIntegration_BlobPK_RoundTrip(t *testing.T) { func TestIntegration_BlobPK_RoundTrip(t *testing.T) {
store, _ := setupFullStack(t) store, _ := setupFullStack(t)
ctx := context.Background() ctx := context.Background()