diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 2f227567..da84cafa 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -22,7 +22,7 @@ var ( mailPriority int mailUrgent bool mailPinned bool - mailEphemeral bool + mailWisp bool mailType string mailReplyTo string mailNotify bool @@ -236,7 +236,7 @@ func init() { mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to") mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient") mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)") - mailSendCmd.Flags().BoolVar(&mailEphemeral, "ephemeral", false, "Send as ephemeral wisp (auto-cleanup on patrol squash)") + mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", false, "Send as wisp (ephemeral, auto-cleanup on patrol squash)") mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)") _ = mailSendCmd.MarkFlagRequired("subject") @@ -334,8 +334,8 @@ func runMailSend(cmd *cobra.Command, args []string) error { // Set pinned flag msg.Pinned = mailPinned - // Set ephemeral flag - msg.Ephemeral = mailEphemeral + // Set wisp flag (ephemeral message) + msg.Wisp = mailWisp // Handle reply-to: auto-set type to reply and look up thread if mailReplyTo != "" { @@ -439,12 +439,12 @@ func runMailInbox(cmd *cobra.Command, args []string) error { if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { priorityMarker = " " + style.Bold.Render("!") } - ephemeralMarker := "" - if msg.Ephemeral || msg.Source == mail.SourceWisp { - ephemeralMarker = " " + style.Dim.Render("(ephemeral)") + wispMarker := "" + if msg.Wisp { + wispMarker = " " + style.Dim.Render("(wisp)") } - fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, ephemeralMarker) + fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker) fmt.Printf(" %s from %s\n", style.Dim.Render(msg.ID), msg.From) diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index b8ac741c..54091bfd 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -398,11 +398,11 @@ func runSpawn(cmd *cobra.Command, args []string) error { // Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping) witnessAddr := fmt.Sprintf("%s/witness", rigName) witnessNotification := &mail.Message{ - To: witnessAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), - Ephemeral: true, + To: witnessAddr, + From: sender, + Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName), + Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + Wisp: true, } if err := townRouter.Send(witnessNotification); err != nil { @@ -414,11 +414,11 @@ func runSpawn(cmd *cobra.Command, args []string) error { // Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping) deaconAddr := "deacon/" deaconNotification := &mail.Message{ - To: deaconAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), - Ephemeral: true, + To: deaconAddr, + From: sender, + Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName), + Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), + Wisp: true, } if err := townRouter.Send(deaconNotification); err != nil { diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 8a5bb596..1d81aad7 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -23,7 +23,6 @@ type Mailbox struct { identity string // beads identity (e.g., "gastown/polecats/Toast") workDir string // directory to run bd commands in beadsDir string // explicit .beads directory path (set via BEADS_DIR) - wispDir string // .beads-wisp directory for ephemeral messages path string // for legacy JSONL mode (crew workers) legacy bool // true = use JSONL files, false = use beads } @@ -49,26 +48,20 @@ func NewMailboxBeads(identity, workDir string) *Mailbox { // NewMailboxFromAddress creates a beads-backed mailbox from a GGT address. func NewMailboxFromAddress(address, workDir string) *Mailbox { beadsDir := filepath.Join(workDir, ".beads") - // Wisp directory is .beads-wisp/.beads (bd init creates .beads/ subdirectory) - wispDir := filepath.Join(workDir, ".beads-wisp", ".beads") return &Mailbox{ identity: addressToIdentity(address), workDir: workDir, beadsDir: beadsDir, - wispDir: wispDir, legacy: false, } } // NewMailboxWithBeadsDir creates a mailbox with an explicit beads directory. func NewMailboxWithBeadsDir(address, workDir, beadsDir string) *Mailbox { - // Derive wispDir from beadsDir (.beads-wisp/.beads sibling structure) - wispDir := filepath.Join(filepath.Dir(beadsDir), ".beads-wisp", ".beads") return &Mailbox{ identity: addressToIdentity(address), workDir: workDir, beadsDir: beadsDir, - wispDir: wispDir, legacy: false, } } @@ -92,29 +85,23 @@ func (m *Mailbox) List() ([]*Message, error) { } func (m *Mailbox) listBeads() ([]*Message, error) { - // Query persistent beads - persistentMsgs, err := m.listFromDir(m.beadsDir, SourcePersistent) + // Single query to beads - returns both persistent and wisp messages + // Wisps are stored in same DB with wisp=true flag, filtered from JSONL export + messages, err := m.listFromDir(m.beadsDir) if err != nil { return nil, err } - // Query wisp beads (ignore errors for missing dir) - var wispMsgs []*Message - if m.wispDir != "" { - wispMsgs, _ = m.listFromDir(m.wispDir, SourceWisp) - } - - // Merge and sort by timestamp (newest first) - all := append(persistentMsgs, wispMsgs...) - sort.Slice(all, func(i, j int) bool { - return all[i].Timestamp.After(all[j].Timestamp) + // Sort by timestamp (newest first) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Timestamp.After(messages[j].Timestamp) }) - return all, nil + return messages, nil } -// listFromDir queries messages from a specific beads directory. -func (m *Mailbox) listFromDir(beadsDir string, source MessageSource) ([]*Message, error) { +// listFromDir queries messages from a beads directory. +func (m *Mailbox) listFromDir(beadsDir string) ([]*Message, error) { // bd list --type=message --assignee= --json --status=open cmd := exec.Command("bd", "list", "--type", "message", @@ -149,13 +136,10 @@ func (m *Mailbox) listFromDir(beadsDir string, source MessageSource) ([]*Message return nil, err } - // Convert to GGT messages and set source + // Convert to GGT messages - wisp status comes from beads issue.wisp field var messages []*Message for _, bm := range beadsMsgs { - msg := bm.ToMessage() - msg.Source = source - msg.Ephemeral = (source == SourceWisp) - messages = append(messages, msg) + messages = append(messages, bm.ToMessage()) } return messages, nil @@ -226,25 +210,12 @@ func (m *Mailbox) Get(id string) (*Message, error) { } func (m *Mailbox) getBeads(id string) (*Message, error) { - // Try persistent first - msg, err := m.getFromDir(id, m.beadsDir, SourcePersistent) - if err == nil { - return msg, nil - } - - // Try wisp storage - if m.wispDir != "" { - msg, err = m.getFromDir(id, m.wispDir, SourceWisp) - if err == nil { - return msg, nil - } - } - - return nil, ErrMessageNotFound + // Single DB query - wisps and persistent messages in same store + return m.getFromDir(id, m.beadsDir) } -// getFromDir retrieves a message from a specific beads directory. -func (m *Mailbox) getFromDir(id, beadsDir string, source MessageSource) (*Message, error) { +// getFromDir retrieves a message from a beads directory. +func (m *Mailbox) getFromDir(id, beadsDir string) (*Message, error) { cmd := exec.Command("bd", "show", id, "--json") cmd.Dir = m.workDir cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) @@ -273,10 +244,8 @@ func (m *Mailbox) getFromDir(id, beadsDir string, source MessageSource) (*Messag return nil, ErrMessageNotFound } - msg := bms[0].ToMessage() - msg.Source = source - msg.Ephemeral = (source == SourceWisp) - return msg, nil + // Wisp status comes from beads issue.wisp field via ToMessage() + return bms[0].ToMessage(), nil } func (m *Mailbox) getLegacy(id string) (*Message, error) { @@ -301,21 +270,8 @@ func (m *Mailbox) MarkRead(id string) error { } func (m *Mailbox) markReadBeads(id string) error { - // Try persistent first - err := m.closeInDir(id, m.beadsDir) - if err == nil { - return nil - } - - // Try wisp storage - if m.wispDir != "" { - err = m.closeInDir(id, m.wispDir) - if err == nil { - return nil - } - } - - return ErrMessageNotFound + // Single DB - wisps and persistent messages in same store + return m.closeInDir(id, m.beadsDir) } // closeInDir closes a message in a specific beads directory. diff --git a/internal/mail/router.go b/internal/mail/router.go index 092e5f61..e59c02d5 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -88,33 +88,23 @@ func isTownLevelAddress(address string) bool { return addr == "mayor" || addr == "deacon" } -// resolveWispDir returns the .beads-wisp/.beads directory for ephemeral mail. -// Like resolveBeadsDir, mail wisps use town-level storage. -// Note: bd init creates .beads/ subdirectory, so the full path is .beads-wisp/.beads -func (r *Router) resolveWispDir() string { - if r.townRoot == "" { - return filepath.Join(r.workDir, ".beads-wisp", ".beads") - } - return filepath.Join(r.townRoot, ".beads-wisp", ".beads") -} - -// shouldBeEphemeral determines if a message should be stored as a wisp. +// shouldBeWisp determines if a message should be stored as a wisp. // Returns true if: -// - Message.Ephemeral is explicitly set +// - Message.Wisp is explicitly set // - Subject matches lifecycle message patterns (POLECAT_*, NUDGE, etc.) -func (r *Router) shouldBeEphemeral(msg *Message) bool { - if msg.Ephemeral { +func (r *Router) shouldBeWisp(msg *Message) bool { + if msg.Wisp { return true } // Auto-detect lifecycle messages by subject prefix subjectLower := strings.ToLower(msg.Subject) - ephemeralPrefixes := []string{ + wispPrefixes := []string{ "polecat_started", "polecat_done", "start_work", "nudge", } - for _, prefix := range ephemeralPrefixes { + for _, prefix := range wispPrefixes { if strings.HasPrefix(subjectLower, prefix) { return true } @@ -154,18 +144,12 @@ func (r *Router) Send(msg *Message) error { args = append(args, "--labels", strings.Join(labels, ",")) } - // Resolve the correct beads directory based on ephemeral status - var beadsDir string - if r.shouldBeEphemeral(msg) { - beadsDir = r.resolveWispDir() - // Ensure wisp directory exists - if err := os.MkdirAll(beadsDir, 0755); err != nil { - return fmt.Errorf("creating wisp dir: %w", err) - } - } else { - beadsDir = r.resolveBeadsDir(msg.To) + // Add --wisp flag for ephemeral messages (stored in single DB, filtered from JSONL export) + if r.shouldBeWisp(msg) { + args = append(args, "--wisp") } + beadsDir := r.resolveBeadsDir(msg.To) cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir, diff --git a/internal/mail/types.go b/internal/mail/types.go index b3e9e723..51291352 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -28,16 +28,6 @@ const ( // MessageType indicates the purpose of a message. type MessageType string -// MessageSource indicates where a message is stored. -type MessageSource string - -const ( - // SourcePersistent indicates the message is in permanent .beads storage. - SourcePersistent MessageSource = "persistent" - - // SourceWisp indicates the message is in ephemeral .beads-wisp storage. - SourceWisp MessageSource = "wisp" -) const ( // TypeTask indicates a message requiring action from the recipient. @@ -109,13 +99,9 @@ type Message struct { // Pinned marks the message as pinned (won't be auto-archived). Pinned bool `json:"pinned,omitempty"` - // Ephemeral marks this as a transient message stored in wisps. - // Ephemeral messages auto-cleanup on patrol squash. - Ephemeral bool `json:"ephemeral,omitempty"` - - // Source indicates where this message is stored (persistent or wisp). - // Set during List(), not serialized. - Source MessageSource `json:"-"` + // Wisp marks this as a transient message (stored in same DB but filtered from JSONL export). + // Wisp messages auto-cleanup on patrol squash. + Wisp bool `json:"wisp,omitempty"` } // NewMessage creates a new message with a generated ID and thread ID. @@ -177,6 +163,7 @@ type BeadsMessage struct { CreatedAt time.Time `json:"created_at"` Labels []string `json:"labels"` // Metadata labels (from:X, thread:X, reply-to:X, msg-type:X) Pinned bool `json:"pinned,omitempty"` + Wisp bool `json:"wisp,omitempty"` // Ephemeral message (filtered from JSONL export) // Cached parsed values (populated by ParseLabels) sender string @@ -237,6 +224,7 @@ func (bm *BeadsMessage) ToMessage() *Message { Type: msgType, ThreadID: bm.threadID, ReplyTo: bm.replyTo, + Wisp: bm.Wisp, } }