Merge polecat/Keeper: mail message types and threading support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-18 20:23:50 -08:00
4 changed files with 369 additions and 16 deletions

View File

@@ -379,3 +379,71 @@ func (m *Mailbox) rewriteLegacy(messages []*Message) error {
// Atomic rename
return os.Rename(tmpPath, m.path)
}
// ListByThread returns all messages in a given thread.
func (m *Mailbox) ListByThread(threadID string) ([]*Message, error) {
if m.legacy {
return m.listByThreadLegacy(threadID)
}
return m.listByThreadBeads(threadID)
}
func (m *Mailbox) listByThreadBeads(threadID string) ([]*Message, error) {
// bd message thread <thread-id> --json
cmd := exec.Command("bd", "message", "thread", threadID, "--json")
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(), "BD_IDENTITY="+m.identity)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, errors.New(errMsg)
}
return nil, err
}
var beadsMsgs []BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
if len(stdout.Bytes()) == 0 || string(stdout.Bytes()) == "null" {
return nil, nil
}
return nil, err
}
var messages []*Message
for _, bm := range beadsMsgs {
messages = append(messages, bm.ToMessage())
}
// Sort by timestamp (oldest first for thread view)
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp.Before(messages[j].Timestamp)
})
return messages, nil
}
func (m *Mailbox) listByThreadLegacy(threadID string) ([]*Message, error) {
messages, err := m.List()
if err != nil {
return nil, err
}
var thread []*Message
for _, msg := range messages {
if msg.ThreadID == threadID {
thread = append(thread, msg)
}
}
// Sort by timestamp (oldest first for thread view)
sort.Slice(thread, func(i, j int) bool {
return thread[i].Timestamp.Before(thread[j].Timestamp)
})
return thread, nil
}

View File

