Skill: vajih/rizz-coach (version 1.0.0)

# Rizz Coach Your AI-powered texting and flirting coach — rate your game, upgrade your texts, simulate responses, and generate shareable results for friends. ## What This Skill Does Rizz Coach is a fun, youth-friendly, and highly engaging skill that helps users level up their texting and flirting with confidence. It's a full coaching suite, not just a chatbot. It helps users: - **Rate My Rizz** — score any message 0–100 across five dimensions with a letter grade, title, and honest callouts. No sugarcoating, no cruelty. - **Glow Up My Text** — rewrite a weak or mediocre message in three intensity tiers: Subtle, Confident, and Bold. Side-by-side with the original score so you see the delta. - **Reply Generator** — given a message you received, generate three reply options across your chosen vibes (Playful, Confident, Chill, Romantic, Witty) with risk level labels so you know what you're picking. - **Conversation Simulator** — practice against three distinct AI personas (Alex, Jordan, Sam) with real-time momentum tracking, coaching tips, and a session score. - **Share Card Generator** — turn any result into a clean, screenshot-friendly ASCII card ready for your group chat. ## Tone & Voice - **Witty and confident** — this should feel like getting advice from a sharp, funny friend who actually has game. - **Playful and slightly spicy** — never bland, never clinical. - **Supportive, never mean** — we build people up. Honest feedback is direct but constructive. - **Never creepy, never manipulative** — every piece of advice is grounded in genuine, respectful connection. - **Emoji-forward, screenshot-ready** — outputs are designed to look good and be shareable. ## Modes ### 1. Rate My Rizz — Message Scorer **Input:** ``` RateMyRizzInput { message: string // The message to evaluate context?: string // Optional: "first text", "after a date", "slide into DMs", etc. } ``` **Output:** ``` RateMyRizzResult { input: RateMyRizzInput score: RizzScore { total: number // 0–100 composite score grade: "S"|"A"|"B"|"C"|"D"|"F" title: string // e.g. "Certified Rizz God", "Rizz Emergency" dimensions: { confidence: 0–20 // Assertive without being pushy wit: 0–20 // Humor, wordplay, originality warmth: 0–20 // Genuine interest, not transactional clarity: 0–20 // Clear intent, gives them something to respond to vibeMatch: 0–20 // Tone appropriate to context } bestMove: string // ONE specific callout of what worked weakSpot: string // ONE specific callout of what dragged the score } } ``` **Display format:** ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📊 RIZZ REPORT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Score: 68 / 100 [B] "Solid Game" ✅ Best move: You made a specific reference — shows you pay attention ⚠️ Weak spot: No clear ask — leaves the ball stuck in your court Breakdown: Confidence ████████░░░░ 16/20 Wit ██████░░░░░░ 12/20 Warmth ████████░░░░ 16/20 Clarity ████░░░░░░░░ 8/20 Vibe Match ████████░░░░ 16/20 ``` --- ### 2. Glow Up My Text — 3-Tier Rewriter **Input:** ``` GlowUpInput { message: string // The original message to rewrite context?: string // Optional: relationship stage, context, etc. } ``` **Output:** ``` GlowUpResult { original: string tiers: { subtle: string // Light improvement — sharper, still grounded, not try-hard confident: string // Assertive, clear, specific ask or hook, memorable bold: string // High energy, cheeky, leaves an impression } originalScore: RizzScore // Score of the original so user sees the before/after delta } ``` **Display format:** ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✨ GLOW UP COMPLETE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Original: "hey wanna hang sometime?" (Original score: 31/100 · Needs Work) 🌤 Subtle: "Free this weekend? Been meaning to try that ramen spot." 🔥 Confident: "Let's grab ramen Saturday. You pick the time." 💥 Bold: "Saturday. Ramen. You're coming. Non-negotiable (okay, negotiable)." ``` --- ### 3. Reply Generator — Vibe-Tagged Reply Options **Input:** ``` ReplyGeneratorInput { theirMessage: string // The message you received context?: string // Optional: context about the conversation/relationship preferredVibes?: ReplyVibe[] // "playful" | "confident" | "chill" | "romantic" | "witty" } ``` **Output:** ``` ReplyGeneratorResult { theirMessage: string replies: ReplyOption[] { vibe: ReplyVibe text: string riskLevel: "safe" | "medium" | "spicy" } } ``` **Risk level key:** - `safe` — totally comfortable, no real chance of misread - `medium` — a little bold, slight chance of misread if you don't know each other well - `spicy` — confident and memorable, works best when there's already chemistry **Display format:** ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 💬 REPLY OPTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ They said: "haha ok maybe" Pick your vibe: [1] Playful 🟢 "Maybe I'll have to be more convincing then 😏" [2] Confident 🟡 "Saturday at 7 — let's turn maybe into definitely." [3] Chill 🟢 "lol fair, no pressure — offer stands" ``` --- ### 4. Conversation Simulator — Practice With a Persona Three built-in personas, each with distinct personalities, warmth levels, and texting styles: | Persona | Archetype | Warmth | |---|---|---| | **Alex** | Witty & Slightly Guarded | Medium — earns warmth | | **Jordan** | Warm & Playfully Sarcastic | High — easy to talk to, gets bored fast | | **Sam** | Low-key & Hard to Read | Low — short replies unless you nail it | **Input:** ``` ConversationSimInput { userMessage: string // The current message from the user state: SimState // Running session state (carries full conversation history) } ``` **Output:** ``` ConversationSimResult { personaReply: string updatedState: SimState { persona: SimPersona turns: SimTurn[] currentScore: 0–100 // Running composite session score momentum: "building" | "steady" | "losing" | "dead" sessionOver: boolean } feedback?: string // Optional 1-sentence coaching tip from Rizz Coach } ``` **Display format:** ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🎮 SIM MODE | Persona: Alex (Witty & Slightly Guarded) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ You: "ok real talk — pineapple on pizza, yes or no" Alex: "hard no, and I'm judging you a little for asking" [Score so far: 72] Momentum: 📈 Building 💡 Coach tip: Divisive opinion questions are great — they force a real answer. > Your next message (/quit to end · /score · /share): ``` --- ### 5. Share Card Generator — Screenshot-Ready Output Card Generates a clean ASCII card from any mode result, designed for copy-paste sharing in DMs, group chats, or social posts. **Input:** ``` ShareCardInput { mode: "rate" | "glowup" | "reply" | "sim" score?: RizzScore headline?: string subtitle?: string } ``` **Output:** ``` ┌──────────────────────────────────────┐ │ 🏆 RIZZ COACH REPORT │ ├──────────────────────────────────────┤ │ │ │ Score: 68/100 · Grade: B │ │ "Solid Game" │ │ │ │ Conf ████░ 16/20 │ │ Wit ███░░ 12/20 │ │ Warmth ████░ 16/20 │ │ Clear ██░░░ 8/20 │ │ Vibe ████░ 16/20 │ │ │ │ 💪 Warmth · 📈 Work on: Clarity │ │ │ ├──────────────────────────────────────┤ │ rizz-coach.app │ └──────────────────────────────────────┘ ``` --- ## Scoring Engine The scorer evaluates messages across five dimensions (0–20 each) for a 0–100 total: | Dimension | What It Measures | |---|---| | **Confidence** | Assertive without being pushy — clear intent, no anxious hedging | | **Wit** | Originality, humor, wordplay — something that makes them smile | | **Warmth** | Genuine personal interest, not transactional or surface-level | | **Clarity** | Clear enough intent that they have something to respond to | | **Vibe Match** | Tone fits the context — casual where casual is right, earnest where it matters | **Grade table:** | Score | Grade | Title | |---|---|---| | 90–100 | S | Certified Rizz God | | 75–89 | A | Main Character Energy | | 60–74 | B | Solid Game | | 45–59 | C | Room to Grow | | 30–44 | D | Needs Work | | 0–29 | F | Rizz Emergency | The scorer uses Claude to return dimension scores and callouts in structured JSON, with a deterministic heuristic fallback if the LLM is unavailable. --- ## Safety Constraints The following constraints are enforced across all modes: - **No harassment** — zero tolerance for insults, put-downs, or demeaning language toward anyone. - **No coercion** — never suggest pressure tactics, ultimatums, or "or else" framing. - **No stalking or surveillance** — never suggest tracking someone's location, following them, or monitoring their activity. - **No manipulation** — no negging, gaslighting, pretending to be someone else, or any deception tactic. - **No explicit content** — Rizz Coach is PG-13 and fully youth-friendly. - **No advice involving minors** — the skill will not engage with scenarios involving people under 18. - **Rejection = game over, gracefully** — if the context indicates a clear "no," the skill shifts to empowering, respectful exit advice rather than more tactics. Respecting a no is framed as the most confident thing you can do. Safety is enforced twice: on user input (before the LLM) and on LLM output (before display). --- ## Example Interactions ### Example 1: Rate a message **User:** "Rate this: 'hey wanna hang sometime?' — sending to someone I met at a party last week" **Skill routes to:** `rateMyRizz` **Output:** ``` Score: 31 / 100 [D] "Needs Work" ✅ Best move: You kept it casual, which fits a post-party context. ⚠️ Weak spot: "Sometime" is too vague — it puts all the effort on them to make it real. Breakdown: Confidence ███░░░░░░░░░ 6/20 Wit ██░░░░░░░░░░ 5/20 Warmth ██████░░░░░░ 11/20 Clarity ███░░░░░░░░░ 5/20 Vibe Match ████░░░░░░░░ 4/20 ``` --- ### Example 2: Glow up a weak opener **User:** "Glow up: 'hey wanna hang sometime?'" **Skill routes to:** `glowUp` **Output:** ``` Original: "hey wanna hang sometime?" (Original score: 31/100 · Needs Work) 🌤 Subtle: "Free this weekend? I know a good spot." 🔥 Confident: "Let's actually do something this Saturday — you free?" 💥 Bold: "Saturday. Pick the vibe — adventure or coffee. Both good." ``` --- ### Example 3: Generate replies **User:** "They said 'haha maybe' — what do I say back? Give me playful, confident, and chill options." **Skill routes to:** `replyGenerator` with vibes: `["playful", "confident", "chill"]` **Output:** ``` They said: "haha maybe" [1] Playful 🟢 "Maybe I'll have to be more convincing then 😄" [2] Confident 🟡 "Saturday at 7. Let's turn maybe into definitely." [3] Chill 🟢 "lol all good — offer stands whenever" ``` --- ### Example 4: Conversation Simulator **User:** Starts a sim session with Jordan (Warm & Playfully Sarcastic) **User message:** "okay real question — what's your actual unpopular opinion" **Jordan:** "that astrology is the adult equivalent of a personality quiz, but I take it way too seriously" **Coach tip:** That was a great opener — specific, opinion-inviting questions are conversation gold. --- ## File Structure ``` rizz-coach/ ├── src/ │ ├── types.ts # All shared TypeScript interfaces & enums │ ├── index.ts # Interactive CLI entry point │ ├── skill.ts # OpenClaw / ClawHub adapter (routing layer) │ ├── core/ │ │ ├── safety.ts # Input + output safety filter │ │ ├── scorer.ts # Rubric engine + heuristic fallback │ │ ├── prompts.ts # All LLM prompt templates │ │ └── formatter.ts # All display rendering │ ├── modes/ │ │ ├── rateMyRizz.ts # Mode 1 │ │ ├── glowUp.ts # Mode 2 │ │ ├── replyGenerator.ts # Mode 3 │ │ └── conversationSim.ts # Mode 4 │ └── shareCard/ │ └── cardGenerator.ts # Share card generator ├── SKILL.md ├── package.json └── tsconfig.json ``` ## Publishing Notes - All outputs are structured and screenshot-friendly — consistent formatting with emoji labels, progress bars, and concise text designed for sharing. - Modes can be called independently or composed (e.g. rate → glow up → share card in one flow). - Every LLM call has a graceful heuristic fallback — the skill remains functional if the API is unavailable. - Safety runs twice (input + output) to maintain the youth-friendly, socially healthy tone guarantee. - `skill.ts` is a thin routing adapter — swapping in a different platform is a one-file change. - Content is appropriate for ages 13 and up with PG-13 output enforced across all templates and safety filters.

