Spaces:
Running
Running
File size: 7,843 Bytes
3c124f3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | // 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;
}
|