Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -1,53 +0,0 @@
package treepath
import (
"database/sql"
"github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// UpdateCursor upserts the channel_cursors row for a user.
func UpdateCursor(channelID, userID, messageID string) error {
if database.IsSQLite() {
// SQLite: id TEXT PRIMARY KEY has no default generator; supply one.
// Use excluded.active_leaf_id so ON CONFLICT works cleanly.
_, err := database.DB.Exec(`
INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (channel_id, user_id) DO UPDATE SET
active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now')
`, uuid.New().String(), channelID, userID, messageID)
return err
}
// Postgres: id has DEFAULT gen_random_uuid(); $3 is referenced twice.
_, err := database.DB.Exec(`
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id, user_id) DO UPDATE SET
active_leaf_id = $3, updated_at = NOW()
`, channelID, userID, messageID)
return err
}
// NextSiblingIndex returns the next sibling_index for a new child of parentID.
// For root messages (parentID is nil), counts existing roots in the channel.
func NextSiblingIndex(channelID string, parentID *string) int {
var maxIdx sql.NullInt64
if parentID == nil {
database.DB.QueryRow(database.Q(`
SELECT MAX(sibling_index) FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
`), channelID).Scan(&maxIdx)
} else {
database.DB.QueryRow(database.Q(`
SELECT MAX(sibling_index) FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
`), *parentID).Scan(&maxIdx)
}
if !maxIdx.Valid {
return 0
}
return int(maxIdx.Int64) + 1
}

View File

@@ -1,239 +1,75 @@
// Package treepath provides message tree traversal operations.
//
// v0.29.0: Delegates to store.MessageStore. The package exists only for
// backward compatibility with handler aliases in tree.go. New code should
// call stores.Messages.* directly. This package will be deleted once all
// callers are migrated.
package treepath
import (
"database/sql"
"context"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Tree Types ──────────────────────────────
// Stores is set at startup by main.go. All tree operations delegate here.
// If nil, functions panic — there is no fallback to raw SQL.
var Stores *store.Stores
// 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"`
SenderName *string `json:"sender_name,omitempty"`
SenderAvatar *string `json:"sender_avatar,omitempty"`
CreatedAt string `json:"created_at"`
}
// ── Type Aliases ────────────────────────────
// Existing consumers reference these types. They're now aliases into the
// store package where the canonical definitions live.
// 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"`
}
type PathMessage = store.PathMessage
type SiblingInfo = store.SiblingInfo
// ── Get Active Leaf ─────────────────────────
// ── Public Functions ────────────────────────
// Same signatures as before. All delegate to store methods.
// 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(database.Q(`
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(database.Q(`
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(database.Q(`
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
return Stores.Messages.GetActiveLeaf(context.Background(), channelID, userID)
}
// ── 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)
return Stores.Messages.GetActivePath(context.Background(), channelID, userID)
}
// GetPathToLeaf walks from a specific leaf up to root, returning root-first.
func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
rows, err := database.DB.Query(database.Q(`
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)
}
// v0.23.2: Resolve sender names and avatars
resolveSenderInfo(path)
return path, nil
return Stores.Messages.GetPathToLeaf(context.Background(), channelID, leafID)
}
// resolveSenderInfo batch-resolves sender_name and sender_avatar for path messages.
func resolveSenderInfo(path []PathMessage) {
// Collect unique participant IDs by type
userIDs := map[string]bool{}
personaIDs := map[string]bool{}
for _, m := range path {
if m.ParticipantID == "" {
continue
}
switch m.ParticipantType {
case "user":
userIDs[m.ParticipantID] = true
case "persona":
personaIDs[m.ParticipantID] = true
}
}
// Resolve users
userNames := map[string]string{}
userAvatars := map[string]string{}
for uid := range userIDs {
var name, avatar sql.NullString
_ = database.DB.QueryRow(database.Q(`
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
`), uid).Scan(&name, &avatar)
if name.Valid {
userNames[uid] = name.String
}
if avatar.Valid {
userAvatars[uid] = avatar.String
}
}
// Resolve personas
personaNames := map[string]string{}
personaAvatars := map[string]string{}
for pid := range personaIDs {
var name, avatar sql.NullString
_ = database.DB.QueryRow(database.Q(`
SELECT name, avatar FROM personas WHERE id = $1
`), pid).Scan(&name, &avatar)
if name.Valid {
personaNames[pid] = name.String
}
if avatar.Valid {
personaAvatars[pid] = avatar.String
}
}
// Apply to path
for i := range path {
pid := path[i].ParticipantID
switch path[i].ParticipantType {
case "user":
if n, ok := userNames[pid]; ok {
path[i].SenderName = &n
}
if a, ok := userAvatars[pid]; ok && a != "" {
path[i].SenderAvatar = &a
}
case "persona":
if n, ok := personaNames[pid]; ok {
path[i].SenderName = &n
}
if a, ok := personaAvatars[pid]; ok && a != "" {
path[i].SenderAvatar = &a
}
}
}
func GetSiblingCount(channelID string, parentID *string) int {
count, _ := Stores.Messages.GetSiblingCount(context.Background(), channelID, parentID)
return count
}
func GetSiblings(messageID string) ([]SiblingInfo, int, error) {
return Stores.Messages.GetSiblingsList(context.Background(), messageID)
}
func FindLeafFromMessage(messageID string) (string, error) {
return Stores.Messages.FindLeafFromMessage(context.Background(), messageID)
}
func UpdateCursor(channelID, userID, messageID string) error {
return Stores.Channels.SetCursor(context.Background(), channelID, userID, messageID)
}
func NextSiblingIndex(channelID string, parentID *string) int {
idx, _ := Stores.Messages.NextSiblingIndexForParent(context.Background(), channelID, parentID)
return idx
}
// IsSummaryMessage checks if a PathMessage has summary metadata.
func IsSummaryMessage(m *PathMessage) bool {
if m.Metadata == nil {
return false
}
var meta map[string]interface{}
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
return false
}
t, _ := meta["type"].(string)
return t == "summary"
}

View File

@@ -1,114 +0,0 @@
package treepath
import (
"database/sql"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// GetSiblingCount returns how many live siblings share the same parent_id.
// Root messages (parent_id IS NULL) count all roots in the same channel.
func GetSiblingCount(channelID string, parentID *string) int {
var count int
if parentID == nil {
database.DB.QueryRow(database.Q(`
SELECT COUNT(*) FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
`), channelID).Scan(&count)
} else {
database.DB.QueryRow(database.Q(`
SELECT COUNT(*) FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
`), *parentID).Scan(&count)
}
if count == 0 {
count = 1
}
return count
}
// GetSiblings returns all live siblings of a given message (including itself),
// ordered by sibling_index.
func GetSiblings(messageID string) ([]SiblingInfo, int, error) {
// First get the parent_id of the target message
var parentID *string
var channelID string
err := database.DB.QueryRow(database.Q(`
SELECT parent_id, channel_id FROM messages
WHERE id = $1 AND deleted_at IS NULL
`), messageID).Scan(&parentID, &channelID)
if err != nil {
return nil, 0, fmt.Errorf("message not found: %w", err)
}
var rows *sql.Rows
if parentID == nil {
// Root messages: siblings are other roots in the same channel
rows, err = database.DB.Query(database.Q(`
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
ORDER BY sibling_index, created_at
`), channelID)
} else {
rows, err = database.DB.Query(database.Q(`
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
ORDER BY sibling_index, created_at
`), *parentID)
}
if err != nil {
return nil, 0, err
}
defer rows.Close()
var siblings []SiblingInfo
currentIdx := 0
for i := 0; rows.Next(); i++ {
var s SiblingInfo
if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil {
return nil, 0, err
}
if s.ID == messageID {
currentIdx = i
}
siblings = append(siblings, s)
}
return siblings, currentIdx, nil
}
// FindLeafFromMessage walks down from a message to find the deepest descendant.
// Follows the first child (lowest sibling_index) at each level.
// Used when switching branches: clicking a mid-tree sibling navigates to its leaf.
func FindLeafFromMessage(messageID string) (string, error) {
var leafID string
err := database.DB.QueryRow(database.Q(`
WITH RECURSIVE descendants AS (
SELECT id, 0 AS depth
FROM messages
WHERE id = $1 AND deleted_at IS NULL
UNION ALL
SELECT child.id, d.depth + 1
FROM messages child
JOIN descendants d ON child.parent_id = d.id
WHERE child.deleted_at IS NULL
AND child.sibling_index = (
SELECT MIN(sibling_index) FROM messages
WHERE parent_id = d.id AND deleted_at IS NULL
)
)
SELECT id FROM descendants
ORDER BY depth DESC
LIMIT 1
`), messageID).Scan(&leafID)
if err != nil {
return messageID, nil // if anything fails, just use the message itself
}
return leafID, nil
}

View File

@@ -1,38 +0,0 @@
package treepath
import "encoding/json"
// IsSummaryMessage checks if a PathMessage has summary metadata
// (metadata.type == "summary").
func IsSummaryMessage(m *PathMessage) bool {
if m.Metadata == nil {
return false
}
meta := ParseMetadata(m)
return meta["type"] == "summary"
}
// ParseMetadata unmarshals the JSONB metadata on a PathMessage.
// Returns an empty map on any error.
func ParseMetadata(m *PathMessage) map[string]interface{} {
if m.Metadata == nil {
return map[string]interface{}{}
}
var meta map[string]interface{}
if err := json.Unmarshal(*m.Metadata, &meta); err != nil {
return map[string]interface{}{}
}
return meta
}
// FindSummaryBoundary returns the index of the most recent summary node
// in a path, or -1 if no summary exists.
func FindSummaryBoundary(path []PathMessage) int {
idx := -1
for i := range path {
if IsSummaryMessage(&path[i]) {
idx = i
}
}
return idx
}