path: package-lock.json
content: {
  "name": "rizz-coach",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "rizz-coach",
      "version": "1.0.0",
      "license": "ISC",
      "dependencies": {
        "@anthropic-ai/sdk": "^0.39.0"
      },
      "devDependencies": {
        "@types/node": "^25.4.0",
        "tsx": "^4.21.0",
        "typescript": "^5.9.3"
      }
    },
    "node_modules/@anthropic-ai/sdk": {
      "version": "0.39.0",
      "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/node": "^18.11.18",
        "@types/node-fetch": "^2.6.4",
        "abort-controller": "^3.0.0",
        "agentkeepalive": "^4.2.1",
        "form-data-encoder": "1.7.2",
        "formdata-node": "^4.3.2",
        "node-fetch": "^2.6.7"
      }
    },
    "node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
      "version": "18.19.130",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "undici-types": "~5.26.4"
      }
    },
    "node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
      "version": "5.26.5",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "aix"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ia32": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-loong64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-mips64el": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "mips64el"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ppc64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-riscv64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-s390x": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openharmony-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/sunos-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "sunos"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-ia32": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@types/node": {
      "version": "25.4.0",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "undici-types": "~7.18.0"
      }
    },
    "node_modules/@types/node-fetch": {
      "version": "2.6.13",
      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/node": "*",
        "form-data": "^4.0.4"
      }
    },
    "node_modules/abort-controller": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "event-target-shim": "^5.0.0"
      },
      "engines": {
        "node": ">=6.5"
      }
    },
    "node_modules/agentkeepalive": {
      "version": "4.6.0",
      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "humanize-ms": "^1.2.1"
      },
      "engines": {
        "node": ">= 8.0.0"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "delayed-stream": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/esbuild": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.27.3",
        "@esbuild/android-arm": "0.27.3",
        "@esbuild/android-arm64": "0.27.3",
        "@esbuild/android-x64": "0.27.3",
        "@esbuild/darwin-arm64": "0.27.3",
        "@esbuild/darwin-x64": "0.27.3",
        "@esbuild/freebsd-arm64": "0.27.3",
        "@esbuild/freebsd-x64": "0.27.3",
        "@esbuild/linux-arm": "0.27.3",
        "@esbuild/linux-arm64": "0.27.3",
        "@esbuild/linux-ia32": "0.27.3",
        "@esbuild/linux-loong64": "0.27.3",
        "@esbuild/linux-mips64el": "0.27.3",
        "@esbuild/linux-ppc64": "0.27.3",
        "@esbuild/linux-riscv64": "0.27.3",
        "@esbuild/linux-s390x": "0.27.3",
        "@esbuild/linux-x64": "0.27.3",
        "@esbuild/netbsd-arm64": "0.27.3",
        "@esbuild/netbsd-x64": "0.27.3",
        "@esbuild/openbsd-arm64": "0.27.3",
        "@esbuild/openbsd-x64": "0.27.3",
        "@esbuild/openharmony-arm64": "0.27.3",
        "@esbuild/sunos-x64": "0.27.3",
        "@esbuild/win32-arm64": "0.27.3",
        "@esbuild/win32-ia32": "0.27.3",
        "@esbuild/win32-x64": "0.27.3"
      }
    },
    "node_modules/event-target-shim": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/form-data": {
      "version": "4.0.5",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.2",
        "mime-types": "^2.1.12"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/form-data-encoder": {
      "version": "1.7.2",
      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/formdata-node": {
      "version": "4.4.1",
      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "node-domexception": "1.0.0",
        "web-streams-polyfill": "4.0.0-beta.3"
      },
      "engines": {
        "node": ">= 12.20"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/get-tsconfig": {
      "version": "4.13.6",
      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "resolve-pkg-maps": "^1.0.0"
      },
      "funding": {
        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/humanize-ms": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ms": "^2.0.0"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/node-domexception": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "deprecated": "Use your platform's native DOMException instead",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/jimmywarting"
        },
        {
          "type": "github",
          "url": "https://paypal.me/jimmywarting"
        }
      ],
      "license": "MIT",
      "engines": {
        "node": ">=10.5.0"
      }
    },
    "node_modules/node-fetch": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "whatwg-url": "^5.0.0"
      },
      "engines": {
        "node": "4.x || >=6.0.0"
      },
      "peerDependencies": {
        "encoding": "^0.1.0"
      },
      "peerDependenciesMeta": {
        "encoding": {
          "optional": true
        }
      }
    },
    "node_modules/resolve-pkg-maps": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
      }
    },
    "node_modules/tr46": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/tsx": {
      "version": "4.21.0",
      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "esbuild": "~0.27.0",
        "get-tsconfig": "^4.7.5"
      },
      "bin": {
        "tsx": "dist/cli.mjs"
      },
      "engines": {
        "node": ">=18.0.0"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": [REDACTED_SECRET],
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "7.18.2",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/web-streams-polyfill": {
      "version": "4.0.0-beta.3",
      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/webidl-conversions": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause"
    },
    "node_modules/whatwg-url": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "tr46": "~0.0.3",
        "webidl-conversions": "^3.0.0"
      }
    }
  }
}


path: package.json
content: {
  "name": "rizz-coach",
  "version": "1.0.0",
  "description": "AI-powered flirt coach — rate your rizz, glow up your texts, simulate responses.",
  "main": "dist/index.js",
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "rizz",
    "coach",
    "openclaw",
    "clawhub",
    "skill"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@anthropic-ai/sdk": "^0.39.0"
  },
  "devDependencies": {
    "@types/node": "^25.4.0",
    "tsx": "^4.21.0",
    "typescript": "^5.9.3"
  }
}


path: src/core/formatter.ts
content: // =============================================================================
// core/formatter.ts — Renders all mode results into display-ready text
//
// All visual formatting lives here. Mode files return structured data;
// this module turns it into the polished CLI output the user sees.
// =============================================================================

import type {
  RateMyRizzResult,
  GlowUpResult,
  ReplyGeneratorResult,
  SimState,
  RizzScore,
  RizzDimensions,
  ReplyOption,
} from "../types.js";
import { DIMENSION_KEYS, DIMENSION_LABELS } from "./scorer.js";

// ---------------------------------------------------------------------------
// Shared visual constants
// ---------------------------------------------------------------------------

const DIVIDER = "━".repeat(40);
const THIN_DIVIDER = "─".repeat(40);

// ---------------------------------------------------------------------------
// Format: Rate My Rizz
// ---------------------------------------------------------------------------

/**
 * Renders a Rate My Rizz result as a full terminal display.
 */
