mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
115 lines
3.0 KiB
Go
115 lines
3.0 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
)
|
|
|
|
// jsonSession mirrors pkg/session.Session for migration purposes.
|
|
type jsonSession struct {
|
|
Key string `json:"key"`
|
|
Messages []providers.Message `json:"messages"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
}
|
|
|
|
// MigrateFromJSON reads legacy sessions/*.json files from sessionsDir,
|
|
// writes them into the Store, and renames each migrated file to
|
|
// .json.migrated as a backup. Returns the number of sessions migrated.
|
|
//
|
|
// Files that fail to parse are logged and skipped. Already-migrated
|
|
// files (.json.migrated) are ignored, making the function idempotent.
|
|
func MigrateFromJSON(
|
|
ctx context.Context, sessionsDir string, store Store,
|
|
) (int, error) {
|
|
entries, err := os.ReadDir(sessionsDir)
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("memory: read sessions dir: %w", err)
|
|
}
|
|
|
|
migrated := 0
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasSuffix(name, ".json") {
|
|
continue
|
|
}
|
|
// Skip JSONL metadata files. They are part of the new storage format,
|
|
// not legacy session snapshots, and re-importing them would overwrite
|
|
// the paired .jsonl history with an empty message list.
|
|
if strings.HasSuffix(name, ".meta.json") {
|
|
continue
|
|
}
|
|
// Skip already-migrated files.
|
|
if strings.HasSuffix(name, ".migrated") {
|
|
continue
|
|
}
|
|
|
|
srcPath := filepath.Join(sessionsDir, name)
|
|
|
|
data, readErr := os.ReadFile(srcPath)
|
|
if readErr != nil {
|
|
log.Printf("memory: migrate: skip %s: %v", name, readErr)
|
|
continue
|
|
}
|
|
|
|
var sess jsonSession
|
|
if parseErr := json.Unmarshal(data, &sess); parseErr != nil {
|
|
log.Printf("memory: migrate: skip %s: %v", name, parseErr)
|
|
continue
|
|
}
|
|
|
|
// Use the key from the JSON content, not the filename.
|
|
// Filenames are sanitized (":" → "_") but keys are not.
|
|
key := sess.Key
|
|
if key == "" {
|
|
key = strings.TrimSuffix(name, ".json")
|
|
}
|
|
|
|
// Use SetHistory (atomic replace) instead of per-message
|
|
// AddFullMessage. This makes migration idempotent: if the
|
|
// process crashes after writing messages but before the
|
|
// rename below, a retry replaces the partial data cleanly
|
|
// instead of duplicating messages.
|
|
if setErr := store.SetHistory(ctx, key, sess.Messages); setErr != nil {
|
|
return migrated, fmt.Errorf(
|
|
"memory: migrate %s: set history: %w",
|
|
name, setErr,
|
|
)
|
|
}
|
|
|
|
if sess.Summary != "" {
|
|
if sumErr := store.SetSummary(ctx, key, sess.Summary); sumErr != nil {
|
|
return migrated, fmt.Errorf(
|
|
"memory: migrate %s: set summary: %w",
|
|
name, sumErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Rename to .migrated as backup (not delete).
|
|
renameErr := os.Rename(srcPath, srcPath+".migrated")
|
|
if renameErr != nil {
|
|
log.Printf("memory: migrate: rename %s: %v", name, renameErr)
|
|
}
|
|
|
|
migrated++
|
|
}
|
|
|
|
return migrated, nil
|
|
}
|