Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -10,47 +10,45 @@ import (
// Mention represents a parsed @mention token in message content.
type Mention struct {
Raw string // "@claude-3-opus" as written (includes @)
Name string // "claude-3-opus" (normalized, no @)
Raw string // "@veronica-sharpe" as written (includes @)
Name string // "veronica-sharpe" (normalized, no @)
Start int // byte offset in message content
End int // byte offset end (exclusive)
Resolved *models.ChannelModel // nil if unresolved
IsAll bool // true for @all special token
}
// Parse extracts @mentions from message content and resolves them
// against the channel's model roster.
//
// Rules:
// - @ followed by one or more non-whitespace characters
// - Greedy: @claude-3-opus-20240229 matches the full token
// - Resolution: case-insensitive match against display_name with
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
// - Longest-match-first when display names overlap
// - Unresolved mentions are included with Resolved = nil
// - @ must be at start of content or preceded by whitespace
// Resolution priority:
// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
// 3. Fallback: normalized display_name match (backward compat)
// 4. @all is a special token that resolves to all roster entries
//
// @ must be at start of content or preceded by whitespace.
func Parse(content string, roster []models.ChannelModel) []Mention {
if len(roster) == 0 || !strings.Contains(content, "@") {
return nil
}
// Build lookup: normalized display_name → *ChannelModel
// Sort roster by display_name length descending for longest-match-first
// Build handle lookup: handle → *ChannelModel
// Also build display name lookup for backward compat
type entry struct {
normalized string
handle string // normalized handle
normalized string // normalized display_name
model models.ChannelModel
}
entries := make([]entry, 0, len(roster))
for _, cm := range roster {
if cm.DisplayName == "" {
continue
}
entries = append(entries, entry{
normalized: normalize(cm.DisplayName),
model: cm,
})
h := normalize(cm.Handle)
dn := normalize(cm.DisplayName)
entries = append(entries, entry{handle: h, normalized: dn, model: cm})
}
// Sort by handle length descending for longest-match-first
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].normalized) > len(entries[j].normalized)
return len(entries[i].handle) > len(entries[j].handle)
})
// Extract @tokens
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
continue // bare @ with no token
}
// Strip trailing punctuation (commas, periods, etc.)
// Strip trailing punctuation
tokenEnd := i
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
tokenEnd--
}
if tokenEnd == tokenStart {
continue // all punctuation
continue
}
raw := content[start:tokenEnd]
name := content[tokenStart:tokenEnd]
normalizedName := normalize(name)
// Try to resolve against roster (longest match first)
// Special: @all
if normalizedName == "all" {
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
IsAll: true,
})
continue
}
// 1. Exact handle match
var resolved *models.ChannelModel
for idx := range entries {
if normalizedName == entries[idx].normalized {
if entries[idx].handle != "" && normalizedName == entries[idx].handle {
cm := entries[idx].model
resolved = &cm
break
}
}
// 2. Prefix handle match (unambiguous only)
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
// 3. Fallback: display_name match (backward compat)
if resolved == nil {
for idx := range entries {
if entries[idx].normalized != "" && normalizedName == entries[idx].normalized {
cm := entries[idx].model
resolved = &cm
break
}
}
// Display name prefix
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 0 {
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
}
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
return result
}
// HasAll returns true if any mention is @all.
func HasAll(mentions []Mention) bool {
for _, m := range mentions {
if m.IsAll {
return true
}
}
return false
}
// isMentionTrailingPunct returns true for punctuation that commonly
// follows an @mention but isn't part of the name.
func isMentionTrailingPunct(b byte) bool {