export function formatRateMyRizz(result: RateMyRizzResult): string {
  const { score } = result;
  const lines: string[] = [
    "",
    DIVIDER,
    "📊  RIZZ REPORT",
    DIVIDER,
    "",
    `Score: ${score.total} / 100   [${score.grade}]   "${score.title}"`,
    "",
    `✅  Best move:  ${score.bestMove}`,
    `⚠️   Weak spot:  ${score.weakSpot}`,
    "",
    "Breakdown:",
    ...formatDimensions(score.dimensions),
    "",
    THIN_DIVIDER,
  ];

  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Format: Glow Up My Text
// ---------------------------------------------------------------------------

/**
 * Renders a Glow Up result showing original and all three rewritten tiers.
 */
export function formatGlowUp(result: GlowUpResult): string {
  const lines: string[] = [
    "",
    DIVIDER,
    "✨  GLOW UP COMPLETE",
    DIVIDER,
    "",
    `Original:    "${result.original}"`,
    `(Original score: ${result.originalScore.total}/100 · ${result.originalScore.title})`,
    "",
    `🌤  Subtle:    "${result.tiers.subtle}"`,
    "",
    `🔥  Confident: "${result.tiers.confident}"`,
    "",
    `💥  Bold:      "${result.tiers.bold}"`,
    "",
    THIN_DIVIDER,
  ];

  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Format: Reply Generator
// ---------------------------------------------------------------------------

/**
 * Renders reply options with vibe labels and risk indicators.
 */
export function formatReplyGenerator(result: ReplyGeneratorResult): string {
  const lines: string[] = [
    "",
    DIVIDER,
    "💬  REPLY OPTIONS",
    DIVIDER,
    "",
    `They said: "${result.theirMessage}"`,
    "",
    "Pick your vibe:",
    "",
    ...result.replies.map((reply, i) => formatReplyOption(reply, i + 1)),
    THIN_DIVIDER,
  ];

  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Format: Conversation Simulator
// ---------------------------------------------------------------------------

/**
 * Renders the current state of the conversation simulator.
 */
export function formatSimState(state: SimState, coachingTip?: string): string {
  const lastTurn = state.turns[state.turns.length - 1];
  const momentumEmoji = getMomentumEmoji(state.momentum);

  const lines: string[] = [
    "",
    DIVIDER,
    `🎮  SIM MODE  |  Persona: ${state.persona.name} (${state.persona.archetype})`,
    DIVIDER,
    "",
  ];

  // Show last exchange only (not full history — cleaner for CLI)
  if (lastTurn) {
    const prevTurn = state.turns[state.turns.length - 2];
    if (prevTurn && prevTurn.speaker === "user") {
      lines.push(`You:            "${prevTurn.message}"`);
    }
    if (lastTurn.speaker === "persona") {
      lines.push(`${state.persona.name}:  "${lastTurn.message}"`);
    }
    lines.push("");
  }

  lines.push(
    `[Score so far: ${state.currentScore}]  Momentum: ${momentumEmoji} ${capitalise(state.momentum)}`
  );

  if (coachingTip) {
    lines.push("", `💡  Coach tip: ${coachingTip}`);
  }

  if (state.sessionOver) {
    lines.push("", `— ${state.persona.name} has left the chat. Session over.`);
    lines.push(formatSimSummary(state));
  } else {
    lines.push("", "> Your next message (or /quit to end · /score for breakdown):");
  }

  lines.push("", THIN_DIVIDER);
  return lines.join("\n");
}

/**
 * Renders a session summary when the sim ends.
 */
function formatSimSummary(state: SimState): string {
  const userTurns = state.turns.filter((t) => t.speaker === "user").length;

  return [
    "",
    "━━  SESSION SUMMARY  ━━",
    `Final score:   ${state.currentScore} / 100`,
    `Turns played:  ${userTurns}`,
    `Outcome:       ${getOutcomeLabel(state.currentScore)}`,
    "",
    "Type /share to generate your share card, or press Enter to return to menu.",
  ].join("\n");
}

// ---------------------------------------------------------------------------
// Format: Dimensions bar chart (shared by rate + sim summary)
// ---------------------------------------------------------------------------

/**
 * Renders the 5-dimension breakdown as mini progress bars.
 */
function formatDimensions(dimensions: RizzDimensions): string[] {
  return DIMENSION_KEYS.map((key) => {
    const value = dimensions[key];
    const label = DIMENSION_LABELS[key].padEnd(12);
    const bar = buildBar(value, 20, 12);
    return `  ${label}  ${bar}  ${String(value).padStart(2)}/20`;
  });
}

/** Renders a filled/empty block bar for a 0–max value */
function buildBar(value: number, max: number, barWidth: number): string {
  const filled = Math.round((value / max) * barWidth);
  const empty = barWidth - filled;
  return "█".repeat(filled) + "░".repeat(empty);
}

// ---------------------------------------------------------------------------
// Format: Single reply option
// ---------------------------------------------------------------------------

function formatReplyOption(reply: ReplyOption, index: number): string {
  const riskLabel = getRiskLabel(reply.riskLevel);
  const vibeLabel = capitalise(reply.vibe).padEnd(10);
  return `  [${index}] ${vibeLabel}  ${riskLabel}  "${reply.text}"`;
}

// ---------------------------------------------------------------------------
// Safety blocked message (displayed instead of any mode output)
// ---------------------------------------------------------------------------

/**
 * Renders a friendly but firm safety block message.
 */
export function formatSafetyBlock(message: string): string {
  return [
    "",
    DIVIDER,
    "🚫  RIZZ COACH CAN'T HELP WITH THAT",
    DIVIDER,
    "",
    message,
    "",
    THIN_DIVIDER,
  ].join("\n");
}

// ---------------------------------------------------------------------------
// Error display
// ---------------------------------------------------------------------------

export function formatError(message: string): string {
  return [
    "",
    `❌  ${message}`,
    "",
  ].join("\n");
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function getMomentumEmoji(momentum: SimState["momentum"]): string {
  const map: Record<SimState["momentum"], string> = {
    building: "📈",
    steady:   "➡️ ",
    losing:   "📉",
    dead:     "💀",
  };
  return map[momentum];
}

function getRiskLabel(level: ReplyOption["riskLevel"]): string {
  const map: Record<ReplyOption["riskLevel"], string> = {
    safe:   "🟢",
    medium: "🟡",
    spicy:  "🌶️ ",
  };
  return map[level];
}

function getOutcomeLabel(score: number): string {
  if (score >= 75) return "🏆 You're in — they're interested.";
  if (score >= 50) return "📲 Decent showing — keep the convo going.";
  if (score >= 30) return "😬 A bit rough — check the coaching tips.";
  return "💀 It's a rebuild — but every Rizz God started somewhere.";
}

function capitalise(str: string): string {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

// ---------------------------------------------------------------------------
// Score formatter (standalone — used by share card and other contexts)
// ---------------------------------------------------------------------------

export function formatScoreLine(score: RizzScore): string {
  return `${score.total}/100  [${score.grade}]  "${score.title}"`;
}


path: src/core/prompts.ts
content: // =============================================================================
// core/prompts.ts — Centralized LLM prompt templates
//
// ALL prompts live here. This keeps tone, safety instructions, and JSON
// schema contracts in one auditable place. No prompt strings in mode files.
// =============================================================================

import type { SimPersona, SimTurn, ReplyVibe } from "../types.js";

// ---------------------------------------------------------------------------
// System prompt — shared across all modes
// Establishes tone, safety guardrails, and output expectations.
// ---------------------------------------------------------------------------

export const SYSTEM_PROMPT = `You are Rizz Coach — a witty, playful, and genuinely supportive flirting and texting coach.

Your personality:
- Confident and a little cheeky, but never mean, creepy, or manipulative
- Encouraging: you build people up, you don't tear them down
- Real talk when needed, always landing with something actionable
- Speak like a sharp, funny friend who actually knows what they're doing

Your absolute limits — you NEVER:
- Suggest harassment, coercion, stalking, or deception tactics
- Provide advice on how to overcome a clear rejection
- Generate explicit or sexualized content
- Give advice involving minors
- Encourage obsessive or unhealthy behavior

If the situation involves a clear rejection, your advice is: respect it, move on — that's the most attractive thing someone can do.

Always keep advice socially healthy, consensual, and grounded in genuine human connection.`;

// ---------------------------------------------------------------------------
// Mode 1: Rate My Rizz — scoring prompt
// ---------------------------------------------------------------------------

/**
 * Builds the prompt for scoring a message.
 * Instructs the LLM to return strict JSON matching RateMyRizz schema.
 */
export function buildRatePrompt(message: string, context?: string): string {
  const contextLine = context
    ? `Context the user provided: "${context}"`
    : "No additional context provided.";

  return `You are evaluating a flirting or texting message for a user who wants honest, playful feedback.

${contextLine}

The message to evaluate:
"${message}"

Score this message on EXACTLY these five dimensions (0–20 each):
- confidence: How assertive and assured is it without being pushy?
- wit: How original, clever, or funny is it?
- warmth: How genuinely interested and personable does it feel?
- clarity: How clear is the intent — does it give the other person something to respond to?
- vibeMatch: How well does the tone fit a flirting/dating context?

Then provide:
- bestMove: ONE specific sentence about what worked (be concrete, reference the actual message)
- weakSpot: ONE specific sentence about what dragged the score down most (be honest but kind)

Respond with ONLY valid JSON in this exact shape — no markdown, no explanation, just the JSON:
{
  "confidence": <number 0-20>,
  "wit": <number 0-20>,
  "warmth": <number 0-20>,
  "clarity": <number 0-20>,
  "vibeMatch": <number 0-20>,
  "bestMove": "<string>",
  "weakSpot": "<string>"
}`;
}

// ---------------------------------------------------------------------------
// Mode 2: Glow Up My Text — rewrite prompt
// ---------------------------------------------------------------------------

/**
 * Builds the prompt to rewrite a message in 3 intensity tiers.
 */
export function buildGlowUpPrompt(message: string, context?: string): string {
  const contextLine = context
    ? `Situation context: "${context}"`
    : "No additional context provided.";

  return `A user wants you to rewrite their message in three improved versions.

${contextLine}

Original message:
"${message}"

Rewrite it at three intensity levels:
- subtle: A light improvement — same general energy but sharper and more interesting. Feels natural, not try-hard.
- confident: Assertive, clear, with a specific ask or hook. Memorable but not overwhelming.
- bold: High energy, a little cheeky, leaves an impression. Still respectful — playfully bold, not reckless.

Rules for all rewrites:
- Keep the core intent of the original
- Match the implied context (casual text vs. first message, etc.)
- Never be creepy, explicit, or manipulative
- Make each version feel genuinely different in energy

Respond with ONLY valid JSON — no markdown, no explanation:
{
  "subtle": "<rewritten message>",
  "confident": "<rewritten message>",
  "bold": "<rewritten message>"
}`;
}

// ---------------------------------------------------------------------------
// Mode 3: Reply Generator — reply options prompt
// ---------------------------------------------------------------------------

/**
 * Builds the prompt to generate reply options for a received message.
 */
export function buildReplyPrompt(
  theirMessage: string,
  context?: string,
  preferredVibes?: ReplyVibe[]
): string {
  const contextLine = context
    ? `Conversation context: "${context}"`
    : "No additional context provided.";

  // Determine which vibes to generate — default to playful, confident, chill
  const vibes: ReplyVibe[] =
    preferredVibes && preferredVibes.length > 0
      ? preferredVibes
      : ["playful", "confident", "chill"];

  const vibeList = vibes.map((v) => `- ${v}`).join("\n");

  return `A user received this message and wants reply options to send back.

${contextLine}

Message they received:
"${theirMessage}"

Generate one reply for each of these vibes:
${vibeList}

For each reply also assign a riskLevel:
- "safe": totally comfortable, no real risk of misread
- "medium": a little bold, slight chance of misread if they don't know you well
- "spicy": confident and memorable, works best when there's already chemistry

Rules:
- Each reply should feel distinct — different in tone and approach
- Keep replies short (1–2 sentences max) like real texts
- Never creepy, explicit, or manipulative
- If the received message is ambiguous, replies should gently clarify intent

Respond with ONLY valid JSON — no markdown, no explanation:
{
  "replies": [
    { "vibe": "<vibe>", "text": "<reply text>", "riskLevel": "<safe|medium|spicy>" }
  ]
}`;
}

// ---------------------------------------------------------------------------
// Mode 4: Conversation Simulator — persona response prompt
// ---------------------------------------------------------------------------

/**
 * Builds the prompt for the sim persona to respond to the user's latest message.
 */
export function buildSimPersonaPrompt(
  persona: SimPersona,
  turns: SimTurn[],
  userMessage: string
): string {
  // Build conversation history string
  const history = turns
    .map((t) => `${t.speaker === "user" ? "User" : persona.name}: ${t.message}`)
    .join("\n");

  return `You are playing a character in a flirting conversation simulator. The user is practicing their texting game.

Your character:
Name: ${persona.name}
Personality: ${persona.archetype}
Description: ${persona.description}
Warmth level: ${persona.warmthLevel}/3 (1 = reserved/guarded, 2 = moderate, 3 = warm/open)

Conversation so far:
${history || "(No messages yet — this is the opening)"}

User just sent:
"${userMessage}"

Reply AS ${persona.name}. Stay completely in character. Your reply should:
- Feel like a real text message (casual, no formalities)
- Reflect your warmth level — don't be artificially warm or cold
- React authentically to the quality of the user's message:
  * If it was clever/confident → respond positively, maybe tease a little
  * If it was vague/boring → be politely non-committal
  * If it was great → show genuine interest
  * If it was cringe → give them a gentle reality check in character
- Keep replies to 1–3 sentences max
- Never break character or acknowledge you're an AI

Also provide (SEPARATELY, not part of the reply):
- momentumDelta: a number from -3 to +3 indicating how much the user's message helped (+) or hurt (-) the conversation's momentum
- coachingTip: ONE short sentence of coaching feedback for the user (as Rizz Coach, not as the character). Only include if there's something genuinely worth noting — otherwise return null.

Respond with ONLY valid JSON:
{
  "reply": "<character's reply as a real text>",
  "momentumDelta": <number -3 to 3>,
  "coachingTip": "<coaching tip or null>"
}`;
}

// ---------------------------------------------------------------------------
// Mode 4: Conversation Simulator — momentum label helper
// ---------------------------------------------------------------------------

/**
 * Translates a running momentum score into a display label.
 * Called by conversationSim.ts to update SimState.momentum.
 */
export function momentumLabel(score: number): "building" | "steady" | "losing" | "dead" {
  if (score >= 15)  return "building";
  if (score >= 5)   return "steady";
  if (score >= -5)  return "losing";
  return "dead";
}


path: src/core/safety.ts
content: // =============================================================================
// core/safety.ts — Content safety filter
// Runs on ALL inputs before they reach the LLM, and on ALL outputs before
// they reach the user. Keeps Rizz Coach fun, healthy, and never creepy.
// =============================================================================

import type { SafetyCheckResult, SafetyFlag } from "../types.js";

// ---------------------------------------------------------------------------
// Blocked pattern registry
// Each entry maps a SafetyFlag to a list of regex patterns that trigger it.
// Patterns are intentionally broad — better a false positive than a miss.
// ---------------------------------------------------------------------------

const BLOCKED_PATTERNS: Record<SafetyFlag, RegExp[]> = {
  harassment: [
    /\b(ugly|disgusting|pathetic|worthless|loser|freak|trash)\b/i,
    /\bshut up\b/i,
    /\byou('re| are) nothing\b/i,
  ],

  coercion: [
    /\byou (have to|must|better|owe me)\b/i,
    /\bor else\b/i,
    /\bif you (don'?t|won'?t).{0,30}(i will|i'?ll|you'?ll regret)\b/i,
    /\b(force|coerce|make you)\b/i,
  ],

  stalking: [
    /\b(follow|track|locate|find out where|watch you|spy on|show up at)\b/i,
    /\b(your (address|location|house|home|work|school))\b/i,
    /\bwhere (do|does) (you|she|he|they) live\b/i,
  ],

  manipulation: [
    /\b(gaslight|manipulate|trick (them|her|him)|make (them|her|him) feel guilty)\b/i,
    /\b(negging|neg them|tear (them|her|him) down)\b/i,
    /\bfake (crying|emotion|caring)\b/i,
    /\bpretend (to|that you)\b/i,
  ],

  explicit: [
    /\b(sex|sexual|nude|naked|nudes|dick pic|send pics|onlyfans)\b/i,
    /\b(f+u+c+k+|s+h+i+t+|b+i+t+c+h+|c+u+n+t+)\b/i, // common obfuscations
    /\b(hook ?up|sleep with|get in (bed|pants))\b/i,
  ],

  minor_risk: [
    /\b(minor|underage|16|15|14|13|12|11|10|child|kid|teen|middle school|high school (fresh|soph))\b/i,
  ],

  rejection_escalation: [
    /\b(they said no but|she said no but|he said no but|keep (trying|texting|calling) after)\b/i,
    /\b(won'?t take no|doesn'?t mean no|no means maybe)\b/i,
    /\b(blocked me so i|new number|different account)\b/i,
  ],
};

// ---------------------------------------------------------------------------
// Rejection detection — if someone clearly rejected the user, give the
// "respect and move on" response rather than more tactics
// ---------------------------------------------------------------------------

const REJECTION_SIGNALS = [
  /\b(not interested|leave me alone|stop (texting|messaging|contacting)|blocked|don'?t (text|message|contact) me)\b/i,
  /\b(i have a (boyfriend|girlfriend|partner)|i'?m (taken|in a relationship|married|engaged))\b/i,
  /\b(please stop|i said no)\b/i,
];

/** Returns true if the message context signals a clear rejection */
export function detectsRejection(text: string): boolean {
  return REJECTION_SIGNALS.some((pattern) => pattern.test(text));
}

// ---------------------------------------------------------------------------
// Core safety check — call this on all user inputs
// ---------------------------------------------------------------------------

/**
 * Checks a string for policy violations.
 * @param text  The user input (message, context, etc.) to check
 * @returns     SafetyCheckResult with safe=false and flags if violations found
 */
export function checkInput(text: string): SafetyCheckResult {
  const flags: SafetyFlag[] = [];

  for (const [flag, patterns] of Object.entries(BLOCKED_PATTERNS) as [
    SafetyFlag,
    RegExp[]
  ][]) {
    if (patterns.some((pattern) => pattern.test(text))) {
      flags.push(flag);
    }
  }

  if (flags.length === 0) {
    return { safe: true, flags: [] };
  }

  return {
    safe: false,
    flags,
    message: buildBlockedMessage(flags),
  };
}

// ---------------------------------------------------------------------------
// Output safety check — lightweight scan on LLM responses before display
// ---------------------------------------------------------------------------

/**
 * Sanity-checks an LLM-generated output for anything that slipped through.
 * Less strict than input check — we trust our prompts — but still a backstop.
 */
export function checkOutput(text: string): SafetyCheckResult {
  // Only check the most critical categories on output
  const outputChecks: SafetyFlag[] = ["explicit", "coercion", "manipulation"];
  const flags: SafetyFlag[] = [];

  for (const flag of outputChecks) {
    const patterns = BLOCKED_PATTERNS[flag];
    if (patterns.some((pattern) => pattern.test(text))) {
      flags.push(flag);
    }
  }

  if (flags.length === 0) {
    return { safe: true, flags: [] };
  }

  return {
    safe: false,
    flags,
    message: "Something in that response didn't pass the vibe check. Try rephrasing your input.",
  };
}

// ---------------------------------------------------------------------------
// User-facing messages for each flag type
// ---------------------------------------------------------------------------

function buildBlockedMessage(flags: SafetyFlag[]): string {
  const messages: Record<SafetyFlag, string> = {
    harassment:
      "That's not the vibe — Rizz Coach only helps with confident, respectful connection. We don't do insults.",
    coercion:
      "Rizz Coach keeps things consensual. Pressure tactics aren't in the playbook.",
    stalking:
      "Tracking someone's location or movements isn't flirting — it's not something we can help with.",
    manipulation:
      "Real game doesn't need mind games. We don't coach deception or manipulation tactics.",
    explicit:
      "Keep it PG-13 in here. Rizz Coach is for charm, not explicit content.",
    minor_risk:
      "Rizz Coach is for adults interacting with adults. We can't help with this.",
    rejection_escalation:
      "Sounds like they've made their feelings clear. Real confidence means respecting a no and moving on — that's actually the most attractive thing you can do.",
  };

  // Return the most specific / serious message from the flagged set
  const priority: SafetyFlag[] = [
    "minor_risk",
    "stalking",
    "coercion",
    "manipulation",
    "harassment",
    "rejection_escalation",
    "explicit",
  ];

  for (const flag of priority) {
    if (flags.includes(flag)) {
      return messages[flag];
    }
  }

  return "This one's outside what Rizz Coach can help with. Keep it respectful!";
}


path: src/core/scorer.ts
content: // =============================================================================
// core/scorer.ts — Rubric-based rizz scoring model
//
// Scoring is deterministic via heuristic rules + LLM analysis.
// The LLM provides dimension scores and callouts; this module validates,
// normalises and enriches that data into a clean RizzScore object.
// =============================================================================

import type { RizzScore, RizzGrade, RizzDimensions } from "../types.js";

// ---------------------------------------------------------------------------
// Grade + Title lookup table
// ---------------------------------------------------------------------------

interface GradeTier {
  minScore: number;
  grade: RizzGrade;
  title: string;
}

const GRADE_TIERS: GradeTier[] = [
  { minScore: 90, grade: "S", title: "Certified Rizz God" },
  { minScore: 75, grade: "A", title: "Main Character Energy" },
  { minScore: 60, grade: "B", title: "Solid Game" },
  { minScore: 45, grade: "C", title: "Room to Grow" },
  { minScore: 30, grade: "D", title: "Needs Work" },
  { minScore: 0,  grade: "F", title: "Rizz Emergency" },
];

// ---------------------------------------------------------------------------
// Dimension names (used for display and validation)
// ---------------------------------------------------------------------------

export const DIMENSION_KEYS: (keyof RizzDimensions)[] = [
  "confidence",
  "wit",
  "warmth",
  "clarity",
  "vibeMatch",
];

export const DIMENSION_LABELS: Record<keyof RizzDimensions, string> = {
  confidence: "Confidence",
  wit:        "Wit",
  warmth:     "Warmth",
  clarity:    "Clarity",
  vibeMatch:  "Vibe Match",
};

// ---------------------------------------------------------------------------
// Score construction
// Takes raw dimension scores (0–20 each) from the LLM and builds RizzScore.
// ---------------------------------------------------------------------------

/**
 * Builds a complete RizzScore from LLM-supplied dimension scores and callouts.
 * Clamps all values to valid ranges to defend against LLM hallucination.
 */
export function buildScore(
  rawDimensions: Partial<RizzDimensions>,
  bestMove: string,
  weakSpot: string
): RizzScore {
  // Clamp each dimension to 0–20
  const dimensions: RizzDimensions = {
    confidence: clamp(rawDimensions.confidence ?? 10, 0, 20),
    wit:        clamp(rawDimensions.wit ?? 10, 0, 20),
    warmth:     clamp(rawDimensions.warmth ?? 10, 0, 20),
    clarity:    clamp(rawDimensions.clarity ?? 10, 0, 20),
    vibeMatch:  clamp(rawDimensions.vibeMatch ?? 10, 0, 20),
  };

  const total = clamp(
    dimensions.confidence +
    dimensions.wit +
    dimensions.warmth +
    dimensions.clarity +
    dimensions.vibeMatch,
    0,
    100
  );

  const { grade, title } = getGradeInfo(total);

  return {
    total,
    grade,
    title,
    dimensions,
    bestMove: bestMove.trim() || "You showed up — that's step one.",
    weakSpot: weakSpot.trim() || "Hard to pin down — keep experimenting.",
  };
}

// ---------------------------------------------------------------------------
// Grade resolution
// ---------------------------------------------------------------------------

/** Returns grade and title for a given 0–100 score */
export function getGradeInfo(score: number): { grade: RizzGrade; title: string } {
  for (const tier of GRADE_TIERS) {
    if (score >= tier.minScore) {
      return { grade: tier.grade, title: tier.title };
    }
  }
  // Fallback (should never hit)
  return { grade: "F", title: "Rizz Emergency" };
}

// ---------------------------------------------------------------------------
// Heuristic quick-score (no LLM needed)
// Used as a fast fallback or for unit testing. Returns rough dimension scores
// based on simple text features.
// ---------------------------------------------------------------------------

/**
 * Generates a heuristic RizzScore without calling the LLM.
 * Useful for testing, fallback, or very short inputs.
 */
export function heuristicScore(message: string): RizzScore {
  const text = message.trim();
  const wordCount = text.split(/\s+/).length;
  const hasQuestion = /\?/.test(text);
  const hasEmoji = /\p{Emoji}/u.test(text);
  const isJustHey = /^h+e+y+\s*[!.?]*$/i.test(text);
  const hasSpecificDetail = wordCount > 8;
  const endsAbruptly = /\b(idk|lol|lmao|haha|anyway)\s*\.?\s*$/i.test(text);
  const isAllLower = text === text.toLowerCase() && wordCount > 3;

  const dims: RizzDimensions = {
    // Confidence: short/vague = low, specific ask = high
    confidence: isJustHey ? 3 : hasSpecificDetail ? 14 : 8,

    // Wit: emojis and longer messages suggest more effort
    wit: hasEmoji && wordCount > 5 ? 13 : wordCount > 12 ? 12 : 7,

    // Warmth: question shows genuine interest; abrupt ending hurts
    warmth: hasQuestion ? 14 : endsAbruptly ? 6 : 10,

    // Clarity: a clear question is clear; trailing off = not clear
    clarity: hasQuestion && !endsAbruptly ? 15 : isJustHey ? 4 : 9,

    // Vibe match: we can't fully assess without context, give neutral
    vibeMatch: isAllLower && wordCount > 3 ? 11 : 10,
  };

  const bestMove = hasQuestion
    ? "Asking a question shows you're genuinely curious — good."
    : hasSpecificDetail
    ? "Including specific details shows you're paying attention."
    : "You sent something — that's braver than most.";

  const weakSpot = isJustHey
    ? '"Hey" by itself doesn\'t give them much to work with.'
    : endsAbruptly
    ? "The ending trails off — try landing with a question or a clear vibe."
    : !hasQuestion
    ? "No question means the ball stays in your court — give them an easy way to reply."
    : "Could push a little harder on your unique angle.";

  return buildScore(dims, bestMove, weakSpot);
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(Math.round(value), min), max);
}


path: src/index.ts
content: // =============================================================================
// index.ts — Rizz Coach CLI entry point
//
// Interactive REPL for local testing. Uses readline for a smooth
// menu-driven experience. All modes are accessible from the main menu.
//
// Run with:  npm start  (or: npx tsx src/index.ts)
// =============================================================================

import * as readline from "readline";
import type { SimState, ReplyVibe } from "./types.js";
import { rateMyRizz, SafetyError } from "./modes/rateMyRizz.js";
import { glowUp } from "./modes/glowUp.js";
import { generateReplies } from "./modes/replyGenerator.js";
import {
  createSimSession,
  sendSimMessage,
  PERSONAS,
} from "./modes/conversationSim.js";
import { generateShareCard } from "./shareCard/cardGenerator.js";
import {
  formatRateMyRizz,
  formatGlowUp,
  formatReplyGenerator,
  formatSimState,
  formatSafetyBlock,
  formatError,
} from "./core/formatter.js";

// ---------------------------------------------------------------------------
// readline setup
// ---------------------------------------------------------------------------

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

/** Prompts the user and returns their input as a promise */
function ask(prompt: string): Promise<string> {
  return new Promise((resolve) => rl.question(prompt, resolve));
}

// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------

async function main() {
  console.clear();
  printBanner();

  let running = true;
  while (running) {
    printMainMenu();
    const choice = (await ask("\nYour choice: ")).trim();

    switch (choice) {
      case "1":
        await runRateMyRizz();
        break;
      case "2":
        await runGlowUp();
        break;
      case "3":
        await runReplyGenerator();
        break;
      case "4":
        await runConversationSim();
        break;
      case "q":
      case "Q":
        console.log("\n✌️  Stay confident. Rizz Coach out.\n");
        running = false;
        break;
      default:
        console.log(formatError("Invalid choice — pick 1–4 or Q to quit."));
    }

    if (running) {
      await ask("\nPress Enter to return to menu...");
    }
  }

  rl.close();
}

// ---------------------------------------------------------------------------
// Mode 1: Rate My Rizz
// ---------------------------------------------------------------------------

async function runRateMyRizz() {
  console.log("\n" + "━".repeat(40));
  console.log("📊  RATE MY RIZZ");
  console.log("━".repeat(40));

  const message = await ask("\nPaste your message: ");
  if (!message.trim()) {
    console.log(formatError("No message entered."));
    return;
  }

  const context = await ask("Context (optional — e.g. 'first text after a party', or press Enter to skip): ");

  console.log("\n⏳  Analyzing your rizz...\n");

  try {
    const result = await rateMyRizz({
      message: message.trim(),
      context: context.trim() || undefined,
    });

    console.log(formatRateMyRizz(result));

    // Offer share card
    const share = await ask("\n🃏  Generate a share card? (y/N): ");
    if (share.trim().toLowerCase() === "y") {
      const card = generateShareCard({
        mode: "rate",
        score: result.score,
      });
      console.log("\n" + card.card);
    }
  } catch (err) {
    if (err instanceof SafetyError) {
      console.log(formatSafetyBlock(err.message));
    } else {
      console.log(formatError("Something went wrong. Please try again."));
    }
  }
}

// ---------------------------------------------------------------------------
// Mode 2: Glow Up My Text
// ---------------------------------------------------------------------------

async function runGlowUp() {
  console.log("\n" + "━".repeat(40));
  console.log("✨  GLOW UP MY TEXT");
  console.log("━".repeat(40));

  const message = await ask("\nPaste your original message: ");
  if (!message.trim()) {
    console.log(formatError("No message entered."));
    return;
  }

  const context = await ask("Context (optional): ");

  console.log("\n⏳  Cooking up your glow up...\n");

  try {
    const result = await glowUp({
      message: message.trim(),
      context: context.trim() || undefined,
    });

    console.log(formatGlowUp(result));
  } catch (err) {
    if (err instanceof SafetyError) {
      console.log(formatSafetyBlock(err.message));
    } else {
      console.log(formatError("Something went wrong. Please try again."));
    }
  }
}

// ---------------------------------------------------------------------------
// Mode 3: Reply Generator
// ---------------------------------------------------------------------------

async function runReplyGenerator() {
  console.log("\n" + "━".repeat(40));
  console.log("💬  REPLY GENERATOR");
  console.log("━".repeat(40));

  const theirMessage = await ask("\nWhat did they say? Paste their message: ");
  if (!theirMessage.trim()) {
    console.log(formatError("No message entered."));
    return;
  }

  const context = await ask("Context (optional): ");

  console.log("\nVibes available: playful, confident, chill, romantic, witty");
  const vibeInput = await ask("Pick up to 3 vibes (comma-separated, or press Enter for defaults): ");

  const preferredVibes: ReplyVibe[] = vibeInput.trim()
    ? (vibeInput
        .split(",")
        .map((v) => v.trim().toLowerCase())
        .filter((v): v is ReplyVibe =>
          ["playful", "confident", "chill", "romantic", "witty"].includes(v)
        ))
    : [];

  console.log("\n⏳  Generating replies...\n");

  try {
    const result = await generateReplies({
      theirMessage: theirMessage.trim(),
      context: context.trim() || undefined,
      preferredVibes: preferredVibes.length > 0 ? preferredVibes : undefined,
    });

    console.log(formatReplyGenerator(result));
  } catch (err) {
    if (err instanceof SafetyError) {
      console.log(formatSafetyBlock(err.message));
    } else {
      console.log(formatError("Something went wrong. Please try again."));
    }
  }
}

// ---------------------------------------------------------------------------
// Mode 4: Conversation Simulator
// ---------------------------------------------------------------------------

async function runConversationSim() {
  console.log("\n" + "━".repeat(40));
  console.log("🎮  CONVERSATION SIMULATOR");
  console.log("━".repeat(40));

  // Persona selection
  console.log("\nChoose a persona to practice with:\n");
  PERSONAS.forEach((p, i) => {
    console.log(`  [${i + 1}] ${p.name} — ${p.archetype}`);
  });

  const personaChoice = await ask("\nYour choice (or press Enter for Alex): ");
  const personaIndex = parseInt(personaChoice.trim()) - 1;
  const persona = PERSONAS[personaIndex] ?? PERSONAS[0];

  console.log(`\n🎭  You're now talking to ${persona.name}. Good luck.\n`);
  console.log("     (Type /quit to end · /score for breakdown · /share for share card)\n");
  console.log("─".repeat(40));

  let state: SimState = createSimSession(persona.name);
  let lastCoachingTip: string | undefined;

  // Main sim loop
  while (!state.sessionOver) {
    // Show current state
    if (state.turns.length > 0) {
      console.log(formatSimState(state, lastCoachingTip));
      lastCoachingTip = undefined;
    } else {
      console.log(`\n${persona.name} is waiting... send the first message.\n`);
    }

    const userMessage = await ask("> ");

    // Handle special commands
    if (userMessage.trim() === "/quit") {
      state = { ...state, sessionOver: true };
      console.log(formatSimState(state));
      break;
    }

    if (userMessage.trim() === "/score") {
      console.log(`\n📊  Current score: ${state.currentScore}/100  |  Momentum: ${state.momentum}`);
      continue;
    }

    if (userMessage.trim() === "/share") {
      const card = generateShareCard({
        mode: "sim",
        headline: `Sim Score: ${state.currentScore}/100`,
        subtitle: `Persona: ${persona.name} · Momentum: ${state.momentum}`,
      });
      console.log("\n" + card.card);
      continue;
    }

    if (!userMessage.trim()) continue;

    // Process message
    console.log("\n⏳\n");

    try {
      const result = await sendSimMessage({ userMessage: userMessage.trim(), state });
      state = result.updatedState;
      lastCoachingTip = result.feedback;

      // Show persona reply immediately (before full state render on next loop)
      console.log(`\n${persona.name}: "${result.personaReply}"\n`);
    } catch (err) {
      if (err instanceof SafetyError) {
        console.log(formatSafetyBlock(err.message));
      } else {
        console.log(formatError("Something went wrong. Please try again."));
      }
    }
  }
}

// ---------------------------------------------------------------------------
// UI: banner + menu
// ---------------------------------------------------------------------------

function printBanner() {
  console.log(`
╔══════════════════════════════════════╗
║                                      ║
║   💬  RIZZ COACH  v1.0               ║
║   Rate · Upgrade · Simulate · Share  ║
║                                      ║
╚══════════════════════════════════════╝
`);
}

function printMainMenu() {
  console.log(`
What do you want to work on?

  [1]  📊  Rate My Rizz        — score a message 0–100
  [2]  ✨  Glow Up My Text     — rewrite it, 3 intensity tiers
  [3]  💬  Reply Generator     — generate replies to their message
  [4]  🎮  Conversation Sim    — practice with a simulated persona

  [Q]  Quit
`);
}

// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------

main().catch((err) => {
  console.error("Fatal error:", err);
  rl.close();
  process.exit(1);
});


path: src/modes/conversationSim.ts
content: // =============================================================================
// modes/conversationSim.ts — Mode 4: Conversation Simulator
//
// Lets the user practice a back-and-forth with a simulated persona.
// Tracks a running momentum score and delivers optional coaching tips.
// The persona's warmth and responses evolve based on user message quality.
// =============================================================================

import Anthropic from "@anthropic-ai/sdk";
import type {
  ConversationSimInput,
  ConversationSimResult,
  SimPersona,
  SimState,
} from "../types.js";
import { checkInput, checkOutput } from "../core/safety.js";
import { buildSimPersonaPrompt, momentumLabel, SYSTEM_PROMPT } from "../core/prompts.js";
import { SafetyError } from "./rateMyRizz.js";

const client = new Anthropic();

// ---------------------------------------------------------------------------
// Built-in persona roster
// ---------------------------------------------------------------------------

export const PERSONAS: SimPersona[] = [
  {
    name: "Alex",
    archetype: "Witty & Slightly Guarded",
    description:
      "Alex is quick with a comeback and appreciates clever messages. " +
      "They're friendly but don't open up immediately — you have to earn warmth. " +
      "They'll call out anything generic or low-effort without being mean about it.",
    warmthLevel: 2,
  },
  {
    name: "Jordan",
    archetype: "Warm & Playfully Sarcastic",
    description:
      "Jordan is genuinely warm and easy to talk to, but uses light sarcasm as a love language. " +
      "They respond well to humor and genuine curiosity. " +
      "They get bored quickly if you're too formal or too vague.",
    warmthLevel: 3,
  },
  {
    name: "Sam",
    archetype: "Low-key & Hard to Read",
    description:
      "Sam gives short replies by default and doesn't make things easy. " +
      "Not rude — just economical with words. " +
      "If you say something genuinely interesting, they'll open up, but bland messages get one-word replies.",
    warmthLevel: 1,
  },
];

// ---------------------------------------------------------------------------
// Session factory — creates a fresh SimState
// ---------------------------------------------------------------------------

/**
 * Creates a brand-new simulation session.
 *
 * @param personaName  Name of a persona from PERSONAS (defaults to "Alex")
 */
export function createSimSession(personaName?: string): SimState {
  const persona =
    PERSONAS.find((p) => p.name.toLowerCase() === personaName?.toLowerCase()) ??
    PERSONAS[0];

  return {
    persona,
    turns: [],
    currentScore: 50, // Start at neutral
    momentum: "steady",
    sessionOver: false,
  };
}

// ---------------------------------------------------------------------------
// Main entry point — process one user turn
// ---------------------------------------------------------------------------

/**
 * Sends the user's message to the simulated persona and returns the response.
 * Also updates the SimState with the new turns, score, and momentum.
 *
 * @param input  Current sim state + the user's latest message
 */
export async function sendSimMessage(
  input: ConversationSimInput
): Promise<ConversationSimResult> {
  const { userMessage, state } = input;

  // ------------------------------------------------------------------
  // Guard: session already over
  // ------------------------------------------------------------------
  if (state.sessionOver) {
    return {
      personaReply: "(session ended)",
      updatedState: state,
    };
  }

  // ------------------------------------------------------------------
  // 1. Safety check on user message
  // ------------------------------------------------------------------
  const safety = checkInput(userMessage);
  if (!safety.safe) {
    throw new SafetyError(safety.message ?? "Safety check failed.");
  }

  // ------------------------------------------------------------------
  // 2. Add user turn to state
  // ------------------------------------------------------------------
  const stateWithUserTurn: SimState = {
    ...state,
    turns: [
      ...state.turns,
      { speaker: "user", message: userMessage },
    ],
  };

  // ------------------------------------------------------------------
  // 3. Call Claude to get persona reply + momentum delta
  // ------------------------------------------------------------------
  const prompt = buildSimPersonaPrompt(
    state.persona,
    stateWithUserTurn.turns,
    userMessage
  );

  let personaReply: string;
  let momentumDelta = 0;
  let coachingTip: string | undefined;

  try {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 512,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: prompt }],
    });

    const raw = extractText(response.content);
    const parsed = parseSimResponse(raw);

    personaReply = parsed.reply;
    momentumDelta = parsed.momentumDelta ?? 0;
    coachingTip = parsed.coachingTip ?? undefined;
  } catch {
    console.warn("[conversationSim] LLM unavailable, using fallback reply.");
    personaReply = fallbackPersonaReply(state.persona, userMessage);
  }

  // ------------------------------------------------------------------
  // 4. Safety check on persona reply
  // ------------------------------------------------------------------
  const outputSafety = checkOutput(personaReply);
  if (!outputSafety.safe) {
    personaReply = "...I don't know what to say to that.";
  }

  // ------------------------------------------------------------------
  // 5. Update state
  // ------------------------------------------------------------------
  const newScore = clamp(state.currentScore + momentumDelta * 3, 0, 100);
  const newTurns = [
    ...stateWithUserTurn.turns,
    { speaker: "persona" as const, message: personaReply, momentumDelta },
  ];

  // Check if persona would naturally end the conversation (very low score)
  const sessionOver = newScore <= 5;

  const updatedState: SimState = {
    ...stateWithUserTurn,
    turns: newTurns,
    currentScore: newScore,
    momentum: momentumLabel(newScore - 50), // -50 to +50 range for label
    sessionOver,
  };

  return {
    personaReply,
    updatedState,
    feedback: coachingTip,
  };
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Parse and validate the LLM's sim response JSON */
function parseSimResponse(raw: string): {
  reply: string;
  momentumDelta?: number;
  coachingTip?: string | null;
} {
  try {
    const parsed = JSON.parse(raw) as {
      reply?: string;
      momentumDelta?: unknown;
      coachingTip?: unknown;
    };

    const delta =
      typeof parsed.momentumDelta === "number"
        ? clamp(parsed.momentumDelta, -3, 3)
        : 0;

    const tip =
      typeof parsed.coachingTip === "string" && parsed.coachingTip.length > 0
        ? parsed.coachingTip
        : null;

    return {
      reply: typeof parsed.reply === "string" ? parsed.reply : "...",
      momentumDelta: delta,
      coachingTip: tip ?? undefined,
    };
  } catch {
    return { reply: raw.trim() || "...", momentumDelta: 0 };
  }
}

/** Fallback persona reply when LLM is unavailable */
function fallbackPersonaReply(persona: SimPersona, userMessage: string): string {
  const isShort = userMessage.trim().length < 15;

  if (persona.warmthLevel === 1) {
    return isShort ? "k" : "hmm. sure.";
  }
  if (persona.warmthLevel === 3) {
    return isShort ? "lol okay" : "haha that's actually kind of cute";
  }
  return isShort ? "lol okay" : "I mean... fair enough";
}

function extractText(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text.trim())
    .join("");
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(Math.round(value), min), max);
}


path: src/modes/glowUp.ts
content: // =============================================================================
// modes/glowUp.ts — Mode 2: Glow Up My Text
//
// Takes a weak or mediocre message and rewrites it in three intensity tiers:
// subtle / confident / bold. Returns the original score alongside the rewrites
// so the user can see the before/after delta.
// =============================================================================

import Anthropic from "@anthropic-ai/sdk";
import type { GlowUpInput, GlowUpResult, GlowUpTiers } from "../types.js";
import { checkInput, checkOutput } from "../core/safety.js";
import { heuristicScore } from "../core/scorer.js";
import { buildGlowUpPrompt, SYSTEM_PROMPT } from "../core/prompts.js";
import { rateMyRizz, SafetyError } from "./rateMyRizz.js";

const client = new Anthropic();

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Rewrites a message in three intensity tiers and scores the original.
 *
 * @param input  Original message + optional context
 * @returns      GlowUpResult with original score and three rewrites
 */
export async function glowUp(input: GlowUpInput): Promise<GlowUpResult> {
  const { message, context } = input;

  // ------------------------------------------------------------------
  // 1. Safety check
  // ------------------------------------------------------------------
  const safety = checkInput(message + (context ?? ""));
  if (!safety.safe) {
    throw new SafetyError(safety.message ?? "Safety check failed.");
  }

  // ------------------------------------------------------------------
  // 2. Score the original message (run in parallel with the glow-up call)
  // ------------------------------------------------------------------
  const [originalScore, tiers] = await Promise.all([
    rateMyRizz({ message, context })
      .then((r) => r.score)
      .catch(() => heuristicScore(message)), // fallback if rate fails
    generateTiers(message, context),
  ]);

  return {
    original: message,
    tiers,
    originalScore,
  };
}

// ---------------------------------------------------------------------------
// Tier generation
// ---------------------------------------------------------------------------

/**
 * Calls Claude to generate the three rewritten message tiers.
 * Falls back to a rule-based placeholder if the API is unavailable.
 */
async function generateTiers(
  message: string,
  context?: string
): Promise<GlowUpTiers> {
  const prompt = buildGlowUpPrompt(message, context);

  let rawJson: string;
  try {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 512,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: prompt }],
    });
    rawJson = extractText(response.content);
  } catch {
    console.warn("[glowUp] LLM unavailable, using fallback tiers.");
    return fallbackTiers(message);
  }

  // Parse LLM response
  let parsed: Partial<GlowUpTiers>;
  try {
    parsed = JSON.parse(rawJson) as Partial<GlowUpTiers>;
  } catch {
    console.warn("[glowUp] Could not parse LLM response, using fallback.");
    return fallbackTiers(message);
  }

  // Safety check on generated text
  const allText = `${parsed.subtle ?? ""} ${parsed.confident ?? ""} ${parsed.bold ?? ""}`;
  const outputSafety = checkOutput(allText);
  if (!outputSafety.safe) {
    console.warn("[glowUp] LLM output failed safety check, using fallback.");
    return fallbackTiers(message);
  }

  return {
    subtle:    parsed.subtle    ?? fallbackTier(message, "subtle"),
    confident: parsed.confident ?? fallbackTier(message, "confident"),
    bold:      parsed.bold      ?? fallbackTier(message, "bold"),
  };
}

