package treepath import ( "database/sql" "encoding/json" "fmt" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Tree Types ────────────────────────────── // PathMessage represents a single message in an active path, enriched // with sibling metadata for the frontend branch indicator. type PathMessage struct { ID string `json:"id"` ParentID *string `json:"parent_id"` Role string `json:"role"` Content string `json:"content"` Model *string `json:"model"` TokensUsed *int `json:"tokens_used,omitempty"` ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` Metadata *json.RawMessage `json:"metadata,omitempty"` SiblingCount int `json:"sibling_count"` SiblingIndex int `json:"sibling_index"` ParticipantType string `json:"participant_type,omitempty"` ParticipantID string `json:"participant_id,omitempty"` CreatedAt string `json:"created_at"` } // SiblingInfo is a lightweight entry for the siblings listing endpoint. type SiblingInfo struct { ID string `json:"id"` Role string `json:"role"` Model *string `json:"model,omitempty"` SiblingIndex int `json:"sibling_index"` Preview string `json:"preview"` // first ~80 chars of content CreatedAt string `json:"created_at"` } // ── Get Active Leaf ───────────────────────── // GetActiveLeaf returns the cursor's active_leaf_id for a user in a channel. // Falls back to the chronologically latest live message if no cursor exists. func GetActiveLeaf(channelID, userID string) (*string, error) { var leafID *string // Try cursor first err := database.DB.QueryRow(` SELECT active_leaf_id FROM channel_cursors WHERE channel_id = $1 AND user_id = $2 `, channelID, userID).Scan(&leafID) if err == nil && leafID != nil { // Verify the leaf still exists and isn't deleted var exists bool database.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) `, *leafID).Scan(&exists) if exists { return leafID, nil } } // Fallback: latest live message in channel var fallbackID string err = database.DB.QueryRow(` SELECT id FROM messages WHERE channel_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `, channelID).Scan(&fallbackID) if err == sql.ErrNoRows { return nil, nil // empty channel } if err != nil { return nil, err } return &fallbackID, nil } // ── Get Active Path ───────────────────────── // GetActivePath walks from the active leaf up to the root via parent_id, // returning messages in root-first order. This is what gets sent to the LLM. func GetActivePath(channelID, userID string) ([]PathMessage, error) { leafID, err := GetActiveLeaf(channelID, userID) if err != nil { return nil, err } if leafID == nil { return []PathMessage{}, nil // empty channel } return GetPathToLeaf(channelID, *leafID) } // GetPathToLeaf walks from a specific leaf up to root, returning root-first. func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) { rows, err := database.DB.Query(` WITH RECURSIVE path AS ( -- Anchor: start at the leaf SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at, 0 AS depth FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL UNION ALL -- Walk up via parent_id SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, m.participant_type, m.participant_id, m.sibling_index, m.created_at, p.depth + 1 FROM messages m JOIN path p ON m.id = p.parent_id WHERE m.deleted_at IS NULL ) SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at FROM path ORDER BY depth DESC `, leafID, channelID) if err != nil { return nil, fmt.Errorf("GetPathToLeaf: %w", err) } defer rows.Close() var path []PathMessage for rows.Next() { var m PathMessage var participantType, participantID sql.NullString var toolCallsJSON, metadataJSON []byte if err := rows.Scan( &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &toolCallsJSON, &metadataJSON, &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, ); err != nil { return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) } if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { raw := json.RawMessage(toolCallsJSON) m.ToolCalls = &raw } if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { raw := json.RawMessage(metadataJSON) m.Metadata = &raw } if participantType.Valid { m.ParticipantType = participantType.String } if participantID.Valid { m.ParticipantID = participantID.String } path = append(path, m) } // Enrich with sibling counts for i := range path { path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID) } return path, nil }