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:
@@ -74,6 +74,7 @@ type AgentLoop struct {
|
||||
// processOptions configures how a message is processed
|
||||
type processOptions struct {
|
||||
SessionKey string // Session identifier for history/context
|
||||
SessionAliases []string // Compatibility aliases for the session key
|
||||
Channel string // Target channel for tool execution
|
||||
ChatID string // Target chat ID for tool execution
|
||||
MessageID string // Current inbound platform message ID
|
||||
@@ -1475,6 +1476,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
|
||||
opts := processOptions{
|
||||
SessionKey: sessionKey,
|
||||
SessionAliases: buildSessionAliases(sessionKey, allocation.SessionKey, msg.SessionKey),
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
MessageID: msg.MessageID,
|
||||
@@ -1547,6 +1549,43 @@ func resolveScopeKey(routeSessionKey, msgSessionKey string) string {
|
||||
return routeSessionKey
|
||||
}
|
||||
|
||||
func buildSessionAliases(canonicalKey string, keys ...string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
aliases := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
canonicalKey = strings.TrimSpace(canonicalKey)
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || key == canonicalKey {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
aliases = append(aliases, key)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) {
|
||||
if key == "" || scope == nil {
|
||||
return
|
||||
}
|
||||
metaStore, ok := store.(interface {
|
||||
EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string)
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
metaStore.EnsureSessionMetadata(key, scope, aliases)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation {
|
||||
return session.AllocateRouteSession(session.AllocationInput{
|
||||
AgentID: route.AgentID,
|
||||
@@ -1668,6 +1707,8 @@ func (al *AgentLoop) runAgentLoop(
|
||||
}
|
||||
}
|
||||
|
||||
ensureSessionMetadata(agent.Sessions, opts.SessionKey, opts.SessionScope, opts.SessionAliases)
|
||||
|
||||
turnScope := al.newTurnEventScope(
|
||||
agent.ID,
|
||||
opts.SessionKey,
|
||||
|
||||
+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()
|
||||
|
||||
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
@@ -15,24 +16,82 @@ type JSONLBackend struct {
|
||||
store memory.Store
|
||||
}
|
||||
|
||||
type metaAwareStore interface {
|
||||
GetSessionMeta(ctx context.Context, sessionKey string) (memory.SessionMeta, error)
|
||||
UpsertSessionMeta(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) error
|
||||
ResolveSessionKey(ctx context.Context, sessionKey string) (string, bool, error)
|
||||
}
|
||||
|
||||
// MetadataAwareSessionStore exposes structured session metadata operations.
|
||||
type MetadataAwareSessionStore interface {
|
||||
EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string)
|
||||
ResolveSessionKey(sessionKey string) string
|
||||
}
|
||||
|
||||
// NewJSONLBackend wraps a memory.Store for use as a SessionStore.
|
||||
func NewJSONLBackend(store memory.Store) *JSONLBackend {
|
||||
return &JSONLBackend{store: store}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) resolveSessionKey(sessionKey string) string {
|
||||
metaStore, ok := b.store.(metaAwareStore)
|
||||
if !ok {
|
||||
return sessionKey
|
||||
}
|
||||
resolved, found, err := metaStore.ResolveSessionKey(context.Background(), sessionKey)
|
||||
if err != nil {
|
||||
log.Printf("session: resolve session key: %v", err)
|
||||
return sessionKey
|
||||
}
|
||||
if found && resolved != "" {
|
||||
return resolved
|
||||
}
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
// ResolveSessionKey maps aliases onto their canonical session key when the
|
||||
// underlying store supports structured metadata. Unknown aliases fall back to
|
||||
// the original input so existing callers remain compatible.
|
||||
func (b *JSONLBackend) ResolveSessionKey(sessionKey string) string {
|
||||
return b.resolveSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
// EnsureSessionMetadata persists scope and alias metadata for a session.
|
||||
func (b *JSONLBackend) EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) {
|
||||
metaStore, ok := b.store.(metaAwareStore)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rawScope json.RawMessage
|
||||
if scope != nil {
|
||||
data, err := json.Marshal(scope)
|
||||
if err != nil {
|
||||
log.Printf("session: encode session scope: %v", err)
|
||||
return
|
||||
}
|
||||
rawScope = data
|
||||
}
|
||||
if err := metaStore.UpsertSessionMeta(context.Background(), sessionKey, rawScope, aliases); err != nil {
|
||||
log.Printf("session: upsert session metadata: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
|
||||
sessionKey = b.resolveSessionKey(sessionKey)
|
||||
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
|
||||
log.Printf("session: add message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
sessionKey = b.resolveSessionKey(sessionKey)
|
||||
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
|
||||
log.Printf("session: add full message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||
key = b.resolveSessionKey(key)
|
||||
msgs, err := b.store.GetHistory(context.Background(), key)
|
||||
if err != nil {
|
||||
log.Printf("session: get history: %v", err)
|
||||
@@ -42,6 +101,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) GetSummary(key string) string {
|
||||
key = b.resolveSessionKey(key)
|
||||
summary, err := b.store.GetSummary(context.Background(), key)
|
||||
if err != nil {
|
||||
log.Printf("session: get summary: %v", err)
|
||||
@@ -51,18 +111,21 @@ func (b *JSONLBackend) GetSummary(key string) string {
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) SetSummary(key, summary string) {
|
||||
key = b.resolveSessionKey(key)
|
||||
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
|
||||
log.Printf("session: set summary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
|
||||
key = b.resolveSessionKey(key)
|
||||
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
|
||||
log.Printf("session: set history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
||||
key = b.resolveSessionKey(key)
|
||||
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
|
||||
log.Printf("session: truncate history: %v", err)
|
||||
}
|
||||
@@ -72,6 +135,7 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
||||
// immediately, the data is already durable. Save runs compaction to reclaim
|
||||
// space from logically truncated messages (no-op when there are none).
|
||||
func (b *JSONLBackend) Save(key string) error {
|
||||
key = b.resolveSessionKey(key)
|
||||
return b.store.Compact(context.Background(), key)
|
||||
}
|
||||
|
||||
|
||||
@@ -177,3 +177,31 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) {
|
||||
t.Errorf("first message = %q, want %q", history[0].Content, "msg 16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLBackend_ResolveAliasAndPersistMetadata(t *testing.T) {
|
||||
b := newBackend(t)
|
||||
|
||||
b.EnsureSessionMetadata("canonical", &session.SessionScope{
|
||||
Version: session.ScopeVersionV1,
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Account: "default",
|
||||
Dimensions: []string{"chat"},
|
||||
Values: map[string]string{
|
||||
"chat": "group:c1",
|
||||
},
|
||||
}, []string{"legacy"})
|
||||
|
||||
if got := b.ResolveSessionKey("legacy"); got != "canonical" {
|
||||
t.Fatalf("ResolveSessionKey() = %q, want %q", got, "canonical")
|
||||
}
|
||||
|
||||
b.AddMessage("legacy", "user", "hello through alias")
|
||||
history := b.GetHistory("canonical")
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("len(history) = %d, want 1", len(history))
|
||||
}
|
||||
if history[0].Content != "hello through alias" {
|
||||
t.Fatalf("history[0].Content = %q, want %q", history[0].Content, "hello through alias")
|
||||
}
|
||||
}
|
||||
|
||||
+163
-90
@@ -13,7 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
)
|
||||
|
||||
// registerSessionRoutes binds session list and detail endpoints to the ServeMux.
|
||||
@@ -42,15 +44,6 @@ type sessionListItem struct {
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
type sessionMetaFile 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"`
|
||||
}
|
||||
|
||||
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
|
||||
// channel sessions. The full key format is:
|
||||
//
|
||||
@@ -60,10 +53,9 @@ type sessionMetaFile struct {
|
||||
//
|
||||
// agent_main_pico_direct_pico_<session-uuid>.json
|
||||
const (
|
||||
picoSessionPrefix = "agent:main:pico:direct:pico:"
|
||||
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
|
||||
maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
|
||||
maxSessionTitleRunes = 60
|
||||
picoSessionPrefix = "agent:main:pico:direct:pico:"
|
||||
maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB
|
||||
maxSessionTitleRunes = 60
|
||||
)
|
||||
|
||||
// extractPicoSessionID extracts the session UUID from a full session key.
|
||||
@@ -75,15 +67,11 @@ func extractPicoSessionID(key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
|
||||
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
|
||||
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func sanitizeSessionKey(key string) string {
|
||||
return strings.ReplaceAll(key, ":", "_")
|
||||
key = strings.ReplaceAll(key, ":", "_")
|
||||
key = strings.ReplaceAll(key, "/", "_")
|
||||
key = strings.ReplaceAll(key, "\\", "_")
|
||||
return key
|
||||
}
|
||||
|
||||
func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) {
|
||||
@@ -100,18 +88,18 @@ func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
|
||||
func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return sessionMetaFile{Key: sessionKey}, nil
|
||||
return memory.SessionMeta{Key: sessionKey}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sessionMetaFile{}, err
|
||||
return memory.SessionMeta{}, err
|
||||
}
|
||||
|
||||
var meta sessionMetaFile
|
||||
var meta memory.SessionMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return sessionMetaFile{}, err
|
||||
return memory.SessionMeta{}, err
|
||||
}
|
||||
if meta.Key == "" {
|
||||
meta.Key = sessionKey
|
||||
@@ -154,8 +142,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
||||
sessionKey := picoSessionPrefix + sessionID
|
||||
func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) {
|
||||
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
||||
jsonlPath := base + ".jsonl"
|
||||
metaPath := base + ".meta.json"
|
||||
@@ -192,6 +179,100 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
type picoJSONLSessionRef struct {
|
||||
ID string
|
||||
Key string
|
||||
}
|
||||
|
||||
func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
strings.TrimSpace(scope.Values["sender"]),
|
||||
strings.TrimSpace(scope.Values["chat"]),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(candidate, "pico:"); idx >= 0 {
|
||||
sessionID := strings.TrimSpace(candidate[idx+len("pico:"):])
|
||||
if sessionID != "" {
|
||||
return sessionID, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) {
|
||||
if sessionID, ok := extractPicoSessionID(meta.Key); ok {
|
||||
return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
|
||||
}
|
||||
for _, alias := range meta.Aliases {
|
||||
if sessionID, ok := extractPicoSessionID(alias); ok {
|
||||
return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
|
||||
}
|
||||
}
|
||||
if len(meta.Scope) == 0 {
|
||||
return picoJSONLSessionRef{}, false
|
||||
}
|
||||
var scope session.SessionScope
|
||||
if err := json.Unmarshal(meta.Scope, &scope); err != nil {
|
||||
return picoJSONLSessionRef{}, false
|
||||
}
|
||||
sessionID, ok := extractPicoSessionIDFromScope(scope)
|
||||
if !ok {
|
||||
return picoJSONLSessionRef{}, false
|
||||
}
|
||||
return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true
|
||||
}
|
||||
|
||||
func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]picoJSONLSessionRef, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
|
||||
continue
|
||||
}
|
||||
metaPath := filepath.Join(dir, entry.Name())
|
||||
meta, err := h.readSessionMeta(metaPath, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, ok := sessionRefFromMeta(meta)
|
||||
if !ok || ref.Key == "" || ref.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[ref.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[ref.ID] = struct{}{}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) {
|
||||
refs, err := h.findPicoJSONLSessions(dir)
|
||||
if err != nil {
|
||||
return picoJSONLSessionRef{}, err
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.ID == sessionID {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
return picoJSONLSessionRef{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem {
|
||||
preview := ""
|
||||
for _, msg := range sess.Messages {
|
||||
@@ -295,66 +376,45 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
items := []sessionListItem{}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil {
|
||||
for _, ref := range refs {
|
||||
sess, loadErr := h.readJSONLSession(dir, ref.Key)
|
||||
if loadErr != nil || isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
seen[ref.ID] = struct{}{}
|
||||
items = append(items, buildSessionListItem(ref.ID, sess))
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
var (
|
||||
sessionID string
|
||||
sess sessionFile
|
||||
loadErr error
|
||||
ok bool
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".jsonl"):
|
||||
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sess, loadErr = h.readJSONLSession(dir, sessionID)
|
||||
if loadErr == nil && isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
case strings.HasSuffix(name, ".meta.json"):
|
||||
continue
|
||||
case filepath.Ext(name) == ".json":
|
||||
base := strings.TrimSuffix(name, ".json")
|
||||
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
|
||||
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
|
||||
if jsonlSess, jsonlErr := h.readJSONLSession(
|
||||
dir,
|
||||
jsonlSessionID,
|
||||
); jsonlErr == nil &&
|
||||
!isEmptySession(jsonlSess) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
continue
|
||||
}
|
||||
if isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
sessionID, ok = extractPicoSessionID(sess.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[sessionID]; exists {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
if strings.HasSuffix(name, ".meta.json") || filepath.Ext(name) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
if loadErr != nil {
|
||||
base := strings.TrimSuffix(name, ".json")
|
||||
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var sess sessionFile
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
continue
|
||||
}
|
||||
if isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
sessionID, ok := extractPicoSessionID(sess.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[sessionID]; exists {
|
||||
@@ -416,7 +476,12 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.readJSONLSession(dir, sessionID)
|
||||
ref, refErr := h.findPicoJSONLSession(dir, sessionID)
|
||||
var sess sessionFile
|
||||
err = refErr
|
||||
if refErr == nil {
|
||||
sess, err = h.readJSONLSession(dir, ref.Key)
|
||||
}
|
||||
if err == nil && isEmptySession(sess) {
|
||||
err = os.ErrNotExist
|
||||
}
|
||||
@@ -480,20 +545,28 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
|
||||
jsonlPath := base + ".jsonl"
|
||||
metaPath := base + ".meta.json"
|
||||
legacyPath := base + ".json"
|
||||
|
||||
removed := false
|
||||
for _, path := range []string{jsonlPath, metaPath, legacyPath} {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil {
|
||||
base := filepath.Join(dir, sanitizeSessionKey(ref.Key))
|
||||
for _, path := range []string{base + ".jsonl", base + ".meta.json"} {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
|
||||
if err := os.Remove(legacyPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
removed = true
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,83 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
dir := sessionsTestDir(t, configPath)
|
||||
store, err := memory.NewJSONLStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||
}
|
||||
|
||||
sessionKey := "sk_v1_scope_discovery"
|
||||
addErr := store.AddFullMessage(nil, sessionKey, providers.Message{
|
||||
Role: "user",
|
||||
Content: "scope discovered session",
|
||||
})
|
||||
if addErr != nil {
|
||||
t.Fatalf("AddFullMessage() error = %v", addErr)
|
||||
}
|
||||
summaryErr := store.SetSummary(nil, sessionKey, "scope summary")
|
||||
if summaryErr != nil {
|
||||
t.Fatalf("SetSummary() error = %v", summaryErr)
|
||||
}
|
||||
|
||||
scopeData, err := json.Marshal(session.SessionScope{
|
||||
Version: session.ScopeVersionV1,
|
||||
AgentID: "main",
|
||||
Channel: "pico",
|
||||
Account: "default",
|
||||
Dimensions: []string{"sender"},
|
||||
Values: map[string]string{
|
||||
"sender": "pico:scope-jsonl",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(scope) error = %v", err)
|
||||
}
|
||||
if err := store.UpsertSessionMeta(nil, sessionKey, scopeData, nil); err != nil {
|
||||
t.Fatalf("UpsertSessionMeta() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
|
||||
mux.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String())
|
||||
}
|
||||
|
||||
var items []sessionListItem
|
||||
if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("Unmarshal(list) error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].ID != "scope-jsonl" {
|
||||
t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "scope-jsonl")
|
||||
}
|
||||
|
||||
detailRec := httptest.NewRecorder()
|
||||
detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/scope-jsonl", nil)
|
||||
mux.ServeHTTP(detailRec, detailReq)
|
||||
if detailRec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String())
|
||||
}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/scope-jsonl", nil)
|
||||
mux.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteSession_JSONLStorage(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user