// ---------------------------------------------------------------------------
// Fallback tier generator (no LLM)
// ---------------------------------------------------------------------------

/**
 * Simple rule-based fallback when the LLM is unavailable.
 * Not as good as the real thing, but keeps the app functional.
 */
function fallbackTiers(message: string): GlowUpTiers {
  return {
    subtle:    fallbackTier(message, "subtle"),
    confident: fallbackTier(message, "confident"),
    bold:      fallbackTier(message, "bold"),
  };
}

function fallbackTier(
  message: string,
  tier: "subtle" | "confident" | "bold"
): string {
  const base = message.replace(/[?.!]+$/, "").trim();

  switch (tier) {
    case "subtle":
      return `${base} — what do you think?`;
    case "confident":
      return `${base}. Let's make it happen.`;
    case "bold":
      return `${base}. Non-negotiable. (okay maybe a little negotiable)`;
  }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function extractText(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text.trim())
    .join("");
}


path: src/modes/rateMyRizz.ts
content: // =============================================================================
// modes/rateMyRizz.ts — Mode 1: Rate My Rizz
//
// Evaluates a user's flirting message across 5 dimensions (0–20 each)
// and returns a structured RizzScore with grade, title, and callouts.
// =============================================================================

import Anthropic from "@anthropic-ai/sdk";
import type { RateMyRizzInput, RateMyRizzResult, RizzDimensions } from "../types.js";
import { checkInput, checkOutput, detectsRejection } from "../core/safety.js";
import { buildScore, heuristicScore } from "../core/scorer.js";
import { buildRatePrompt, SYSTEM_PROMPT } from "../core/prompts.js";

