// Wire events: what external watchers POST to the daemon (see /schema/event.schema.json). // This module is the only place wire shapes become engine EventDefs — the daemon // stays dumb pipes; mapping wire types onto scoring templates is policy, so it lives here. import { EVENTS } from "./events"; import type { EventDef, EventScores, SourceId } from "./types"; export interface WireEvent { id?: string; ts?: string; source: "claude" | "build" | "mail" | "discord" | "calendar" | "browser"; type: string; title: string; summary?: string; payload?: Record; } /** Keep in lockstep with event.schema.json — pinned by a parity test. */ export const WIRE_SOURCES = ["claude", "build", "mail", "discord", "calendar", "browser"] as const; export const WIRE_REQUIRED = ["source", "type", "title"] as const; export const WIRE_OPTIONAL = ["id", "ts", "summary", "payload"] as const; export function isWireEvent(v: unknown): v is WireEvent { if (typeof v !== "object" || v === null) return false; const o = v as Record; return ( typeof o.source === "string" && (WIRE_SOURCES as readonly string[]).includes(o.source) && typeof o.type === "string" && o.type.length > 0 && typeof o.title === "string" && o.title.length > 0 ); } const SOURCE_IDS: Record = { claude: "Claude", build: "Build", mail: "Mail", discord: "Discord", calendar: "Calendar", browser: "Browser", }; // (source, type) → the sim-deck event whose base scores fit. Real events borrow // the deck's tuned scoring + memory seeds; only the words change. // Exported for the drift test: every value must name a real deck event. export const TEMPLATES: Record = { "claude/task_completed": "claude_done", "claude/stop": "claude_done", "claude/notification": "claude_done", "claude/permission_needed": "claude_permission", // a failed Claude turn borrows the failing-build urgency profile "claude/failed": "build_fail", "build/passed": "build_done", "build/failed": "build_fail", "build/tests_failed": "build_fail", "mail/important": "mail_important", "mail/statement": "mail_finance", "discord/mention": "discord_mention", "discord/noise": "discord_noise", "calendar/soon": "calendar_soon", "browser/stale_tab": "stale_tab", "browser/new_page": "browser_newpage", "browser/rabbit_hole": "browser_rabbithole", }; // unknown (source, type): middling everything — surfaces as a scroll/glow, never an interrupt const FALLBACK_SCORES: EventScores = { urgency: 50, relevance: 60, novelty: 55, annoyance: 30, interest: 60 }; /** Wrap a wire event in an EventDef the engine can score and voice. */ export function toEventDef(w: WireEvent): EventDef { const template = EVENTS.find((e) => e.id === TEMPLATES[`${w.source}/${w.type}`]); const line = w.summary ? `${w.title} — ${w.summary}` : w.title; return { id: w.id ?? `wire_${w.source}_${w.type}`, source: SOURCE_IDS[w.source], // real events have no sim window to fly to; the shell decides where Puck goes target: null, glyph: template?.glyph ?? "✦", title: w.title, base: template?.base ?? FALLBACK_SCORES, effect: "none", // real titles speak for themselves; tier flavor comes from the template's framing lines: { plain: line, playful: template ? `Word from the ${w.source} realm: ${line}` : line, mythic: template ? `An omen from ${w.source}: ${line}` : line, }, memory: template?.memory ?? { text: `Vu gets real ${w.source} events now. Watch how he reacts.`, type: "pattern", icon: "🪶", }, }; }