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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user