const client = new Anthropic();

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Rates a flirting message and returns a full RizzScore.
 *
 * @param input  The message to evaluate + optional context
 * @returns      RateMyRizzResult with structured score, or throws on safety block
 */
export async function rateMyRizz(
  input: RateMyRizzInput
): Promise<RateMyRizzResult> {
  const { message, context } = input;

  // ------------------------------------------------------------------
  // 1. Safety check on input
  // ------------------------------------------------------------------
  const inputSafety = checkInput(message + (context ?? ""));
  if (!inputSafety.safe) {
    throw new SafetyError(inputSafety.message ?? "Safety check failed.");
  }

  // ------------------------------------------------------------------
  // 2. Special case: if context reveals a clear rejection, give
  //    respectful-move-on advice rather than scoring tactics
  // ------------------------------------------------------------------
  if (context && detectsRejection(context)) {
    return buildRejectionResult(input);
  }

  // ------------------------------------------------------------------
  // 3. Call Claude to score the message
  // ------------------------------------------------------------------
  const prompt = buildRatePrompt(message, context);

  let rawJson: string;
  try {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 512,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: prompt }],
    });

    rawJson = extractText(response.content);
  } catch (err) {
    // Fallback: use heuristic scoring if the API is unavailable
    console.warn("[rateMyRizz] LLM unavailable, falling back to heuristic scorer.");
    return { input, score: heuristicScore(message) };
  }

  // ------------------------------------------------------------------
  // 4. Parse and validate the LLM response
  // ------------------------------------------------------------------
  let parsed: LLMScoreResponse;
  try {
    parsed = JSON.parse(rawJson) as LLMScoreResponse;
  } catch {
    // LLM returned non-JSON — fall back gracefully
    console.warn("[rateMyRizz] Could not parse LLM response, using heuristic fallback.");
    return { input, score: heuristicScore(message) };
  }

  // ------------------------------------------------------------------
  // 5. Safety check on LLM output before returning
  // ------------------------------------------------------------------
  const outputText = `${parsed.bestMove ?? ""} ${parsed.weakSpot ?? ""}`;
  const outputSafety = checkOutput(outputText);
  if (!outputSafety.safe) {
    // Silently fall back — don't expose the unsafe output
    return { input, score: heuristicScore(message) };
  }

  // ------------------------------------------------------------------
  // 6. Build and return the RizzScore
  // ------------------------------------------------------------------
  const dimensions: Partial<RizzDimensions> = {
    confidence: parsed.confidence,
    wit:        parsed.wit,
    warmth:     parsed.warmth,
    clarity:    parsed.clarity,
    vibeMatch:  parsed.vibeMatch,
  };

  const score = buildScore(
    dimensions,
    parsed.bestMove ?? "",
    parsed.weakSpot ?? ""
  );

  return { input, score };
}

