mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(session): persist scope metadata and aliases
This commit is contained in:
+157
-14
@@ -32,14 +32,19 @@ const (
|
||||
maxLineSize = 10 * 1024 * 1024 // 10 MB
|
||||
)
|
||||
|
||||
// sessionMeta holds per-session metadata stored in a .meta.json file.
|
||||
type sessionMeta struct {
|
||||
Key string `json:"key"`
|
||||
Summary string `json:"summary"`
|
||||
Skip int `json:"skip"`
|
||||
Count int `json:"count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// SessionMeta holds per-session metadata stored in a .meta.json file.
|
||||
//
|
||||
// Scope is stored as raw JSON so pkg/memory can stay decoupled from the
|
||||
// higher-level session package while still preserving structured scope data.
|
||||
type SessionMeta struct {
|
||||
Key string `json:"key"`
|
||||
Summary string `json:"summary"`
|
||||
Skip int `json:"skip"`
|
||||
Count int `json:"count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Scope json.RawMessage `json:"scope,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
// JSONLStore implements Store using append-only JSONL files.
|
||||
@@ -98,25 +103,31 @@ func sanitizeKey(key string) string {
|
||||
|
||||
// readMeta loads the metadata file for a session.
|
||||
// Returns a zero-value sessionMeta if the file does not exist.
|
||||
func (s *JSONLStore) readMeta(key string) (sessionMeta, error) {
|
||||
func (s *JSONLStore) readMeta(key string) (SessionMeta, error) {
|
||||
data, err := os.ReadFile(s.metaPath(key))
|
||||
if os.IsNotExist(err) {
|
||||
return sessionMeta{Key: key}, nil
|
||||
return SessionMeta{Key: key}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err)
|
||||
return SessionMeta{}, fmt.Errorf("memory: read meta: %w", err)
|
||||
}
|
||||
var meta sessionMeta
|
||||
var meta SessionMeta
|
||||
err = json.Unmarshal(data, &meta)
|
||||
if err != nil {
|
||||
return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err)
|
||||
return SessionMeta{}, fmt.Errorf("memory: decode meta: %w", err)
|
||||
}
|
||||
if meta.Key == "" {
|
||||
meta.Key = key
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// writeMeta atomically writes the metadata file using the project's
|
||||
// standard WriteFileAtomic (temp + fsync + rename).
|
||||
func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error {
|
||||
func (s *JSONLStore) writeMeta(key string, meta SessionMeta) error {
|
||||
if strings.TrimSpace(meta.Key) == "" {
|
||||
meta.Key = key
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory: encode meta: %w", err)
|
||||
@@ -124,6 +135,138 @@ func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error {
|
||||
return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644)
|
||||
}
|
||||
|
||||
func cloneRawJSON(data json.RawMessage) json.RawMessage {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append(json.RawMessage(nil), data...)
|
||||
}
|
||||
|
||||
func normalizeAliases(canonicalKey string, aliases []string) []string {
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := make([]string, 0, len(aliases))
|
||||
seen := make(map[string]struct{}, len(aliases))
|
||||
canonicalKey = strings.TrimSpace(canonicalKey)
|
||||
for _, alias := range aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" || alias == canonicalKey {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[alias]; ok {
|
||||
continue
|
||||
}
|
||||
seen[alias] = struct{}{}
|
||||
normalized = append(normalized, alias)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (s *JSONLStore) sessionExists(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(s.jsonlPath(key)); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(s.metaPath(key)); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSessionMeta returns the current metadata snapshot for sessionKey.
|
||||
func (s *JSONLStore) GetSessionMeta(_ context.Context, sessionKey string) (SessionMeta, error) {
|
||||
l := s.sessionLock(sessionKey)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
meta, err := s.readMeta(sessionKey)
|
||||
if err != nil {
|
||||
return SessionMeta{}, err
|
||||
}
|
||||
meta.Scope = cloneRawJSON(meta.Scope)
|
||||
if len(meta.Aliases) > 0 {
|
||||
meta.Aliases = append([]string(nil), meta.Aliases...)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// UpsertSessionMeta stores structured session metadata while preserving
|
||||
// summary/count/skip timestamps maintained by the core JSONL store.
|
||||
func (s *JSONLStore) UpsertSessionMeta(
|
||||
_ context.Context,
|
||||
sessionKey string,
|
||||
scope json.RawMessage,
|
||||
aliases []string,
|
||||
) error {
|
||||
l := s.sessionLock(sessionKey)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
meta, err := s.readMeta(sessionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.Scope = cloneRawJSON(scope)
|
||||
meta.Aliases = normalizeAliases(sessionKey, aliases)
|
||||
now := time.Now()
|
||||
if meta.CreatedAt.IsZero() {
|
||||
meta.CreatedAt = now
|
||||
}
|
||||
meta.UpdatedAt = now
|
||||
|
||||
return s.writeMeta(sessionKey, meta)
|
||||
}
|
||||
|
||||
// ResolveSessionKey returns the canonical session key for a candidate key.
|
||||
// It first checks direct key existence, then scans metadata aliases on miss.
|
||||
func (s *JSONLStore) ResolveSessionKey(_ context.Context, sessionKey string) (string, bool, error) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if s.sessionExists(sessionKey) {
|
||||
return sessionKey, true, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("memory: read sessions dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
|
||||
continue
|
||||
}
|
||||
data, readErr := os.ReadFile(filepath.Join(s.dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
return "", false, fmt.Errorf("memory: read meta: %w", readErr)
|
||||
}
|
||||
var meta SessionMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return "", false, fmt.Errorf("memory: decode meta: %w", err)
|
||||
}
|
||||
if meta.Key == "" {
|
||||
continue
|
||||
}
|
||||
if meta.Key == sessionKey {
|
||||
return meta.Key, true, nil
|
||||
}
|
||||
for _, alias := range meta.Aliases {
|
||||
if alias == sessionKey {
|
||||
return meta.Key, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// readMessages reads valid JSON lines from a .jsonl file, skipping
|
||||
// the first `skip` lines without unmarshaling them. This avoids the
|
||||
// cost of json.Unmarshal on logically truncated messages.
|
||||
|
||||
@@ -2,8 +2,10 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -241,6 +243,59 @@ func TestSetSummary_GetSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionMetaScopeAndAliasesPersist(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
scope := json.RawMessage(`{"version":1,"channel":"telegram","values":{"chat":"group:c1"}}`)
|
||||
aliases := []string{"legacy:one", "legacy:one", "canonical"}
|
||||
if err := store.UpsertSessionMeta(ctx, "canonical", scope, aliases); err != nil {
|
||||
t.Fatalf("UpsertSessionMeta() error = %v", err)
|
||||
}
|
||||
|
||||
meta, err := store.GetSessionMeta(ctx, "canonical")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionMeta() error = %v", err)
|
||||
}
|
||||
var gotScope map[string]any
|
||||
if err := json.Unmarshal(meta.Scope, &gotScope); err != nil {
|
||||
t.Fatalf("Unmarshal(meta.Scope) error = %v", err)
|
||||
}
|
||||
var wantScope map[string]any
|
||||
if err := json.Unmarshal(scope, &wantScope); err != nil {
|
||||
t.Fatalf("Unmarshal(scope) error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotScope, wantScope) {
|
||||
t.Fatalf("meta.Scope = %#v, want %#v", gotScope, wantScope)
|
||||
}
|
||||
if len(meta.Aliases) != 1 || meta.Aliases[0] != "legacy:one" {
|
||||
t.Fatalf("meta.Aliases = %#v, want [legacy:one]", meta.Aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionKeyByAlias(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.AddMessage(ctx, "canonical", "user", "hello"); err != nil {
|
||||
t.Fatalf("AddMessage() error = %v", err)
|
||||
}
|
||||
if err := store.UpsertSessionMeta(ctx, "canonical", nil, []string{"legacy:key"}); err != nil {
|
||||
t.Fatalf("UpsertSessionMeta() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, found, err := store.ResolveSessionKey(ctx, "legacy:key")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionKey() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("ResolveSessionKey() did not find alias")
|
||||
}
|
||||
if resolved != "canonical" {
|
||||
t.Fatalf("resolved = %q, want %q", resolved, "canonical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHistory_KeepLast(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user