// The visible learning profile: per-source interruption bias that ratings // push around, learned tone tags, and the automation offers that bridge // toward acting on the user's behalf. import type { AutomationDef, Decision, LearnedAutomation, Profile, Rating, SourceId, SourceStats, Taste, } from "./types"; import { clamp } from "./util"; export interface SourceInfo { id: SourceId; glyph: string; label: string; } export const SOURCES: readonly SourceInfo[] = [ { id: "Claude", glyph: "✳", label: "Claude Code" }, { id: "Build", glyph: "▦", label: "Builds & tests" }, { id: "Mail", glyph: "✉", label: "Mail" }, { id: "Discord", glyph: "◍", label: "Discord" }, { id: "Calendar", glyph: "◷", label: "Calendar" }, { id: "Browser", glyph: "❍", label: "Stray tabs" }, { id: "Git", glyph: "⎇", label: "Git" }, ]; // automations Puck can offer once he's confident a source annoys you export const AUTOMATIONS: Partial> = { Discord: { id: "mute_channels", verb: "auto-mute noisy channels", offer: "You've waved off the social bog a few times now. Want me to mute the loud channels and only ping you on direct @mentions?", done: "muted #general · only @mentions reach you", handles: ["discord_noise"], }, Mail: { id: "file_finance", verb: "auto-file finance mail", offer: "Three statements, three shrugs. Shall I quietly file finance & statement mail from now on, and only flag the ones that look urgent?", done: "filed Fidelity statement · finance mail handled", handles: ["mail_finance"], }, Browser: { id: "close_stale", verb: "close stale tabs", offer: "These docs tabs go feral and you never return. Want me to close anything untouched for 3+ days?", done: "closed 'sqlite-vec docs' · 3 days untouched", handles: ["stale_tab"], }, Build: { id: "watch_builds", verb: "watch builds silently", offer: "You always glance at builds yourself. Want me to stop announcing the passing ones and only speak up when something breaks?", done: "passing builds logged silently · failures still surface", handles: ["build_done"], }, }; const EMPTY_STATS: SourceStats = { bias: 0, helpful: 0, annoying: 0, cute: 0, samples: 0 }; /** A source's stats, or empty defaults if this (possibly older) profile predates * the source. Indexing profile.sources directly is unsafe — use this. */ export function statsFor(profile: Profile, id: SourceId): SourceStats { return profile.sources[id] ?? EMPTY_STATS; } /** Backfill any SOURCES added after this profile was persisted. Adding a source * must never leave old profiles with a missing entry (that crashed the Learning * tab render). Returns the same object when nothing's missing. */ export function ensureSources(profile: Profile): Profile { const missing = SOURCES.filter((s) => !profile.sources[s.id]); if (!missing.length) return profile; const sources = { ...profile.sources }; for (const s of missing) sources[s.id] = { ...EMPTY_STATS }; return { ...profile, sources }; } export function defaultProfile(): Profile { const sources = {} as Record; for (const s of SOURCES) sources[s.id] = { ...EMPTY_STATS }; // seed a couple so a fresh profile isn't blank sources.Claude.bias = 10; sources.Claude.helpful = 1; sources.Claude.samples = 1; sources.Discord.bias = -8; sources.Discord.annoying = 1; sources.Discord.samples = 1; return { sources, automations: [], offered: {} }; } export function sourceBias(profile: Profile, source: SourceId): number { return profile.sources[source]?.bias ?? 0; } /** A rating pushes that source's bias (helpful↑ surface more, annoying↓ back off). */ export function rateSource(profile: Profile, source: SourceId, rating: Rating): Profile { if (!profile.sources[source]) return profile; const s = { ...profile.sources[source] }; s.samples += 1; if (rating === "helpful") { s.bias = clamp(s.bias + 9, -50, 50); s.helpful++; } if (rating === "annoying") { s.bias = clamp(s.bias - 12, -50, 50); s.annoying++; } if (rating === "cute") { s.bias = clamp(s.bias - 3, -50, 50); s.cute++; } return { ...profile, sources: { ...profile.sources, [source]: s } }; } // Learned bias shifts a decision up/down the ladder (never manufactures // a full interrupt — those stay reserved for genuine urgency). const LADDER: Decision[] = ["ignore", "scroll", "glow", "notify", "interrupt"]; export function applyBias(decision: Decision, bias: number): Decision { let idx = LADDER.indexOf(decision); if (idx < 0) return decision; idx = clamp(idx + Math.round(bias / 18), 0, 4); if (decision !== "interrupt" && idx === 4) idx = 3; return LADDER[idx]; } export interface Disposition { t: string; k: "hi" | "mid" | "lo" | "off"; } export function dispositionLabel(bias: number): Disposition { if (bias >= 28) return { t: "interrupt freely", k: "hi" }; if (bias >= 8) return { t: "speak up", k: "mid" }; if (bias > -8) return { t: "log quietly", k: "lo" }; if (bias > -28) return { t: "rarely surface", k: "lo" }; return { t: "stay silent", k: "off" }; } /** Confidence grows with samples, caps at 1. */ export function confidence(samples: number): number { return Math.min(1, samples / 5); } /** Readable tone tags from the taste vector. */ export function voiceTags(taste: Taste): string[] { const tags: string[] = []; if (taste.useful > 0.6) tags.push("to the point"); if (taste.terse > 0.6) tags.push("brief"); if (taste.warm > 0.6) tags.push("warm"); if (taste.theatrical > 0.62) tags.push("theatrical"); else if (taste.theatrical < 0.4) tags.push("low drama"); if (!tags.length) tags.push("still figuring you out"); return tags; } /** What irks you — distinct annoying sources, most-flagged first. */ export function irks(profile: Profile): (SourceInfo & { n: number })[] { return SOURCES.map((s) => ({ ...s, n: statsFor(profile, s.id).annoying })) .filter((s) => s.n > 0) .sort((a, b) => b.n - a.n); } /** Should Puck offer an automation for this source right now? */ export function automationOffer( profile: Profile, source: SourceId, ): (AutomationDef & { source: SourceId }) | null { const auto = AUTOMATIONS[source]; if (!auto) return null; if (profile.offered[source]) return null; if (profile.automations.find((a) => a.id === auto.id)) return null; const s = profile.sources[source]; // Build earns its automation by being consistently *helpful*; the rest by annoying you const ready = source === "Build" ? s.helpful >= 2 : s.annoying >= 2; return ready ? { source, ...auto } : null; } export function acceptAutomation(profile: Profile, source: SourceId): Profile { const auto = AUTOMATIONS[source]; if (!auto) return profile; return { ...profile, offered: { ...profile.offered, [source]: true }, automations: [...profile.automations, { id: auto.id, source, verb: auto.verb, done: auto.done }], }; } export function declineAutomation(profile: Profile, source: SourceId): Profile { return { ...profile, offered: { ...profile.offered, [source]: true } }; } export function hasAutomation(profile: Profile, source: SourceId): boolean { const auto = AUTOMATIONS[source]; return !!auto && profile.automations.some((a) => a.id === auto.id); } /** The accepted automation that silently handles this event, if any. * Scoped per-event, not per-source: muting the bog must not eat a @mention. */ export function handlesEvent(profile: Profile, source: SourceId, eventId: string): LearnedAutomation | null { const auto = AUTOMATIONS[source]; if (!auto?.handles.includes(eventId)) return null; return profile.automations.find((a) => a.id === auto.id) ?? null; }