Documentation
¶
Overview ¶
Package companion tracks notifications sent to the user to prevent duplicates across restarts and surfaces notification status in the feed UI.
Index ¶
- Constants
- func FormatGroup(key string, evs []signalpkg.StoredEvent) string
- type AgentMessage
- type Deliverer
- type Group
- type Learner
- func (l *Learner) ComputePreferences(ctx context.Context) (*Preferences, error)
- func (l *Learner) PeakHour(ctx context.Context) int
- func (l *Learner) Scores(ctx context.Context) (sources map[string]float64, topics map[string]float64)
- func (l *Learner) SourceScores(ctx context.Context) map[string]float64
- func (l *Learner) TopicScores(ctx context.Context) map[string]float64
- type LogStore
- func (s *LogStore) CountRecentNotifications(ctx context.Context, window time.Duration) int
- func (s *LogStore) NotificationStatus(ctx context.Context, eventIDs []string) map[string]NotificationInfo
- func (s *LogStore) RecentlyNotifiedSet(ctx context.Context, eventIDs []string, window time.Duration) map[string]bool
- func (s *LogStore) Record(ctx context.Context, id, eventID, channel string, messageHash []byte) error
- func (s *LogStore) UpdateReaction(ctx context.Context, id, reaction string) error
- func (s *LogStore) WasHashSentRecently(ctx context.Context, messageHash []byte, window time.Duration) bool
- func (s *LogStore) WasNotifiedRecently(ctx context.Context, eventID string, window time.Duration) bool
- type MessageWriter
- type MissedContentChecker
- type NotificationAccumulator
- type NotificationInfo
- type Notifier
- type PreferenceSource
- type Preferences
- type PriorityConfig
- type ScoredGroup
- type SourcePreference
- type TopicPreference
Constants ¶
const DefaultLearningThreshold = 5
DefaultLearningThreshold is the minimum number of notifications (including 'none'/unrated ones) a source must accumulate before its learned score is applied at full weight. The confidence factor is b.total/threshold where b.total counts all companion_log rows for the source, not just explicit reactions. Below this threshold the score is linearly interpolated toward neutral (0), preventing a single thumbs_up from permanently boosting an unknown source.
Variables ¶
This section is empty.
Functions ¶
func FormatGroup ¶
func FormatGroup(key string, evs []signalpkg.StoredEvent) string
FormatGroup formats a human-readable notification message for a group. Single-event groups show full detail; multi-event groups show a summary header plus bullet points for each item.
Types ¶
type AgentMessage ¶
type AgentMessage struct {
ID string
Content string
SourceEventIDs []string
Channel string
CreatedAt string
}
AgentMessage is a row from agent_messages.
type Deliverer ¶
type Deliverer struct {
// contains filtered or unexported fields
}
Deliverer groups, prioritizes, and delivers feed events as batched notifications. It respects the companion log (Phase 1 dedup) so already-notified events are moved to the back rather than dropped entirely.
func NewDeliverer ¶
func NewDeliverer(log *LogStore, cfg PriorityConfig) *Deliverer
NewDeliverer creates a Deliverer. log may be nil (disables dedup and novelty scoring).
func (*Deliverer) Deliver ¶
func (d *Deliverer) Deliver(ctx context.Context, events []signalpkg.StoredEvent, notifier Notifier, maxGroups int)
Deliver groups events, prioritizes them, and sends the top groups via notifier. maxGroups caps how many groups are dispatched per call (0 = unlimited). Each group becomes one Notify call with the group's formatted message.
func (*Deliverer) DeliverText ¶
func (d *Deliverer) DeliverText(ctx context.Context, text string, priority int, eventID string, notifier Notifier)
DeliverText sends a pre-formatted text notification (e.g. an LLM-authored message) via notifier. When a companion log is configured it:
- skips the call if an identical hash was sent within the last dedup window
- records the sent notification so the companion log stays accurate
eventID ties the notification to a specific feed event for UI annotation; it may be empty for free-form messages that have no underlying event.
type Group ¶
type Group struct {
// GroupKey is the shared key (e.g. "avifenesh/cairn", "twitter:rust", "gmail").
GroupKey string
// Events are the individual events in this group, in insertion order.
Events []signalpkg.StoredEvent
// Message is the pre-formatted notification text for this group.
Message string
// IDs holds the event IDs for companion-log dedup lookups.
IDs []string
// MaxPriority is the highest priority among events in the group (1=low, 3=high).
MaxPriority int
}
Group bundles related StoredEvents that share a GroupKey.
func GroupEvents ¶
func GroupEvents(events []signalpkg.StoredEvent) []Group
GroupEvents groups a flat list of StoredEvents by GroupKey. Events without a GroupKey are each placed in a singleton group with key "ungrouped:<ID>". Within each group, events are in input (insertion) order — no sorting is applied. Empty or nil input returns nil.
type Learner ¶
type Learner struct {
// contains filtered or unexported fields
}
Learner reads companion_log joined with events to compute user preferences. A nil Learner returns empty Preferences without error.
func NewLearner ¶
NewLearner creates a Learner backed by db with DefaultLearningThreshold.
func (*Learner) ComputePreferences ¶
func (l *Learner) ComputePreferences(ctx context.Context) (*Preferences, error)
ComputePreferences derives preferences from companion_log + events.
func (*Learner) PeakHour ¶
PeakHour returns the UTC hour (0–23) with the most notification activity, or -1 when no data is available. Used externally to time-weight delivery.
func (*Learner) Scores ¶
func (l *Learner) Scores(ctx context.Context) (sources map[string]float64, topics map[string]float64)
Scores returns both source and topic scores in a single ComputePreferences call. Use this instead of calling SourceScores and TopicScores separately to avoid two full DB scans per tick when both are needed.
func (*Learner) SourceScores ¶
SourceScores returns a map of source name → confidence-weighted engagement score in [-1, 1]. Implements PreferenceSource so Learner can be passed directly to PriorityConfig.
Scores are confidence-weighted: a source with fewer notifications than LearningThreshold returns a score linearly damped toward 0 (neutral). This prevents a single thumbs_up or thumbs_down from fully overriding the default weights before a real pattern has emerged. At threshold or above the raw score is returned unchanged.
Sources with a ConfidenceScore of exactly 0 (all reactions were 'none'/neutral) are excluded from the returned map — absent means neutral, not disliked. Returns nil when: the Learner is nil, an error occurs, or all sources have only 'none' reactions (no actionable signal).
type LogStore ¶
type LogStore struct {
// contains filtered or unexported fields
}
LogStore records every notification sent and provides duplicate-check queries. All methods are safe to call concurrently. A nil LogStore is a no-op.
func NewLogStore ¶
NewLogStore creates a LogStore backed by db.
func (*LogStore) CountRecentNotifications ¶
CountRecentNotifications returns the total number of companion_log entries within the given window. Used by the orchestrator to rate-limit notifications.
func (*LogStore) NotificationStatus ¶
func (s *LogStore) NotificationStatus(ctx context.Context, eventIDs []string) map[string]NotificationInfo
NotificationStatus returns the most recent notification per event ID using a single batch query. eventIDs with no companion_log entry are absent from the result map. Returns an empty (non-nil) map on any error or nil store. Partial results are returned on a best-effort basis if scanning fails for individual rows.
func (*LogStore) RecentlyNotifiedSet ¶
func (s *LogStore) RecentlyNotifiedSet(ctx context.Context, eventIDs []string, window time.Duration) map[string]bool
RecentlyNotifiedSet returns a set of event IDs from eventIDs that have been notified within the given window. Used by the digest generator to skip recently-notified events in a single batch query.
func (*LogStore) Record ¶
func (s *LogStore) Record(ctx context.Context, id, eventID, channel string, messageHash []byte) error
Record writes a notification entry. eventID may be empty for notifications not tied to a specific feed event. messageHash is the MD5 of the message text. channel must be one of "telegram", "discord", "slack", "broadcast", "companion". sent_at is set explicitly from Go using util.ISOTime for consistency with the rest of the codebase.
func (*LogStore) UpdateReaction ¶
UpdateReaction sets the user_reaction field for a companion_log entry. Valid reactions: "none", "thumbs_up", "thumbs_down", "dismiss".
func (*LogStore) WasHashSentRecently ¶
func (s *LogStore) WasHashSentRecently(ctx context.Context, messageHash []byte, window time.Duration) bool
WasHashSentRecently returns true if a message with the given hash was sent within the given window on any channel. Returns false on any error, nil store, or non-positive window.
func (*LogStore) WasNotifiedRecently ¶
func (s *LogStore) WasNotifiedRecently(ctx context.Context, eventID string, window time.Duration) bool
WasNotifiedRecently returns true if event eventID has a companion_log entry with sent_at within the given window. Returns false on any error, nil store, or non-positive window.
type MessageWriter ¶
type MessageWriter struct {
// contains filtered or unexported fields
}
MessageWriter persists agent-authored messages to the agent_messages table. These messages appear in the curated feed UI. A nil MessageWriter is a no-op.
func NewMessageWriter ¶
func NewMessageWriter(db *sql.DB) *MessageWriter
NewMessageWriter creates a MessageWriter backed by db. Returns nil when db is nil so callers can gate on nil without extra checks.
func (*MessageWriter) List ¶
func (w *MessageWriter) List(ctx context.Context, limit int) ([]AgentMessage, error)
List returns the most recent n non-archived messages, newest first.
func (*MessageWriter) Write ¶
Write inserts a new agent message into agent_messages with channel="feed". Skips insertion if a message with the same content prefix was written within the last 24 hours (prevents repetitive digest generation). sourceEventIDs links the message to the signal-plane events it was derived from (e.g. article event IDs that fed a digest). Pass nil or empty when not applicable. source_event_ids is stored as a JSON array to match the agentmsg package schema. Safe to call on a nil MessageWriter — returns nil without action.
type MissedContentChecker ¶
type MissedContentChecker struct {
// contains filtered or unexported fields
}
MissedContentChecker detects prolonged user inactivity and sends a proactive "you might have missed…" Telegram message with the top unread feed items.
Detection uses two signals:
- messages.created_at (role='user'): last time the user typed in the chat
- If no user messages exist at all: treat as inactive
Dedup: the MD5 of the composed nudge is checked against companion_log so the same content is never sent twice within missedContentDedupeWindow.
func NewMissedContentChecker ¶
func NewMissedContentChecker(db *sql.DB, log *LogStore) *MissedContentChecker
NewMissedContentChecker creates a checker backed by db. log may be nil (disables dedup — each call will always send if inactivity threshold is met).
func (*MissedContentChecker) Check ¶
Check queries the database for user inactivity and unread feed items, then sends a nudge via n when:
- the user has not sent a chat message in >= 24h (or never), AND
- there are unread agent_messages to share, AND
- the same nudge content has not been sent in the last 24h.
Returns true when a nudge was sent.
type NotificationAccumulator ¶
type NotificationAccumulator struct {
// contains filtered or unexported fields
}
NotificationAccumulator queues non-urgent notifications for periodic batched delivery instead of sending each one immediately. High-priority notifications bypass the accumulator and are sent immediately. All methods are safe to call on a nil receiver (no-op).
func NewNotificationAccumulator ¶
func NewNotificationAccumulator(db *sql.DB) *NotificationAccumulator
NewNotificationAccumulator creates a NotificationAccumulator backed by db. Returns nil when db is nil.
func (*NotificationAccumulator) Accumulate ¶
func (a *NotificationAccumulator) Accumulate(ctx context.Context, text, priority, source string) bool
Accumulate evaluates the notification priority. If priority is "high", it returns false (caller should send immediately). Otherwise it inserts the notification into the queue and returns true (caller should skip immediate send).
func (*NotificationAccumulator) Flush ¶
func (a *NotificationAccumulator) Flush(ctx context.Context) (string, error)
Flush collects all queued items, formats them as a single digest message, deletes them from the queue, and returns the formatted text. Returns an empty string when the queue is empty. Returns an error only on DB failure.
func (*NotificationAccumulator) PendingCount ¶
func (a *NotificationAccumulator) PendingCount(ctx context.Context) int
PendingCount returns the number of items currently queued. Returns 0 on any error or nil receiver.
type NotificationInfo ¶
type NotificationInfo struct {
ID string `json:"id"` // companion_log primary key; used by PATCH /v1/companion/reaction/{id}
SentAt time.Time `json:"sentAt"`
Channel string `json:"channel"`
Reaction string `json:"reaction"`
}
NotificationInfo holds the most recent notification for a feed event.
type Notifier ¶
Notifier sends a formatted text notification at the given priority. Satisfied by tool.NotifyService.
type PreferenceSource ¶
type PreferenceSource interface {
SourceScores(ctx context.Context) map[string]float64
// TopicScores returns keyword → confidence-weighted score in [-1, 1].
// Only keywords with |score| > 0.1 are returned.
TopicScores(ctx context.Context) map[string]float64
}
PreferenceSource provides learned per-source and per-topic engagement scores. Implemented by Learner; both methods return nil when no data is available. Nil is safe: the prioritizer falls back to HighValueSources when nil.
type Preferences ¶
type Preferences struct {
// Sources ranked by score descending. Only sources with at least one
// notification in companion_log are included.
Sources []SourcePreference `json:"sources"`
// Topics ranked by confidence score descending. Each entry represents a
// keyword extracted from reacted feed item titles and bodies.
Topics []TopicPreference `json:"topics"`
// ActiveHours is a 24-element UTC histogram of how many notifications
// were sent per hour of day. Index 0 = 00:00–00:59 UTC.
ActiveHours [24]int `json:"activeHours"`
// TotalLogged is the total number of companion_log entries.
TotalLogged int `json:"totalLogged"`
// LearningThreshold is the minimum total notifications (not just explicit
// reactions) required for full-confidence score application. Sources below
// this threshold have their score linearly damped toward neutral.
LearningThreshold int `json:"learningThreshold"`
}
Preferences is the full preference model derived from companion_log history.
type PriorityConfig ¶
type PriorityConfig struct {
// Topics is the user's configured signal topics (from config.toml twitter_topics).
// Groups whose GroupKey contains a topic earn an interest score boost.
Topics []string
// HighValueSources are source names that receive a higher base weight.
// Defaults to {"github", "github_signal", "gmail", "calendar"} when nil.
// Used only when Preferences is nil or has no data for a given source.
HighValueSources []string
// Preferences optionally wires the companion Learner so Prioritize uses
// real user engagement scores instead of the hardcoded HighValueSources list.
// Learned score in [-1, 1] is mapped to [0.10, 0.25]:
// score=-1 → 0.10, score=0 → 0.175, score=+1 → 0.25
// Sources absent from Preferences fall back to HighValueSources logic.
Preferences PreferenceSource
// RecencyHalfLife controls how fast recency decays.
// Default: 6 hours. A group 6h old scores 0.5 on recency.
RecencyHalfLife time.Duration
// DedupeWindow is the lookback window passed to companion log to check
// whether a similar message was recently sent.
DedupeWindow time.Duration
}
PriorityConfig tunes the scoring weights for Prioritize.
type ScoredGroup ¶
ScoredGroup is a Group with a computed delivery score.
func Prioritize ¶
func Prioritize(ctx context.Context, groups []Group, cfg PriorityConfig, log *LogStore) []ScoredGroup
Prioritize scores and sorts groups for delivery. It returns groups ordered by Score descending. Groups whose events appear in the companion log within DedupeWindow have their novelty component reduced proportionally; they stay in the sorted order but score lower than groups with unnotified events.
type SourcePreference ¶
type SourcePreference struct {
Source string `json:"source"`
Positive int `json:"positive"` // thumbs_up reactions
Negative int `json:"negative"` // thumbs_down + dismiss reactions
Total int `json:"total"` // all notifications for this source
Score float64 `json:"score"` // (positive - negative) / total; range [-1, 1]
ConfidenceScore float64 `json:"confidenceScore"` // Score * min(1, Total/threshold); used for prioritization
}
SourcePreference holds engagement stats for a single signal source.
type TopicPreference ¶
type TopicPreference struct {
Keyword string `json:"keyword"`
Positive int `json:"positive"` // weighted count from thumbs_up events
Negative int `json:"negative"` // weighted count from thumbs_down/dismiss events
Score float64 `json:"score"` // (positive - negative) / (positive + negative); range [-1, 1]
ConfidenceScore float64 `json:"confidenceScore"` // Score * min(1, (positive+negative)/threshold)
}
TopicPreference holds engagement stats for a single keyword extracted from reacted feed items. Keywords are extracted from event titles (weight 2×) and bodies (weight 1×) of companion_log entries with explicit thumbs_up/thumbs_down reactions. Only tokens ≥ 4 chars and not in the stopword list are tracked.