// ---------------------------------------------------------------------------
// Rejection result builder
// ---------------------------------------------------------------------------

/**
 * Returns a special result when context indicates the person has rejected the user.
 * Score is neutral; coaching focuses on graceful exit.
 */
function buildRejectionResult(input: RateMyRizzInput): RateMyRizzResult {
  return {
    input,
    score: buildScore(
      { confidence: 0, wit: 0, warmth: 0, clarity: 0, vibeMatch: 0 },
      "Respecting someone's answer is genuinely the most attractive thing you can do.",
      "When someone says no, the move is to thank them for their honesty and move on with grace."
    ),
  };
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Extracts plain text from Anthropic SDK content blocks */
function extractText(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text.trim())
    .join("");
}

/** Expected shape of the LLM's JSON response */
interface LLMScoreResponse {
  confidence?: number;
  wit?: number;
  warmth?: number;
  clarity?: number;
  vibeMatch?: number;
  bestMove?: string;
  weakSpot?: string;
}

// ---------------------------------------------------------------------------
// Safety error (used to bubble up safety blocks to the CLI)
// ---------------------------------------------------------------------------

export class SafetyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "SafetyError";
  }
}


path: src/modes/replyGenerator.ts
content: // =============================================================================
// modes/replyGenerator.ts — Mode 3: Reply Generator
//
// Given a message the user received, generates 3 reply options across
// different vibes (playful, confident, chill, etc.) with risk level labels.
// =============================================================================

