puck / frontend /src /engine /learning.ts
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
7.84 kB
// 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;
}