Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions docs/content/building-gormes/architecture_plan/progress.json
Original file line number Diff line number Diff line change
Expand Up @@ -1534,9 +1534,9 @@
},
{
"name": "Lineage-aware source-filtered search hits",
"status": "planned",
"status": "complete",
"contract": "Session and message search can surface parent/child lineage context for matched sessions without widening the same-chat default recall fence",
"contract_status": "draft",
"contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
Expand All @@ -1551,9 +1551,6 @@
"internal/memory/session_catalog.go",
"docs/content/building-gormes/architecture_plan/phase-3-memory.md"
],
"blocked_by": [
"parent_session_id lineage for compression splits"
],
"ready_when": [
"Session lineage metadata is persisted and can be queried from the session read model."
],
Expand All @@ -1568,7 +1565,7 @@
"Same-chat default recall remains unchanged unless scope=user or source filters explicitly widen it.",
"Orphaned or incomplete chains produce explicit evidence fields instead of dropped results."
],
"note": "TDD: once parent_session_id exists, make SearchMessages/SearchSessions expose parent/child lineage context for matched sessions without changing default same-chat recall behavior.",
"note": "Complete: TDD landed internal/memory/session_lineage_search_test.go. SearchMessages and SearchSessions now attach SearchLineage evidence with parent_session_id, lineage_kind, child_session_ids, and explicit ok/orphan/unavailable status without changing the same-chat default recall fence.",
"write_scope": [
"internal/memory/",
"internal/session/",
Expand Down
100 changes: 100 additions & 0 deletions internal/memory/session_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ type SearchFilter struct {
Query string
}

// SearchLineageStatusUnavailable means the hit matched through a chat key but
// there was no session-specific metadata row to prove a lineage chain.
const SearchLineageStatusUnavailable = "unavailable"

// SearchLineage is the lineage evidence attached to one matched session.
type SearchLineage struct {
ParentSessionID string
LineageKind string
ChildSessionIDs []string
Status string
}

// MessageSearchHit is one turn-level result from the session catalog.
type MessageSearchHit struct {
SessionID string
Expand All @@ -26,6 +38,7 @@ type MessageSearchHit struct {
Role string
Content string
TSUnix int64
Lineage SearchLineage
}

// SessionSearchHit is one session-level result ordered by latest matching turn.
Expand All @@ -34,6 +47,7 @@ type SessionSearchHit struct {
ChatID string
Source string
LatestTurnUnix int64
Lineage SearchLineage
}

// SearchMessages returns matching turns across the canonical sessions bound to
Expand All @@ -45,6 +59,7 @@ func SearchMessages(ctx context.Context, db *sql.DB, metas []session.Metadata, f
}

sessionIDs, chatKeys, metaBySession, metaByChat := metadataIndexes(selected)
lineage := buildSearchLineageIndex(selected)
query, args := buildTurnSearchQuery(filter.Query, sessionIDs, chatKeys, limit, false)
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
Expand All @@ -63,6 +78,7 @@ func SearchMessages(ctx context.Context, db *sql.DB, metas []session.Metadata, f
} else if meta, ok := metaByChat[hit.ChatID]; ok {
hit.Source = meta.Source
}
hit.Lineage = lineage.contextFor(hit.SessionID)
hits = append(hits, hit)
}
if err := rows.Err(); err != nil {
Expand All @@ -79,6 +95,7 @@ func SearchSessions(ctx context.Context, db *sql.DB, metas []session.Metadata, f
}

sessionIDs, chatKeys, metaBySession, metaByChat := metadataIndexes(selected)
lineage := buildSearchLineageIndex(selected)
query, args := buildTurnSearchQuery(filter.Query, sessionIDs, chatKeys, limit, true)
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
Expand All @@ -97,6 +114,7 @@ func SearchSessions(ctx context.Context, db *sql.DB, metas []session.Metadata, f
} else if meta, ok := metaByChat[hit.ChatID]; ok {
hit.Source = meta.Source
}
hit.Lineage = lineage.contextFor(hit.SessionID)
hits = append(hits, hit)
}
if err := rows.Err(); err != nil {
Expand Down Expand Up @@ -166,6 +184,88 @@ func canonicalChatKey(meta session.Metadata) string {
return source + ":" + chatID
}

type searchLineageIndex struct {
bySession map[string]session.Metadata
children map[string][]string
}

func buildSearchLineageIndex(metas []session.Metadata) searchLineageIndex {
idx := searchLineageIndex{
bySession: make(map[string]session.Metadata, len(metas)),
children: make(map[string][]string, len(metas)),
}
for _, meta := range metas {
meta = normalizeSearchLineageMetadata(meta)
if meta.SessionID == "" {
continue
}
idx.bySession[meta.SessionID] = meta
}
for _, meta := range idx.bySession {
if meta.ParentSessionID == "" {
continue
}
childIDs := idx.children[meta.ParentSessionID]
if !slices.Contains(childIDs, meta.SessionID) {
idx.children[meta.ParentSessionID] = append(childIDs, meta.SessionID)
}
}
for parentID := range idx.children {
slices.Sort(idx.children[parentID])
}
return idx
}

func normalizeSearchLineageMetadata(meta session.Metadata) session.Metadata {
meta.SessionID = strings.TrimSpace(meta.SessionID)
meta.ParentSessionID = strings.TrimSpace(meta.ParentSessionID)
meta.LineageKind = strings.ToLower(strings.TrimSpace(meta.LineageKind))
return meta
}

func (idx searchLineageIndex) contextFor(sessionID string) SearchLineage {
sessionID = strings.TrimSpace(sessionID)
meta, ok := idx.bySession[sessionID]
if !ok {
return SearchLineage{Status: SearchLineageStatusUnavailable}
}
children := append([]string(nil), idx.children[sessionID]...)
return SearchLineage{
ParentSessionID: meta.ParentSessionID,
LineageKind: searchLineageKind(meta),
ChildSessionIDs: children,
Status: idx.statusFor(sessionID),
}
}

func searchLineageKind(meta session.Metadata) string {
if meta.LineageKind == "" {
return session.LineageKindPrimary
}
return meta.LineageKind
}

func (idx searchLineageIndex) statusFor(sessionID string) string {
meta, ok := idx.bySession[sessionID]
if !ok {
return SearchLineageStatusUnavailable
}
seen := map[string]struct{}{sessionID: {}}
for current := meta.ParentSessionID; current != ""; {
if _, ok := seen[current]; ok {
return session.LineageStatusLoop
}
seen[current] = struct{}{}

parent, ok := idx.bySession[current]
if !ok {
return session.LineageStatusOrphan
}
current = parent.ParentSessionID
}
return session.LineageStatusOK
}

func buildTurnSearchQuery(rawQuery string, sessionIDs, chatKeys []string, limit int, sessionsOnly bool) (string, []any) {
var b strings.Builder
args := make([]any, 0, len(sessionIDs)+len(chatKeys)+2)
Expand Down
200 changes: 200 additions & 0 deletions internal/memory/session_lineage_search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package memory

import (
"context"
"testing"

"github.com/TrebuchetDynamics/gormes-agent/internal/session"
)

func TestSessionCatalog_SearchHitsIncludeLineageContext(t *testing.T) {
store, err := OpenSqlite(t.TempDir()+"/memory.db", 0, nil)
if err != nil {
t.Fatalf("OpenSqlite: %v", err)
}
defer func() {
if err := store.Close(context.Background()); err != nil {
t.Fatalf("Close: %v", err)
}
}()

ctx := context.Background()
for _, turn := range []struct {
sessionID string
chatID string
content string
ts int64
}{
{
sessionID: "sess-root",
chatID: "telegram:42",
content: "Atlas root evidence",
ts: 100,
},
{
sessionID: "sess-child",
chatID: "telegram:42",
content: "Atlas compressed continuation",
ts: 200,
},
{
sessionID: "sess-orphan",
chatID: "telegram:42",
content: "Atlas orphaned branch",
ts: 300,
},
{
sessionID: "sess-chat-only",
chatID: "telegram:42",
content: "Atlas legacy row matched only by chat key",
ts: 400,
},
} {
if _, err := store.DB().ExecContext(ctx,
`INSERT INTO turns(session_id, role, content, ts_unix, chat_id) VALUES (?, ?, ?, ?, ?)`,
turn.sessionID, "user", turn.content, turn.ts, turn.chatID,
); err != nil {
t.Fatalf("insert turn %s: %v", turn.sessionID, err)
}
}

metas := []session.Metadata{
{SessionID: "sess-root", Source: "telegram", ChatID: "42", UserID: "user-juan"},
{
SessionID: "sess-child",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
ParentSessionID: "sess-root",
LineageKind: session.LineageKindCompression,
},
{
SessionID: "sess-orphan",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
ParentSessionID: "sess-missing",
LineageKind: session.LineageKindFork,
},
}

messages, err := SearchMessages(ctx, store.DB(), metas, SearchFilter{
UserID: "user-juan",
Sources: []string{"telegram"},
Query: "Atlas",
}, 10)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(messages) != 4 {
t.Fatalf("SearchMessages len = %d, want 4", len(messages))
}
messageBySession := make(map[string]MessageSearchHit, len(messages))
for _, hit := range messages {
messageBySession[hit.SessionID] = hit
}
assertSearchLineage(t, "message root", messageBySession["sess-root"].Lineage, SearchLineage{
LineageKind: session.LineageKindPrimary,
ChildSessionIDs: []string{"sess-child"},
Status: session.LineageStatusOK,
})
assertSearchLineage(t, "message child", messageBySession["sess-child"].Lineage, SearchLineage{
ParentSessionID: "sess-root",
LineageKind: session.LineageKindCompression,
Status: session.LineageStatusOK,
})
assertSearchLineage(t, "message orphan", messageBySession["sess-orphan"].Lineage, SearchLineage{
ParentSessionID: "sess-missing",
LineageKind: session.LineageKindFork,
Status: session.LineageStatusOrphan,
})
assertSearchLineage(t, "message chat-only", messageBySession["sess-chat-only"].Lineage, SearchLineage{
Status: SearchLineageStatusUnavailable,
})

sessions, err := SearchSessions(ctx, store.DB(), metas, SearchFilter{
UserID: "user-juan",
Sources: []string{"telegram"},
Query: "Atlas",
}, 10)
if err != nil {
t.Fatalf("SearchSessions: %v", err)
}
if len(sessions) != 4 {
t.Fatalf("SearchSessions len = %d, want 4", len(sessions))
}
sessionByID := make(map[string]SessionSearchHit, len(sessions))
for _, hit := range sessions {
sessionByID[hit.SessionID] = hit
}
assertSearchLineage(t, "session root", sessionByID["sess-root"].Lineage, SearchLineage{
LineageKind: session.LineageKindPrimary,
ChildSessionIDs: []string{"sess-child"},
Status: session.LineageStatusOK,
})
assertSearchLineage(t, "session child", sessionByID["sess-child"].Lineage, SearchLineage{
ParentSessionID: "sess-root",
LineageKind: session.LineageKindCompression,
Status: session.LineageStatusOK,
})
assertSearchLineage(t, "session orphan", sessionByID["sess-orphan"].Lineage, SearchLineage{
ParentSessionID: "sess-missing",
LineageKind: session.LineageKindFork,
Status: session.LineageStatusOrphan,
})
assertSearchLineage(t, "session chat-only", sessionByID["sess-chat-only"].Lineage, SearchLineage{
Status: SearchLineageStatusUnavailable,
})
}

func TestSessionCatalog_LineageMetadataDoesNotWidenDefaultRecall(t *testing.T) {
_, p := openProviderWithRichGraph(t)
dir := session.NewMemMap()
ctx := context.Background()
if err := dir.PutMetadata(ctx, session.Metadata{
SessionID: "s",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
}); err != nil {
t.Fatalf("PutMetadata root: %v", err)
}
if err := dir.PutMetadata(ctx, session.Metadata{
SessionID: "s-compressed",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
ParentSessionID: "s",
LineageKind: session.LineageKindCompression,
}); err != nil {
t.Fatalf("PutMetadata child: %v", err)
}

p = p.WithDirectory(dir)
out := p.GetContext(ctx, RecallInput{
UserMessage: "Acme progress?",
ChatKey: "discord:7",
UserID: "user-juan",
})
if out != "" {
t.Fatalf("default same-chat recall widened through lineage metadata; got %q", out)
}
}

func assertSearchLineage(t *testing.T, label string, got, want SearchLineage) {
t.Helper()
if got.ParentSessionID != want.ParentSessionID ||
got.LineageKind != want.LineageKind ||
got.Status != want.Status {
t.Fatalf("%s lineage = %+v, want parent %q kind %q status %q",
label, got, want.ParentSessionID, want.LineageKind, want.Status)
}
if len(got.ChildSessionIDs) != len(want.ChildSessionIDs) {
t.Fatalf("%s children = %v, want %v", label, got.ChildSessionIDs, want.ChildSessionIDs)
}
for i := range want.ChildSessionIDs {
if got.ChildSessionIDs[i] != want.ChildSessionIDs[i] {
t.Fatalf("%s children = %v, want %v", label, got.ChildSessionIDs, want.ChildSessionIDs)
}
}
}