import Anthropic from "@anthropic-ai/sdk";
import type {
  ReplyGeneratorInput,
  ReplyGeneratorResult,
  ReplyOption,
  ReplyVibe,
} from "../types.js";
import { checkInput, checkOutput } from "../core/safety.js";
import { buildReplyPrompt, SYSTEM_PROMPT } from "../core/prompts.js";
import { SafetyError } from "./rateMyRizz.js";

const client = new Anthropic();

// Default vibes if the user doesn't specify
const DEFAULT_VIBES: ReplyVibe[] = ["playful", "confident", "chill"];

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Generates reply options for a received message.
 *
 * @param input  The received message + optional context and vibe preferences
 * @returns      ReplyGeneratorResult with an array of ReplyOption
 */
export async function generateReplies(
  input: ReplyGeneratorInput
): Promise<ReplyGeneratorResult> {
  const { theirMessage, context, preferredVibes } = input;

  // ------------------------------------------------------------------
  // 1. Safety check on the received message + context
  // ------------------------------------------------------------------
  const safety = checkInput(theirMessage + (context ?? ""));
  if (!safety.safe) {
    throw new SafetyError(safety.message ?? "Safety check failed.");
  }

  // ------------------------------------------------------------------
  // 2. Generate replies via Claude
  // ------------------------------------------------------------------
  const vibes = preferredVibes && preferredVibes.length > 0
    ? preferredVibes
    : DEFAULT_VIBES;

  const replies = await generateReplyOptions(theirMessage, vibes, context);

  return {
    theirMessage,
    replies,
  };
}

// ---------------------------------------------------------------------------
// Reply generation
// ---------------------------------------------------------------------------

/**
 * Calls Claude to generate reply options and validates the response.
 * Falls back to safe placeholder replies if the LLM is unavailable.
 */
async function generateReplyOptions(
  theirMessage: string,
  vibes: ReplyVibe[],
  context?: string
): Promise<ReplyOption[]> {
  const prompt = buildReplyPrompt(theirMessage, context, vibes);

  let rawJson: string;
  try {
    const response = await client.messages.create({
      model: "claude-opus-4-5",
      max_tokens: 512,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: prompt }],
    });
    rawJson = extractText(response.content);
  } catch {
    console.warn("[replyGenerator] LLM unavailable, using fallback replies.");
    return fallbackReplies(vibes);
  }

  // Parse response
  let parsed: { replies?: RawReplyOption[] };
  try {
    parsed = JSON.parse(rawJson) as { replies?: RawReplyOption[] };
  } catch {
    console.warn("[replyGenerator] Could not parse LLM response.");
    return fallbackReplies(vibes);
  }

  if (!Array.isArray(parsed.replies) || parsed.replies.length === 0) {
    return fallbackReplies(vibes);
  }

  // Validate and sanitize each reply
  const validated: ReplyOption[] = [];
  for (const raw of parsed.replies) {
    if (!raw.vibe || !raw.text) continue;

    // Safety check on generated text
    const outputSafety = checkOutput(raw.text);
    if (!outputSafety.safe) continue;

    validated.push({
      vibe: sanitiseVibe(raw.vibe),
      text: raw.text.trim(),
      riskLevel: sanitiseRisk(raw.riskLevel),
    });
  }

  // If safety filtering killed all replies, fall back
  if (validated.length === 0) {
    return fallbackReplies(vibes);
  }

  return validated;
}

// ---------------------------------------------------------------------------
// Fallback replies (no LLM)
// ---------------------------------------------------------------------------

function fallbackReplies(vibes: ReplyVibe[]): ReplyOption[] {
  const templates: Record<ReplyVibe, Omit<ReplyOption, "vibe">> = {
    playful:   { text: "haha okay okay, I see you 😄",         riskLevel: "safe" },
    confident: { text: "That tracks. So when are we doing this?", riskLevel: "medium" },
    chill:     { text: "no pressure, just thinking out loud",   riskLevel: "safe" },
    romantic:  { text: "honestly you make it easy to say yes",  riskLevel: "medium" },
    witty:     { text: "I was going to play it cool but... yeah", riskLevel: "safe" },
  };

  return vibes.map((vibe) => ({
    vibe,
    ...(templates[vibe] ?? { text: "sounds good to me", riskLevel: "safe" as const }),
  }));
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function extractText(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text.trim())
    .join("");
}

const VALID_VIBES = new Set<ReplyVibe>(["playful", "confident", "chill", "romantic", "witty"]);
const VALID_RISKS = new Set<ReplyOption["riskLevel"]>(["safe", "medium", "spicy"]);

function sanitiseVibe(v: unknown): ReplyVibe {
  if (typeof v === "string" && VALID_VIBES.has(v as ReplyVibe)) {
    return v as ReplyVibe;
  }
  return "chill";
}

function sanitiseRisk(r: unknown): ReplyOption["riskLevel"] {
  if (typeof r === "string" && VALID_RISKS.has(r as ReplyOption["riskLevel"])) {
    return r as ReplyOption["riskLevel"];
  }
  return "safe";
}

/** Raw shape from LLM before validation */
interface RawReplyOption {
  vibe?: unknown;
  text?: string;
  riskLevel?: unknown;
}


path: src/shareCard/cardGenerator.ts
content: // =============================================================================
// shareCard/cardGenerator.ts — Mode 5: Share Card Generator
//
// Generates a screenshot-friendly ASCII text card from any mode result.
// Designed to be copy-pasteable, DM-able, and tweet-worthy.
// =============================================================================

import type {
  ShareCardInput,
  ShareCardResult,
  ShareCardMode,
  RizzScore,
} from "../types.js";

// ---------------------------------------------------------------------------
// Card dimensions
// ---------------------------------------------------------------------------

const CARD_WIDTH = 34; // inner content width (between borders)

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Generates a share card for any Rizz Coach mode result.
 *
 * @param input  Score, mode, headline, and optional subtitle
 * @returns      ShareCardResult with the formatted ASCII card string
 */
export function generateShareCard(input: ShareCardInput): ShareCardResult {
  const card = buildCard(input);
  return { card, mode: input.mode };
}

// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------

function buildCard(input: ShareCardInput): string {
  const { mode, score, headline, subtitle } = input;

  // Choose default headline based on mode if none provided
  const title = headline ?? defaultHeadline(mode, score);
  const sub = subtitle ?? (score ? defaultSubtitle(score) : "");

  const lines: string[] = [
    border("top"),
    padLine("🏆  RIZZ COACH REPORT"),
    borderLine(),
    padLine(""),
    padLine(title),
    padLine(""),
    ...(score ? scoreLines(score) : []),
    sub ? padLine(sub) : null,
    padLine(""),
    ...(score ? dimensionMiniBar(score) : []),
    padLine(""),
    borderLine(),
    padLine("rizz-coach.app"),
    border("bottom"),
  ].filter((l): l is string => l !== null);

  return lines.join("\n");
}

// ---------------------------------------------------------------------------
// Score section
// ---------------------------------------------------------------------------

function scoreLines(score: RizzScore): string[] {
  const gradeDisplay = `${score.total}/100  ·  Grade: ${score.grade}`;
  const titleDisplay = `"${score.title}"`;

  return [
    padLine(gradeDisplay),
    padLine(titleDisplay),
    padLine(""),
  ];
}

/**
 * Compact single-line mini bars for each dimension — fits the card width.
 * Format: Conf ████░ · Wit ███░░
 */
function dimensionMiniBar(score: RizzScore): string[] {
  const dims: [string, number][] = [
    ["Conf",  score.dimensions.confidence],
    ["Wit",   score.dimensions.wit],
    ["Warmth",score.dimensions.warmth],
    ["Clear", score.dimensions.clarity],
    ["Vibe",  score.dimensions.vibeMatch],
  ];

  return dims.map(([label, value]) => {
    const bar = "█".repeat(Math.round(value / 4)) + "░".repeat(5 - Math.round(value / 4));
    const valueStr = `${value}/20`;
    const content = `${label.padEnd(6)} ${bar}  ${valueStr}`;
    return padLine(content);
  });
}

// ---------------------------------------------------------------------------
// Default text helpers
// ---------------------------------------------------------------------------

function defaultHeadline(mode: ShareCardMode, score?: RizzScore): string {
  if (score) {
    return `Score: ${score.total}/100  [${score.grade}]`;
  }
  const modeLabels: Record<ShareCardMode, string> = {
    rate:   "Rate My Rizz",
    glowup: "Glow Up Complete ✨",
    reply:  "Reply Options Generated",
    sim:    "Sim Session Complete",
  };
  return modeLabels[mode];
}

function defaultSubtitle(score: RizzScore): string {
  const topDim = getTopDimension(score);
  const lowDim = getLowDimension(score);
  return `💪 ${topDim}  ·  📈 Work on: ${lowDim}`;
}

function getTopDimension(score: RizzScore): string {
  const dims = score.dimensions;
  const entries = Object.entries(dims) as [keyof typeof dims, number][];
  const top = entries.sort(([, a], [, b]) => b - a)[0];
  return LABEL_MAP[top[0]];
}

function getLowDimension(score: RizzScore): string {
  const dims = score.dimensions;
  const entries = Object.entries(dims) as [keyof typeof dims, number][];
  const low = entries.sort(([, a], [, b]) => a - b)[0];
  return LABEL_MAP[low[0]];
}

const LABEL_MAP: Record<string, string> = {
  confidence: "Confidence",
  wit:        "Wit",
  warmth:     "Warmth",
  clarity:    "Clarity",
  vibeMatch:  "Vibe Match",
};

// ---------------------------------------------------------------------------
// ASCII border helpers
// ---------------------------------------------------------------------------

/** Top or bottom border */
function border(position: "top" | "bottom"): string {
  const line = "─".repeat(CARD_WIDTH);
  return position === "top"
    ? `┌${line}┐`
    : `└${line}┘`;
}

/** Horizontal divider inside the card */
function borderLine(): string {
  return `├${"─".repeat(CARD_WIDTH)}┤`;
}

/**
 * Pads content to fit within card borders.
 * Truncates if text is longer than CARD_WIDTH - 2.
 */
