From 97e316f4600e60fd27944dea7c739f3275b40be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=8A=E3=83=A0?= Date: Fri, 10 Jul 2026 00:55:11 +0800 Subject: [PATCH] fix(memory): surface passive channel episodic recall (#1410) * fix(memory): surface passive channel episodic recall * fix(memory): expose episodic key topics --------- Co-authored-by: Collective Developer --- internal/channelmemory/review.go | 15 ++- internal/channelmemory/service_test.go | 38 +++++-- internal/channelmemory/util.go | 27 +++++ internal/store/episodic_store.go | 1 + internal/store/pg/episodic_search.go | 26 +++-- internal/store/pg/episodic_summaries.go | 14 ++- internal/store/pg/memory_scan_rows.go | 12 ++- internal/store/pg/scan_rows_test.go | 21 +++- internal/store/sqlitestore/episodic.go | 12 +++ internal/store/sqlitestore/episodic_search.go | 31 ++++-- internal/tools/memory.go | 15 +-- internal/tools/memory_expand.go | 14 ++- internal/tools/memory_search_topics_test.go | 101 ++++++++++++++++++ tests/integration/v3_episodic_store_test.go | 42 ++++++++ 14 files changed, 315 insertions(+), 54 deletions(-) create mode 100644 internal/tools/memory_search_topics_test.go diff --git a/internal/channelmemory/review.go b/internal/channelmemory/review.go index cf0e743c..09ec829d 100644 --- a/internal/channelmemory/review.go +++ b/internal/channelmemory/review.go @@ -22,19 +22,24 @@ func (s *Service) Approve(ctx context.Context, itemID uuid.UUID, approver string return nil, fmt.Errorf("item is not approvable") } sourceID := item.SourceID - exists, err := s.Episodic.ExistsBySourceID(ctx, item.AgentID.String(), item.UserID, sourceID) + // Passive channel extraction is reusable channel context, not a personal + // memory of the channel instance creator or current sender. + memoryUserID := "" + exists, err := s.Episodic.ExistsBySourceID(ctx, item.AgentID.String(), memoryUserID, sourceID) if err != nil { return nil, err } if !exists { retention := s.retentionDuration(ctx, item) + keyTopics := memoryKeyTopics(item) ep := &store.EpisodicSummary{ TenantID: item.TenantID, AgentID: item.AgentID, - UserID: item.UserID, + UserID: memoryUserID, SessionKey: "channel:" + item.ChannelInstanceID.String(), Summary: item.Summary, - KeyTopics: decodeStrings(item.Topics), + KeyTopics: keyTopics, + L0Abstract: item.Summary, SourceID: sourceID, SourceType: "channel", ExpiresAt: timePtr(time.Now().UTC().Add(retention)), @@ -54,13 +59,13 @@ func (s *Service) Approve(ctx context.Context, itemID uuid.UUID, approver string EpisodicID: ep.ID.String(), SessionKey: ep.SessionKey, Summary: item.Summary, - KeyTopics: decodeStrings(item.Topics), + KeyTopics: keyTopics, KeyEntities: decodeStrings(item.Entities), }, }) } } else { - ep, err := s.Episodic.GetBySourceID(ctx, item.AgentID.String(), item.UserID, sourceID) + ep, err := s.Episodic.GetBySourceID(ctx, item.AgentID.String(), memoryUserID, sourceID) if err != nil { return nil, err } diff --git a/internal/channelmemory/service_test.go b/internal/channelmemory/service_test.go index 6f811571..f787281f 100644 --- a/internal/channelmemory/service_test.go +++ b/internal/channelmemory/service_test.go @@ -170,13 +170,15 @@ func (f *fakeChannelStore) Get(context.Context, uuid.UUID) (*store.ChannelInstan type fakeEpisodicStore struct { store.EpisodicStore - exists bool - bySource *store.EpisodicSummary - created []*store.EpisodicSummary - getCalls int - createErr error - existsErr error - getByErr error + exists bool + bySource *store.EpisodicSummary + created []*store.EpisodicSummary + getCalls int + existsUserID string + getUserID string + createErr error + existsErr error + getByErr error } type fakeDomainEventBus struct { @@ -207,12 +209,14 @@ func (f *fakeEpisodicStore) Create(_ context.Context, ep *store.EpisodicSummary) return nil } -func (f *fakeEpisodicStore) ExistsBySourceID(context.Context, string, string, string) (bool, error) { +func (f *fakeEpisodicStore) ExistsBySourceID(_ context.Context, _, userID, _ string) (bool, error) { + f.existsUserID = userID return f.exists, f.existsErr } -func (f *fakeEpisodicStore) GetBySourceID(context.Context, string, string, string) (*store.EpisodicSummary, error) { +func (f *fakeEpisodicStore) GetBySourceID(_ context.Context, _, userID, _ string) (*store.EpisodicSummary, error) { f.getCalls++ + f.getUserID = userID if f.getByErr != nil { return nil, f.getByErr } @@ -621,6 +625,12 @@ func TestApproveUsesConfiguredRetentionHours(t *testing.T) { if len(episodic.created) != 1 { t.Fatalf("created episodic count = %d, want 1", len(episodic.created)) } + if episodic.created[0].UserID != "" { + t.Fatalf("channel episodic user_id = %q, want shared scope", episodic.created[0].UserID) + } + if episodic.created[0].L0Abstract != extractions.items[itemID].Summary { + t.Fatalf("channel episodic L0Abstract = %q, want summary", episodic.created[0].L0Abstract) + } expires := episodic.created[0].ExpiresAt if expires == nil { t.Fatal("episodic ExpiresAt is nil") @@ -667,7 +677,10 @@ func TestApprovePublishesTopicsAndEntitiesForSemanticHints(t *testing.T) { if len(episodic.created) != 1 { t.Fatalf("created episodic count = %d, want 1", len(episodic.created)) } - if got := episodic.created[0].KeyTopics; strings.Join(got, ",") != "collaboration,planning" { + if episodic.existsUserID != "" { + t.Fatalf("ExistsBySourceID user_id = %q, want shared scope", episodic.existsUserID) + } + if got := strings.Join(episodic.created[0].KeyTopics, ","); got != "collaboration,planning,Project Orion,ExampleCo" { t.Fatalf("episodic key_topics = %#v", got) } if len(eventBus.published) != 1 { @@ -677,7 +690,7 @@ func TestApprovePublishesTopicsAndEntitiesForSemanticHints(t *testing.T) { if !ok { t.Fatalf("published payload type = %T", eventBus.published[0].Payload) } - if got := strings.Join(payload.KeyTopics, ","); got != "collaboration,planning" { + if got := strings.Join(payload.KeyTopics, ","); got != "collaboration,planning,Project Orion,ExampleCo" { t.Fatalf("payload KeyTopics = %q", got) } if got := strings.Join(payload.KeyEntities, ","); got != "Project Orion,ExampleCo" { @@ -716,6 +729,9 @@ func TestApproveExistingSourceWritesExistingEpisodicID(t *testing.T) { if episodic.getCalls != 1 { t.Fatalf("GetBySourceID calls = %d, want 1", episodic.getCalls) } + if episodic.getUserID != "" { + t.Fatalf("GetBySourceID user_id = %q, want shared scope", episodic.getUserID) + } if item.EpisodicID != existingID.String() { t.Fatalf("item episodic_id = %q, want %q", item.EpisodicID, existingID) } diff --git a/internal/channelmemory/util.go b/internal/channelmemory/util.go index 5a05f1b9..7e3e3f50 100644 --- a/internal/channelmemory/util.go +++ b/internal/channelmemory/util.go @@ -62,6 +62,33 @@ func decodeStrings(raw json.RawMessage) []string { return out } +func memoryKeyTopics(item *store.ChannelMemoryExtractionItem) []string { + if item == nil { + return nil + } + return mergeTopicLabels(decodeStrings(item.Topics), decodeStrings(item.Entities)) +} + +func mergeTopicLabels(groups ...[]string) []string { + seen := make(map[string]struct{}) + var out []string + for _, group := range groups { + for _, value := range group { + label := strings.TrimSpace(value) + if label == "" { + continue + } + key := strings.ToLower(label) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, label) + } + } + return out +} + //go:fix inline func timePtr(t time.Time) *time.Time { return &t } diff --git a/internal/store/episodic_store.go b/internal/store/episodic_store.go index b60b7886..c7daf18b 100644 --- a/internal/store/episodic_store.go +++ b/internal/store/episodic_store.go @@ -36,6 +36,7 @@ type EpisodicSummary struct { type EpisodicSearchResult struct { EpisodicID string `json:"episodic_id" db:"episodic_id"` L0Abstract string `json:"l0_abstract" db:"l0_abstract"` + KeyTopics []string `json:"key_topics" db:"key_topics"` Score float64 `json:"score" db:"score"` CreatedAt time.Time `json:"created_at" db:"created_at"` SessionKey string `json:"session_key" db:"session_key"` diff --git a/internal/store/pg/episodic_search.go b/internal/store/pg/episodic_search.go index b9de7aa1..06e421db 100644 --- a/internal/store/pg/episodic_search.go +++ b/internal/store/pg/episodic_search.go @@ -19,6 +19,7 @@ type episodicScored struct { id string sessionKey string l0 string + keyTopics []string score float64 createdAt time.Time } @@ -27,7 +28,7 @@ type episodicScored struct { // Uses the stored search_vector column (GIN-indexed, 'english' config from migration 040). // When userID is empty, returns results across all users (admin view). func (s *PGEpisodicStore) ftsSearch(ctx context.Context, query, agentID, userID string, limit int) []episodicScored { - q := `SELECT id, session_key, l0_abstract, + q := `SELECT id, session_key, COALESCE(NULLIF(l0_abstract, ''), left(summary, 500)) AS l0_abstract, key_topics, ts_rank(search_vector, plainto_tsquery('english', $1)) AS score, created_at FROM episodic_summaries WHERE agent_id = $2 @@ -35,10 +36,14 @@ func (s *PGEpisodicStore) ftsSearch(ctx context.Context, query, agentID, userID args := []any{query, agentID} p := 3 - if userID != "" { - q += fmt.Sprintf(" AND user_id = $%d", p) + if store.IsSharedMemory(ctx) { + // Shared memory searches all user scopes for the agent. + } else if userID != "" { + q += fmt.Sprintf(" AND (user_id = $%d OR user_id = '')", p) args = append(args, userID) p++ + } else { + q += " AND user_id = ''" } q += fmt.Sprintf(" AND tenant_id = $%d", p) args = append(args, tenantFromCtx(ctx)) @@ -61,17 +66,22 @@ func (s *PGEpisodicStore) ftsSearch(ctx context.Context, query, agentID, userID // When userID is empty, returns results across all users (admin view). func (s *PGEpisodicStore) vectorSearch(ctx context.Context, embedding []float32, agentID, userID string, limit int) []episodicScored { vecStr := vectorToString(embedding) - q := `SELECT id, session_key, l0_abstract, 1 - (embedding <=> $1) AS score, created_at + q := `SELECT id, session_key, COALESCE(NULLIF(l0_abstract, ''), left(summary, 500)) AS l0_abstract, + key_topics, 1 - (embedding <=> $1) AS score, created_at FROM episodic_summaries WHERE agent_id = $2 AND embedding IS NOT NULL` args := []any{vecStr, agentID} p := 3 - if userID != "" { - q += fmt.Sprintf(" AND user_id = $%d", p) + if store.IsSharedMemory(ctx) { + // Shared memory searches all user scopes for the agent. + } else if userID != "" { + q += fmt.Sprintf(" AND (user_id = $%d OR user_id = '')", p) args = append(args, userID) p++ + } else { + q += " AND user_id = ''" } q += fmt.Sprintf(" AND tenant_id = $%d", p) args = append(args, tenantFromCtx(ctx)) @@ -94,13 +104,13 @@ func (s *PGEpisodicStore) vectorSearch(ctx context.Context, embedding []float32, func mergeEpisodicScores(fts, vec []episodicScored, textWeight, vecWeight float64) []episodicScored { byID := make(map[string]*episodicScored) for _, r := range fts { - byID[r.id] = &episodicScored{id: r.id, sessionKey: r.sessionKey, l0: r.l0, createdAt: r.createdAt, score: r.score * textWeight} + byID[r.id] = &episodicScored{id: r.id, sessionKey: r.sessionKey, l0: r.l0, keyTopics: r.keyTopics, createdAt: r.createdAt, score: r.score * textWeight} } for _, r := range vec { if existing, ok := byID[r.id]; ok { existing.score += r.score * vecWeight } else { - byID[r.id] = &episodicScored{id: r.id, sessionKey: r.sessionKey, l0: r.l0, createdAt: r.createdAt, score: r.score * vecWeight} + byID[r.id] = &episodicScored{id: r.id, sessionKey: r.sessionKey, l0: r.l0, keyTopics: r.keyTopics, createdAt: r.createdAt, score: r.score * vecWeight} } } var merged []episodicScored diff --git a/internal/store/pg/episodic_summaries.go b/internal/store/pg/episodic_summaries.go index 307b9a38..0f1c3b5d 100644 --- a/internal/store/pg/episodic_summaries.go +++ b/internal/store/pg/episodic_summaries.go @@ -31,6 +31,9 @@ func (s *PGEpisodicStore) Close() error { retu func (s *PGEpisodicStore) Create(ctx context.Context, ep *store.EpisodicSummary) error { id := uuid.Must(uuid.NewV7()) ep.ID = id + if ep.L0Abstract == "" { + ep.L0Abstract = fallbackEpisodicL0(ep.Summary) + } topics := pq.Array(ep.KeyTopics) now := time.Now().UTC() @@ -63,6 +66,15 @@ func (s *PGEpisodicStore) Create(ctx context.Context, ep *store.EpisodicSummary) return nil } +func fallbackEpisodicL0(summary string) string { + const maxRunes = 500 + runes := []rune(summary) + if len(runes) <= maxRunes { + return summary + } + return string(runes[:maxRunes]) +} + // Get retrieves an episodic summary by ID. func (s *PGEpisodicStore) Get(ctx context.Context, id string) (*store.EpisodicSummary, error) { row := s.db.QueryRowContext(ctx, ` @@ -163,7 +175,7 @@ func (s *PGEpisodicStore) Search(ctx context.Context, query, agentID, userID str continue } results = append(results, store.EpisodicSearchResult{ - EpisodicID: m.id, L0Abstract: m.l0, Score: m.score, + EpisodicID: m.id, L0Abstract: m.l0, KeyTopics: m.keyTopics, Score: m.score, CreatedAt: m.createdAt, SessionKey: m.sessionKey, }) } diff --git a/internal/store/pg/memory_scan_rows.go b/internal/store/pg/memory_scan_rows.go index 80f0ccfa..5bb8886d 100644 --- a/internal/store/pg/memory_scan_rows.go +++ b/internal/store/pg/memory_scan_rows.go @@ -146,11 +146,12 @@ func (r *episodicSummaryRow) toEpisodicSummary() store.EpisodicSummary { // episodicScoredRow is an sqlx scan struct for ftsSearch/vectorSearch in episodic_search.go. type episodicScoredRow struct { - ID string `db:"id"` - SessionKey string `db:"session_key"` - L0Abstract string `db:"l0_abstract"` - Score float64 `db:"score"` - CreatedAt time.Time `db:"created_at"` + ID string `db:"id"` + SessionKey string `db:"session_key"` + L0Abstract string `db:"l0_abstract"` + KeyTopics pq.StringArray `db:"key_topics"` + Score float64 `db:"score"` + CreatedAt time.Time `db:"created_at"` } func (r *episodicScoredRow) toEpisodicScored() episodicScored { @@ -158,6 +159,7 @@ func (r *episodicScoredRow) toEpisodicScored() episodicScored { id: r.ID, sessionKey: r.SessionKey, l0: r.L0Abstract, + keyTopics: []string(r.KeyTopics), score: r.Score, createdAt: r.CreatedAt, } diff --git a/internal/store/pg/scan_rows_test.go b/internal/store/pg/scan_rows_test.go index cc47ee56..0f468bef 100644 --- a/internal/store/pg/scan_rows_test.go +++ b/internal/store/pg/scan_rows_test.go @@ -2,6 +2,7 @@ package pg import ( "encoding/json" + "strings" "testing" "time" @@ -195,12 +196,15 @@ func TestEpisodicSummaryRow_ToEpisodicSummary(t *testing.T) { func TestEpisodicScoredRow_ToEpisodicScored(t *testing.T) { created := time.Now() r := episodicScoredRow{ - ID: "ep-1", SessionKey: "s-1", L0Abstract: "abs", Score: 0.5, CreatedAt: created, + ID: "ep-1", SessionKey: "s-1", L0Abstract: "abs", KeyTopics: pq.StringArray{"topic-a", "Entity B"}, Score: 0.5, CreatedAt: created, } got := r.toEpisodicScored() if got.id != "ep-1" || got.sessionKey != "s-1" || got.l0 != "abs" || got.score != 0.5 { t.Errorf("%+v", got) } + if strings.Join(got.keyTopics, ",") != "topic-a,Entity B" { + t.Errorf("keyTopics = %v", got.keyTopics) + } if !got.createdAt.Equal(created) { t.Errorf("createdAt mismatch") } @@ -722,12 +726,12 @@ func TestCustomSkillExportRow_EmptyOptionals(t *testing.T) { func TestMergeEpisodicScores_WeightingAndMerge(t *testing.T) { fts := []episodicScored{ - {id: "a", sessionKey: "s-a", l0: "A", score: 1.0}, - {id: "b", sessionKey: "s-b", l0: "B", score: 0.5}, + {id: "a", sessionKey: "s-a", l0: "A", keyTopics: []string{"topic-a"}, score: 1.0}, + {id: "b", sessionKey: "s-b", l0: "B", keyTopics: []string{"topic-b"}, score: 0.5}, } vec := []episodicScored{ - {id: "a", sessionKey: "s-a", l0: "A", score: 0.8}, - {id: "c", sessionKey: "s-c", l0: "C", score: 0.9}, + {id: "a", sessionKey: "s-a", l0: "A", keyTopics: []string{"topic-a"}, score: 0.8}, + {id: "c", sessionKey: "s-c", l0: "C", keyTopics: []string{"topic-c"}, score: 0.9}, } merged := mergeEpisodicScores(fts, vec, 0.5, 0.5) if len(merged) != 3 { @@ -737,6 +741,13 @@ func TestMergeEpisodicScores_WeightingAndMerge(t *testing.T) { for _, r := range merged { byID[r.id] = r.score } + byTopics := make(map[string]string) + for _, r := range merged { + byTopics[r.id] = strings.Join(r.keyTopics, ",") + } + if byTopics["a"] != "topic-a" || byTopics["c"] != "topic-c" { + t.Errorf("topics not preserved: %v", byTopics) + } // a: in both → 1.0*0.5 + 0.8*0.5 = 0.5 + 0.4 = 0.9 if got := byID["a"]; got < 0.89 || got > 0.91 { t.Errorf("a score = %v, want ~0.9", got) diff --git a/internal/store/sqlitestore/episodic.go b/internal/store/sqlitestore/episodic.go index c08b497a..a460c7e0 100644 --- a/internal/store/sqlitestore/episodic.go +++ b/internal/store/sqlitestore/episodic.go @@ -34,6 +34,9 @@ func (s *SQLiteEpisodicStore) Close() error { return nil } func (s *SQLiteEpisodicStore) Create(ctx context.Context, ep *store.EpisodicSummary) error { id := uuid.Must(uuid.NewV7()) ep.ID = id + if ep.L0Abstract == "" { + ep.L0Abstract = fallbackEpisodicL0(ep.Summary) + } now := time.Now().UTC() topics := jsonStringArray(ep.KeyTopics) @@ -63,6 +66,15 @@ func (s *SQLiteEpisodicStore) Create(ctx context.Context, ep *store.EpisodicSumm return nil } +func fallbackEpisodicL0(summary string) string { + const maxRunes = 500 + runes := []rune(summary) + if len(runes) <= maxRunes { + return summary + } + return string(runes[:maxRunes]) +} + // Get retrieves an episodic summary by ID. func (s *SQLiteEpisodicStore) Get(ctx context.Context, id string) (*store.EpisodicSummary, error) { tenantID := tenantIDForInsert(ctx) diff --git a/internal/store/sqlitestore/episodic_search.go b/internal/store/sqlitestore/episodic_search.go index 7c08573c..631d1594 100644 --- a/internal/store/sqlitestore/episodic_search.go +++ b/internal/store/sqlitestore/episodic_search.go @@ -27,14 +27,24 @@ func (s *SQLiteEpisodicStore) Search(ctx context.Context, query string, agentID, tenantID := tenantIDForInsert(ctx) pattern := "%" + escapeLike(query) + "%" - rows, err := s.db.QueryContext(ctx, ` - SELECT id, l0_abstract, key_topics, created_at, session_key + q := ` + SELECT id, COALESCE(NULLIF(l0_abstract, ''), substr(summary, 1, 500)) AS l0_abstract, key_topics, created_at, session_key FROM episodic_summaries - WHERE agent_id = ? AND user_id = ? AND tenant_id = ? - AND (summary LIKE ? ESCAPE '\' OR key_topics LIKE ? ESCAPE '\') - ORDER BY created_at DESC - LIMIT ?`, - agentID, userID, tenantID.String(), pattern, pattern, maxResults*3) + WHERE agent_id = ? AND tenant_id = ? + AND (summary LIKE ? ESCAPE '\' OR key_topics LIKE ? ESCAPE '\')` + args := []any{agentID, tenantID.String(), pattern, pattern} + if store.IsSharedMemory(ctx) { + // Shared memory searches all user scopes for the agent. + } else if userID != "" { + q += " AND (user_id = ? OR user_id = '')" + args = append(args, userID) + } else { + q += " AND user_id = ''" + } + q += " ORDER BY created_at DESC LIMIT ?" + args = append(args, maxResults*3) + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -94,6 +104,7 @@ func (s *SQLiteEpisodicStore) Search(ctx context.Context, query string, agentID, results = append(results, store.EpisodicSearchResult{ EpisodicID: sr.raw.id, L0Abstract: sr.raw.l0Abstract, + KeyTopics: searchKeyTopics(sr.raw.keyTopics), Score: sr.score, CreatedAt: sr.raw.createdAt.Time, SessionKey: sr.raw.sessionKey, @@ -105,5 +116,11 @@ func (s *SQLiteEpisodicStore) Search(ctx context.Context, query string, agentID, return results, nil } +func searchKeyTopics(raw string) []string { + var topics []string + scanJSONStringArray([]byte(raw), &topics) + return topics +} + // Ensure SQLiteEpisodicStore implements store.EpisodicStore. var _ store.EpisodicStore = (*SQLiteEpisodicStore)(nil) diff --git a/internal/tools/memory.go b/internal/tools/memory.go index 2b8aefe1..1968755c 100644 --- a/internal/tools/memory.go +++ b/internal/tools/memory.go @@ -15,10 +15,10 @@ import ( // MemorySearchTool implements the memory_search tool for hybrid semantic + FTS search. type MemorySearchTool struct { - memStore store.MemoryStore // Postgres-backed - episodicStore store.EpisodicStore // v3 episodic memory (nil = v2 fallback) - metricsStore store.EvolutionMetricsStore // evolution metrics (nil = disabled) - hasKG bool // knowledge_graph_search tool is available + memStore store.MemoryStore // Postgres-backed + episodicStore store.EpisodicStore // v3 episodic memory (nil = v2 fallback) + metricsStore store.EvolutionMetricsStore // evolution metrics (nil = disabled) + hasKG bool // knowledge_graph_search tool is available } func NewMemorySearchTool() *MemorySearchTool { @@ -150,8 +150,9 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]any) *Re type taggedResult struct { Tier string `json:"tier"` store.MemorySearchResult - L0 string `json:"l0_abstract,omitempty"` - EpisodicID string `json:"episodic_id,omitempty"` + L0 string `json:"l0_abstract,omitempty"` + KeyTopics []string `json:"key_topics,omitempty"` + EpisodicID string `json:"episodic_id,omitempty"` } var combined []taggedResult for _, r := range results { @@ -159,7 +160,7 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]any) *Re } for _, r := range episodicResults { combined = append(combined, taggedResult{ - Tier: "episodic", EpisodicID: r.EpisodicID, L0: r.L0Abstract, + Tier: "episodic", EpisodicID: r.EpisodicID, L0: r.L0Abstract, KeyTopics: r.KeyTopics, MemorySearchResult: store.MemorySearchResult{ Path: "episodic:" + r.SessionKey, Score: r.Score, Snippet: r.L0Abstract, Source: "episodic", }, diff --git a/internal/tools/memory_expand.go b/internal/tools/memory_expand.go index ffe13802..af664118 100644 --- a/internal/tools/memory_expand.go +++ b/internal/tools/memory_expand.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "strings" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -23,8 +24,8 @@ func (t *MemoryExpandTool) SetEpisodicStore(es store.EpisodicStore) { t.episodicStore = es } -func (t *MemoryExpandTool) Name() string { return "memory_expand" } -func (t *MemoryExpandTool) Description() string { +func (t *MemoryExpandTool) Name() string { return "memory_expand" } +func (t *MemoryExpandTool) Description() string { return "Load full content for a memory entry by ID. Returns the complete episodic summary for deep context." } @@ -61,9 +62,12 @@ func (t *MemoryExpandTool) Execute(ctx context.Context, args map[string]any) *Re } // Format full summary with metadata - result := fmt.Sprintf("## Memory: %s\n\n**Session:** %s\n**Created:** %s\n**Turns:** %d\n\n%s", - ep.L0Abstract, ep.SessionKey, ep.CreatedAt.Format("2006-01-02 15:04"), - ep.TurnCount, ep.Summary) + result := fmt.Sprintf("## Memory: %s\n\n**Session:** %s\n**Created:** %s\n**Turns:** %d", + ep.L0Abstract, ep.SessionKey, ep.CreatedAt.Format("2006-01-02 15:04"), ep.TurnCount) + if len(ep.KeyTopics) > 0 { + result += "\n**Topics:** " + strings.Join(ep.KeyTopics, ", ") + } + result += "\n\n" + ep.Summary return &Result{ForLLM: result} } diff --git a/internal/tools/memory_search_topics_test.go b/internal/tools/memory_search_topics_test.go new file mode 100644 index 00000000..75d76635 --- /dev/null +++ b/internal/tools/memory_search_topics_test.go @@ -0,0 +1,101 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type memorySearchFakeMemoryStore struct { + store.MemoryStore + results []store.MemorySearchResult +} + +func (f *memorySearchFakeMemoryStore) Search(context.Context, string, string, string, store.MemorySearchOptions) ([]store.MemorySearchResult, error) { + return f.results, nil +} + +type memorySearchFakeEpisodicStore struct { + store.EpisodicStore + results []store.EpisodicSearchResult + ep *store.EpisodicSummary +} + +func (f *memorySearchFakeEpisodicStore) Search(context.Context, string, string, string, store.EpisodicSearchOptions) ([]store.EpisodicSearchResult, error) { + return f.results, nil +} + +func (f *memorySearchFakeEpisodicStore) Get(context.Context, string) (*store.EpisodicSummary, error) { + return f.ep, nil +} + +func (f *memorySearchFakeEpisodicStore) RecordRecall(context.Context, string, float64) error { + return nil +} + +func TestMemorySearchIncludesEpisodicKeyTopics(t *testing.T) { + tool := NewMemorySearchTool() + tool.SetMemoryStore(&memorySearchFakeMemoryStore{}) + tool.SetEpisodicStore(&memorySearchFakeEpisodicStore{results: []store.EpisodicSearchResult{ + { + EpisodicID: "ep-1", + L0Abstract: "BUV website Workshop 5 typography contrast.", + KeyTopics: []string{"typography", "BUV", "Workshop 5"}, + Score: 0.8, + CreatedAt: time.Now(), + SessionKey: "channel:design", + }, + }}) + + ctx := store.WithUserID(store.WithAgentID(context.Background(), uuid.New()), "user-1") + res := tool.Execute(ctx, map[string]any{"query": "BUV typography"}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + + var output struct { + Results []struct { + Tier string `json:"tier"` + EpisodicID string `json:"episodic_id"` + KeyTopics []string `json:"key_topics"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(res.ForLLM), &output); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, res.ForLLM) + } + if len(output.Results) != 1 { + t.Fatalf("results = %d, want 1: %s", len(output.Results), res.ForLLM) + } + got := output.Results[0] + if got.Tier != "episodic" || got.EpisodicID != "ep-1" { + t.Fatalf("episodic result metadata = %+v", got) + } + if strings.Join(got.KeyTopics, ",") != "typography,BUV,Workshop 5" { + t.Fatalf("key_topics = %#v", got.KeyTopics) + } +} + +func TestMemoryExpandIncludesEpisodicKeyTopics(t *testing.T) { + tool := NewMemoryExpandTool() + tool.SetEpisodicStore(&memorySearchFakeEpisodicStore{ep: &store.EpisodicSummary{ + L0Abstract: "BUV website Workshop 5.", + SessionKey: "channel:design", + CreatedAt: time.Date(2026, 7, 9, 14, 55, 0, 0, time.UTC), + KeyTopics: []string{"typography", "BUV", "Workshop 5"}, + Summary: "Typography weight contrast is a focus area.", + }}) + + res := tool.Execute(context.Background(), map[string]any{"id": "ep-1"}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "**Topics:** typography, BUV, Workshop 5") { + t.Fatalf("topics missing from expanded memory: %s", res.ForLLM) + } +} diff --git a/tests/integration/v3_episodic_store_test.go b/tests/integration/v3_episodic_store_test.go index 2e773248..422946f8 100644 --- a/tests/integration/v3_episodic_store_test.go +++ b/tests/integration/v3_episodic_store_test.go @@ -210,6 +210,48 @@ func TestStoreEpisodic_FTSSearch(t *testing.T) { } } +func TestStoreEpisodic_SearchSharedBlankL0FallsBackToSummary(t *testing.T) { + db := testDB(t) + tenantID, agentID := seedTenantAgent(t, db) + ctx := tenantCtx(tenantID) + s := newEpisodicStore(t) + + summary := "BUV website Workshop 5 Brand Style Test focuses on typography weight contrast" + ep := &store.EpisodicSummary{ + TenantID: tenantID, + AgentID: agentID, + UserID: "", + SessionKey: "channel:discord", + Summary: summary, + KeyTopics: []string{"BUV-website", "workshop-5", "typography"}, + SourceType: "channel", + SourceID: "channel-blank-l0-" + tenantID.String()[:8], + } + if err := s.Create(ctx, ep); err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := db.ExecContext(ctx, `UPDATE episodic_summaries SET l0_abstract = '' WHERE id = $1`, ep.ID); err != nil { + t.Fatalf("force blank l0: %v", err) + } + + results, err := s.Search(ctx, "BUV website typography", agentID.String(), "guild:discord:user:nam", store.EpisodicSearchOptions{ + MaxResults: 10, + }) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) == 0 { + t.Fatal("Search returned 0 results, expected shared channel memory") + } + if results[0].L0Abstract == "" { + t.Fatal("Search returned blank L0Abstract, want summary fallback") + } + if results[0].L0Abstract != summary { + t.Fatalf("Search L0Abstract = %q, want summary fallback", results[0].L0Abstract) + } +} + func TestStoreEpisodic_TenantIsolation(t *testing.T) { db := testDB(t) tenantA, agentA := seedTenantAgent(t, db)