@@ -37,9 +37,23 @@ func (r *Router) Send(msg *Message) error {
"-m", msg.Body,
}
// Add importance flag for high priority
if msg.Priority == PriorityHigh {
args = append(args, "--importance", "high")
// Add priority flag
beadsPriority := PriorityToBeads(msg.Priority)
args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority))
// Add message type if set
if msg.Type != "" && msg.Type != TypeNotification {
args = append(args, "--type", string(msg.Type))
}
// Add thread ID if set
if msg.ThreadID != "" {
args = append(args, "--thread-id", msg.ThreadID)
}
// Add reply-to if set
if msg.ReplyTo != "" {
args = append(args, "--reply-to", msg.ReplyTo)
}
cmd := exec.Command("bd", args...)
@@ -58,7 +72,7 @@ func (r *Router) Send(msg *Message) error {
}
// Optionally notify if recipient is a polecat with active session
if isPolecat(msg.To) && msg.Priority == PriorityHigh {
if isPolecat(msg.To) && (msg.Priority == PriorityHigh || msg.Priority == PriorityUrgent) {
r.notifyPolecat(msg)
}

View File

@@ -11,11 +11,34 @@ import (
type Priority string
const (
// PriorityLow is for non-urgent messages.
PriorityLow Priority = "low"
// PriorityNormal is the default priority.
PriorityNormal Priority = "normal"
// PriorityHigh indicates an urgent message.
// PriorityHigh indicates an important message.
PriorityHigh Priority = "high"
// PriorityUrgent indicates an urgent message requiring immediate attention.
PriorityUrgent Priority = "urgent"
)
// MessageType indicates the purpose of a message.
type MessageType string
const (
// TypeTask indicates a message requiring action from the recipient.
TypeTask MessageType = "task"
// TypeScavenge indicates optional first-come-first-served work.
TypeScavenge MessageType = "scavenge"
// TypeNotification is an informational message (default).
TypeNotification MessageType = "notification"
// TypeReply is a response to another message.
TypeReply MessageType = "reply"
)
// Message represents a mail message between agents.
@@ -44,9 +67,18 @@ type Message struct {
// Priority is the message priority.
Priority Priority `json:"priority"`
// Type indicates the message type (task, scavenge, notification, reply).
Type MessageType `json:"type"`
// ThreadID groups related messages into a conversation thread.
ThreadID string `json:"thread_id,omitempty"`
// ReplyTo is the ID of the message this is replying to.
ReplyTo string `json:"reply_to,omitempty"`
}
// NewMessage creates a new message with a generated ID (for legacy JSONL mode).
// NewMessage creates a new message with a generated ID and thread ID.
func NewMessage(from, to, subject, body string) *Message {
return &Message{
ID: generateID(),
@@ -57,6 +89,25 @@ func NewMessage(from, to, subject, body string) *Message {
Timestamp: time.Now(),
Read: false,
Priority: PriorityNormal,
Type: TypeNotification,
ThreadID: generateThreadID(),
}
}
// NewReplyMessage creates a reply message that inherits the thread from the original.
func NewReplyMessage(from, to, subject, body string, original *Message) *Message {
return &Message{
ID: generateID(),
From: from,
To: to,
Subject: subject,
Body: body,
Timestamp: time.Now(),
Read: false,
Priority: PriorityNormal,
Type: TypeReply,
ThreadID: original.ThreadID,
ReplyTo: original.ID,
}
}
@@ -67,6 +118,13 @@ func generateID() string {
return "msg-" + hex.EncodeToString(b)
}
// generateThreadID creates a random thread ID.
func generateThreadID() string {
b := make([]byte, 6)
rand.Read(b)
return "thread-" + hex.EncodeToString(b)
}
// BeadsMessage represents a message as returned by bd mail commands.
type BeadsMessage struct {
ID string `json:"id"`
@@ -74,16 +132,34 @@ type BeadsMessage struct {
Description string `json:"description"` // Body
Sender string `json:"sender"` // From identity
Assignee string `json:"assignee"` // To identity
Priority int `json:"priority"` // 0=urgent, 2=normal
Priority int `json:"priority"` // 0=urgent, 1=high, 2=normal, 3=low
Status string `json:"status"` // open=unread, closed=read
CreatedAt time.Time `json:"created_at"`
Type string `json:"type,omitempty"` // Message type
ThreadID string `json:"thread_id,omitempty"` // Thread identifier
ReplyTo string `json:"reply_to,omitempty"` // Original message ID
}
// ToMessage converts a BeadsMessage to a GGT Message.
func (bm *BeadsMessage) ToMessage() *Message {
priority := PriorityNormal
if bm.Priority == 0 {
// Convert beads priority (0=urgent, 1=high, 2=normal, 3=low) to GGT Priority
var priority Priority
switch bm.Priority {
case 0:
priority = PriorityUrgent
case 1:
priority = PriorityHigh
case 3:
priority = PriorityLow
default:
priority = PriorityNormal
}
// Convert message type, default to notification
msgType := TypeNotification
switch MessageType(bm.Type) {
case TypeTask, TypeScavenge, TypeReply:
msgType = MessageType(bm.Type)
}
return &Message{
@@ -95,6 +171,44 @@ func (bm *BeadsMessage) ToMessage() *Message {
Timestamp: bm.CreatedAt,
Read: bm.Status == "closed",
Priority: priority,
Type: msgType,
ThreadID: bm.ThreadID,
ReplyTo: bm.ReplyTo,
}
}
// PriorityToBeads converts a GGT Priority to beads priority integer.
// Returns: 0=urgent, 1=high, 2=normal, 3=low
func PriorityToBeads(p Priority) int {
switch p {
case PriorityUrgent:
return 0
case PriorityHigh:
return 1
case PriorityLow:
return 3
default:
return 2 // normal
}
}
// ParsePriority parses a priority string, returning PriorityNormal for invalid values.
func ParsePriority(s string) Priority {
switch Priority(s) {
case PriorityLow, PriorityNormal, PriorityHigh, PriorityUrgent:
return Priority(s)
default:
return PriorityNormal
}
}
// ParseMessageType parses a message type string, returning TypeNotification for invalid values.
func ParseMessageType(s string) MessageType {
switch MessageType(s) {
case TypeTask, TypeScavenge, TypeNotification, TypeReply:
return MessageType(s)
default:
return TypeNotification
}
}