import { existsSync } from "node:fs"; import { getWorkspacePromptPath } from "../util/platform.js"; type Tone = "grounded" | "warm" | "energetic" | "poetic"; export interface OnboardingGreetingContext { tools: string[]; tasks: string[]; /** Valid values: "grounded" | "warm" | "energetic" | "poetic" */ tone: string; userName?: string; assistantName?: string; googleConnected?: boolean; } /** * Returns `true` when all of the following are true: * - `conversationMessageCount === 0` (no prior messages in this conversation) * - BOOTSTRAP.md exists at the workspace prompt path * - The trimmed content matches the macOS wake-up greeting (case-insensitive) */ export function isWakeUpGreeting( content: string, conversationMessageCount: number, ): boolean { if (conversationMessageCount !== 0) { return false; } if (!existsSync(getWorkspacePromptPath("BOOTSTRAP.md"))) { return false; } return ( content .trim() .toLowerCase() .replace(/[.!?]+$/, "") === "wake up, my friend" ); } export function getCannedFirstGreeting( onboarding?: OnboardingGreetingContext, ): string { if (onboarding) { return buildPersonalizedGreeting(onboarding); } return CANNED_FIRST_GREETING; } /** * Builds a natural self-introduction to send *on behalf of the user* in place * of the wake-up greeting, so the assistant generates a real response instead * of replaying canned copy. Names come from the onboarding context; missing * names are dropped so the line stays natural: * - both: "Hi Vela, I'm alex. Nice to meet you." * - assistant only: "Hi Vela. Nice to meet you." * - user only: "Hi, I'm alex. Nice to meet you." * When neither name is known there is nothing personal to say, so this returns * `undefined` and the caller falls back to the canned greeting. */ export function buildSelfIntroMessage( onboarding?: OnboardingGreetingContext, ): string | undefined { const assistant = onboarding?.assistantName?.trim(); const user = onboarding?.userName?.trim(); if (!assistant && !user) { return undefined; } const hi = assistant ? `Hi ${assistant}` : "Hi"; const intro = user ? `, I'm ${user}` : ""; return `${hi}${intro}. Nice to meet you.`; } const TONE_INTRO_CLOSE: Record = { grounded: "", warm: "Good to meet you.", energetic: "Let's see what you've got.", poetic: "", }; function buildIntroLine( name?: string, assistant?: string, tone: Tone = "grounded", ): string { const greeting = name ? `Hey ${name},` : "Hey,"; const who = assistant ? `I'm ${assistant}.` : ""; const close = assistant ? TONE_INTRO_CLOSE[tone] : ""; return [greeting, who, close].filter(Boolean).join(" "); } // Every greeting variant — the no-onboarding CANNED greeting and all four // personalized tones — ends with a migration offer. The opener (task-vs-talk) // and the migration offer are kept as separate per-tone maps and composed in // `buildInvite`, rather than inlined as one full sentence per variant, so the // offer cannot be silently dropped from a variant when the opener copy is // edited. The first-greeting test asserts every variant still includes it. const TONE_INVITE_OPENER: Record = { grounded: "We can get into whatever you've got, or just talk first — that tends to go better. Up to you.", warm: "We can start on something specific, or just talk for a bit first — honestly that tends to work out better. Either way, I'm here.", energetic: "We can jump straight into whatever you've got, or take a few minutes to just talk first.", poetic: "We can start with whatever's in front of you, or just talk for a bit first. Either way.", }; const TONE_MIGRATION_OFFER: Record = { grounded: "And you don't have to start me from scratch — if you've built up a ChatGPT or Claude, bring it over and I'll learn from it fast. Best head start you can give me.", warm: "And you don't have to start me from scratch — if there's a ChatGPT or Claude that already knows you, bring it over and I'll get up to speed fast. Honestly, it's the best head start you could give me.", energetic: "And you don't have to start me from scratch — if you've built up a ChatGPT or Claude, bring it over and I'll get up to speed fast. Best head start you can give me — want to start there?", poetic: "And you don't have to start me from nothing — if there's a ChatGPT or Claude that already knows you, bring it over and I'll learn from it. The best head start you could give me.", }; const TONE_GOOGLE_SCAN: Record = { grounded: "I can scan Gmail in the background while we talk — just say the word.", warm: "Also — I can scan Gmail in the background while we chat, if you'd like. Just let me know.", energetic: "Oh, and I can scan Gmail in the background right now — want me to?", poetic: "I can also look through Gmail quietly in the background — say the word.", }; function buildInvite(tone: Tone = "grounded"): string { return `${TONE_INVITE_OPENER[tone]} ${TONE_MIGRATION_OFFER[tone]}`; } // Composed from the grounded opener + migration offer so the no-onboarding // greeting reuses the same source as the personalized grounded greeting rather // than duplicating the copy. Defined after the tone maps so they are // initialized before this module-level evaluation runs. export const CANNED_FIRST_GREETING = ["Hey,", "", buildInvite("grounded")].join( "\n", ); const VALID_TONES = new Set([ "grounded", "warm", "energetic", "poetic", ]); function resolveTone(raw?: string): Tone { return raw && VALID_TONES.has(raw) ? (raw as Tone) : "grounded"; } export function buildScanFirstMessage( url: string, variant: "website" | "content-source", ): string { if (variant === "content-source") { return `Here's a page with content I'd like you to look at: ${url}`; } return `Here's my website: ${url}`; } function buildPersonalizedGreeting(ctx: OnboardingGreetingContext): string { const name = ctx.userName?.trim(); const assistant = ctx.assistantName?.trim(); const tone = resolveTone(ctx.tone); if ( !name && !assistant && !VALID_TONES.has(ctx.tone) && !ctx.googleConnected ) { return CANNED_FIRST_GREETING; } const intro = buildIntroLine(name, assistant, tone); const invite = buildInvite(tone); const parts = [intro, "", invite]; if (ctx.googleConnected) { parts.push("", TONE_GOOGLE_SCAN[tone]); } return parts.join("\n"); }