Spaces:
Running
Running
| // 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<Record<SourceId, AutomationDef>> = { | |
| 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<SourceId, SourceStats>; | |
| 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; | |
| } | |