diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 3d53fbc58..7d535e6dd 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -33,6 +33,8 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "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/skills" "github.com/sipeed/picoclaw/pkg/state" @@ -191,6 +193,8 @@ func main() { fmt.Printf("Unknown skills command: %s\n", subcommand) skillsHelp() } + case "memory": + memoryCmd() case "version", "--version", "-v": printVersion() default: @@ -212,6 +216,7 @@ func printHelp() { fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") 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(" version Show version information") } @@ -701,6 +706,112 @@ func gatewayCmd() { 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 ") + 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() { cfg, err := loadConfig() if err != nil { diff --git a/pkg/memory/delegate/sqlite.go b/pkg/memory/delegate/sqlite.go index 7a052ce03..22487fd68 100644 --- a/pkg/memory/delegate/sqlite.go +++ b/pkg/memory/delegate/sqlite.go @@ -165,6 +165,19 @@ func (d *LibSQLDelegate) Init(ctx context.Context) error { 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. func (d *LibSQLDelegate) EmbeddingDims() int { return d.embeddingDims } diff --git a/pkg/memory/integration_test.go b/pkg/memory/integration_test.go index f16b73415..74b091a5b 100644 --- a/pkg/memory/integration_test.go +++ b/pkg/memory/integration_test.go @@ -41,6 +41,45 @@ func TestIntegration_GooseMigrationIdempotent(t *testing.T) { 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) { store, _ := setupFullStack(t) ctx := context.Background()