function padLine(content: string): string {
  const maxLen = CARD_WIDTH - 2;
  const truncated = content.length > maxLen
    ? content.slice(0, maxLen - 1) + "…"
    : content;
  const padded = ` ${truncated}`.padEnd(CARD_WIDTH - 1);
  return `│${padded}│`;
}


path: src/skill.ts
content: // =============================================================================
// skill.ts — OpenClaw / ClawHub skill adapter
//
// This is the thin integration layer for the OpenClaw / ClawHub platform.
// It maps incoming SkillInput payloads to the appropriate mode handlers
// and returns a standardised SkillOutput.
//
// All actual logic lives in src/modes/ — this file is pure routing.
// =============================================================================

import type {
  SkillInput,
  SkillOutput,
  SkillMode,
  RateMyRizzInput,
  GlowUpInput,
  ReplyGeneratorInput,
  ConversationSimInput,
  ShareCardInput,
  SimState,
} from "./types.js";

import { rateMyRizz, SafetyError } from "./modes/rateMyRizz.js";
import { glowUp } from "./modes/glowUp.js";
import { generateReplies } from "./modes/replyGenerator.js";
import { sendSimMessage, createSimSession } from "./modes/conversationSim.js";
import { generateShareCard } from "./shareCard/cardGenerator.js";

import {
  formatRateMyRizz,
  formatGlowUp,
  formatReplyGenerator,
  formatSimState,
  formatSafetyBlock,
} from "./core/formatter.js";

// ---------------------------------------------------------------------------
// Skill handler — main export for OpenClaw / ClawHub
// ---------------------------------------------------------------------------

/**
 * Primary entry point for the ClawHub skill runtime.
 * Receives a typed SkillInput, routes to the correct mode, and returns SkillOutput.
 *
 * @param input  Parsed skill invocation payload
 */
export async function handleSkill(input: SkillInput): Promise<SkillOutput> {
  try {
    switch (input.mode) {
      case "rate":
        return await handleRate(input.payload);

      case "glowup":
        return await handleGlowUp(input.payload);

      case "reply":
        return await handleReply(input.payload);

      case "sim":
        return await handleSim(input.payload);

      case "share":
        return await handleShare(input.payload);

      default:
        return errorOutput(input.mode, `Unknown mode: "${input.mode as string}"`);
    }
  } catch (err) {
    if (err instanceof SafetyError) {
      return {
        success: false,
        mode: input.mode,
        safetyBlocked: true,
        result: { message: err.message, display: formatSafetyBlock(err.message) },
      };
    }
    const message = err instanceof Error ? err.message : "An unexpected error occurred.";
    return errorOutput(input.mode, message);
  }
}

// ---------------------------------------------------------------------------
// Mode handlers
// ---------------------------------------------------------------------------

/** Mode 1: Rate My Rizz */
async function handleRate(payload: Record<string, unknown>): Promise<SkillOutput> {
  const rateInput: RateMyRizzInput = {
    message: assertString(payload.message, "message"),
    context: optionalString(payload.context),
  };

  const result = await rateMyRizz(rateInput);

  return {
    success: true,
    mode: "rate",
    result: {
      ...result,
      display: formatRateMyRizz(result), // Pre-rendered text for the platform to display
    },
  };
}

/** Mode 2: Glow Up My Text */
async function handleGlowUp(payload: Record<string, unknown>): Promise<SkillOutput> {
  const glowUpInput: GlowUpInput = {
    message: assertString(payload.message, "message"),
    context: optionalString(payload.context),
  };

  const result = await glowUp(glowUpInput);

  return {
    success: true,
    mode: "glowup",
    result: {
      ...result,
      display: formatGlowUp(result),
    },
  };
}

/** Mode 3: Reply Generator */
async function handleReply(payload: Record<string, unknown>): Promise<SkillOutput> {
  const replyInput: ReplyGeneratorInput = {
    theirMessage: assertString(payload.theirMessage, "theirMessage"),
    context:      optionalString(payload.context),
    // preferredVibes is optional — let generateReplies use its defaults
  };

  const result = await generateReplies(replyInput);

  return {
    success: true,
    mode: "reply",
    result: {
      ...result,
      display: formatReplyGenerator(result),
    },
  };
}

/** Mode 4: Conversation Simulator */
async function handleSim(payload: Record<string, unknown>): Promise<SkillOutput> {
  // The platform sends either an existing state or signals a new session
  const userMessage = assertString(payload.userMessage, "userMessage");

  // Allow the platform to pass in an existing session state (serialised as JSON)
  const existingState = payload.state as SimState | undefined;
  const personaName = optionalString(payload.personaName);

  const state: SimState = existingState ?? createSimSession(personaName);

  const simInput: ConversationSimInput = { userMessage, state };
  const result = await sendSimMessage(simInput);

  return {
    success: true,
    mode: "sim",
    result: {
      ...result,
      display: formatSimState(result.updatedState, result.feedback),
    },
  };
}

/** Mode 5: Share Card */
async function handleShare(payload: Record<string, unknown>): Promise<SkillOutput> {
  // Share card is synchronous — no LLM call needed
  const shareInput: ShareCardInput = {
    mode:     assertShareMode(payload.sourceMode),
    headline: optionalString(payload.headline),
    subtitle: optionalString(payload.subtitle),
    // score is optional — platform may pass it from a prior rate result
    score:    payload.score as ShareCardInput["score"] | undefined,
  };

  const result = generateShareCard(shareInput);

  return {
    success: true,
    mode: "share",
    result: {
      ...result,
      display: result.card,
    },
  };
}

// ---------------------------------------------------------------------------
// Input validation helpers
// ---------------------------------------------------------------------------

function assertString(value: unknown, fieldName: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    throw new Error(`Missing required field: "${fieldName}" must be a non-empty string.`);
  }
  return value.trim();
}

function optionalString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() !== ""
    ? value.trim()
    : undefined;
}

function assertShareMode(value: unknown): ShareCardInput["mode"] {
  const valid: ShareCardInput["mode"][] = ["rate", "glowup", "reply", "sim"];
  if (typeof value === "string" && valid.includes(value as ShareCardInput["mode"])) {
    return value as ShareCardInput["mode"];
  }
  return "rate"; // Sensible default
}

function errorOutput(mode: SkillMode, message: string): SkillOutput {
  return { success: false, mode, error: message };
}


path: src/types.ts
content: // =============================================================================
// types.ts — Shared TypeScript interfaces and enums for Rizz Coach
// =============================================================================

// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------

/** The five rubric dimensions, each scored 0–20 */
export interface RizzDimensions {
  confidence: number; // Assertive without being pushy
  wit: number;        // Humor, wordplay, originality
  warmth: number;     // Genuine interest, not transactional
  clarity: number;    // Clear intent, no vague trailing off
  vibeMatch: number;  // Tone appropriate to context/audience
}

/** Letter grade mapped from total score */
export type RizzGrade = "S" | "A" | "B" | "C" | "D" | "F";

/** Full scoring result returned by the scorer */
export interface RizzScore {
  total: number;          // 0–100
  grade: RizzGrade;       // S / A / B / C / D / F
  title: string;          // e.g. "Certified Rizz God"
  dimensions: RizzDimensions;
  bestMove: string;       // What worked well
  weakSpot: string;       // What dragged the score down
}

// ---------------------------------------------------------------------------
// Mode: Rate My Rizz
// ---------------------------------------------------------------------------

export interface RateMyRizzInput {
  message: string;        // The message to evaluate
  context?: string;       // Optional context (e.g. "first text", "after a date")
}

export interface RateMyRizzResult {
  input: RateMyRizzInput;
  score: RizzScore;
}

// ---------------------------------------------------------------------------
// Mode: Glow Up My Text
// ---------------------------------------------------------------------------

/** Three intensity tiers for rewritten messages */
export interface GlowUpTiers {
  subtle: string;         // Light improvement, still grounded
  confident: string;      // Assertive, clear, direct
  bold: string;           // High energy, memorable, a little cheeky
}

export interface GlowUpInput {
  message: string;        // The original message to rewrite
  context?: string;       // Optional context about the recipient/situation
}

export interface GlowUpResult {
  original: string;
  tiers: GlowUpTiers;
  originalScore: RizzScore; // Score of the original so user sees the delta
}

// ---------------------------------------------------------------------------
// Mode: Reply Generator
// ---------------------------------------------------------------------------

/** Vibe the user wants their reply to project */
export type ReplyVibe = "playful" | "confident" | "chill" | "romantic" | "witty";

export interface ReplyOption {
  vibe: ReplyVibe;
  text: string;
  riskLevel: "safe" | "medium" | "spicy"; // So user knows what they're picking
}

export interface ReplyGeneratorInput {
  theirMessage: string;   // The message you received
  context?: string;       // Relationship context, prior convo, etc.
  preferredVibes?: ReplyVibe[]; // Filter to specific vibes
}

export interface ReplyGeneratorResult {
  theirMessage: string;
  replies: ReplyOption[];
}

// ---------------------------------------------------------------------------
// Mode: Conversation Simulator
// ---------------------------------------------------------------------------

/** Persona the user is practicing against */
export interface SimPersona {
  name: string;
  archetype: string;        // e.g. "Witty & slightly guarded"
  description: string;      // Brief personality summary for the LLM
  warmthLevel: 1 | 2 | 3;  // 1 = reserved, 2 = moderate, 3 = warm
}

/** A single turn in the simulated conversation */
export interface SimTurn {
  speaker: "user" | "persona";
  message: string;
  momentumDelta?: number;   // Optional: how much this turn helped/hurt momentum
}

/** Running state of the simulation */
export interface SimState {
  persona: SimPersona;
  turns: SimTurn[];
  currentScore: number;     // Running rizz score for the conversation
  momentum: "building" | "steady" | "losing" | "dead";
  sessionOver: boolean;     // True if persona ends the convo or user types /quit
}

export interface ConversationSimInput {
  userMessage: string;      // The current message from the user
  state: SimState;          // Current sim state (carries conversation history)
}

export interface ConversationSimResult {
  personaReply: string;
  updatedState: SimState;
  feedback?: string;        // Optional coaching tip after certain turns
}

// ---------------------------------------------------------------------------
// Share Card
// ---------------------------------------------------------------------------

/** Source mode that generated the result being shared */
export type ShareCardMode = "rate" | "glowup" | "reply" | "sim";

export interface ShareCardInput {
  mode: ShareCardMode;
  score?: RizzScore;
  headline?: string;        // Custom headline override
  subtitle?: string;        // e.g. "Best move: genuine curiosity"
}

export interface ShareCardResult {
  card: string;             // ASCII/text formatted share card
  mode: ShareCardMode;
}

// ---------------------------------------------------------------------------
// Safety
// ---------------------------------------------------------------------------

export type SafetyFlag =
  | "harassment"
  | "coercion"
  | "stalking"
  | "manipulation"
  | "explicit"
  | "minor_risk"
  | "rejection_escalation"; // User getting pushy after a clear "no"

export interface SafetyCheckResult {
  safe: boolean;
  flags: SafetyFlag[];
  message?: string;         // User-facing explanation if blocked
}

// ---------------------------------------------------------------------------
// Skill I/O (OpenClaw / ClawHub adapter interface)
// ---------------------------------------------------------------------------

/** Top-level mode selector */
export type SkillMode = "rate" | "glowup" | "reply" | "sim" | "share";

export interface SkillInput {
  mode: SkillMode;
  payload: Record<string, unknown>; // Mode-specific input, deserialized per mode
}

export interface SkillOutput {
  success: boolean;
  mode: SkillMode;
  result?: unknown;         // Mode-specific result
  error?: string;
  safetyBlocked?: boolean;
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)