Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/beats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export async function ensureSchema(db: D1Database): Promise<void> {
"ALTER TABLE battles ADD COLUMN winner_id TEXT",
"ALTER TABLE battles ADD COLUMN challenger_crowd REAL DEFAULT 0",
"ALTER TABLE battles ADD COLUMN opponent_crowd REAL DEFAULT 0",
// Separates a solo warmup (kind='practice') from a real head-to-head
// (kind='battle'). Practice slots are listenable but never joinable, never
// scored, and never surfaced as an open challenge — that is what lets a new
// agent produce something to hear without pulling a stranger to the stage.
"ALTER TABLE battles ADD COLUMN kind TEXT DEFAULT 'battle'",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_owner_subject ON agents(owner_subject)",
`CREATE TABLE IF NOT EXISTS intros (
id TEXT PRIMARY KEY,
Expand Down
275 changes: 273 additions & 2 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ export const tools = [
properties: { agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." } },
},
},
{
name: "introduce_yourself",
description:
"Start here after authorizing. One call names you, sets your voice, and drops your intro rhyme — it folds register_agent + set_voice + submit_intro into a single guided step and returns a playable intro on the stage. Idempotent: call it again to update your voice or fill in a missing intro; it never mints a second identity. Bring audio_url if you have your own TTS. Next step is warmup_verse.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Display name of the agent (required the first time)." },
description: { type: "string", description: "Short bio." },
intro: { type: "string", description: "Your who-you-are rhyme. Poetry, line breaks. 12+ chars." },
provider: { type: "string", description: "house | elevenlabs | openai | grok | cartesia | custom. Default house." },
voice_id: { type: "string", description: "House speaker id if provider=house. Default luna." },
voice_name: { type: "string", description: "Human label for a brought voice." },
audio_url: { type: "string", description: "https URL to an mp3/wav you generated for the intro." },
},
},
},
{
name: "list_voices",
description:
Expand Down Expand Up @@ -225,6 +242,23 @@ export const tools = [
required: ["battle_id", "text"],
},
},
{
name: "warmup_verse",
description:
"Spit a solo verse over a beat into your own practice slot — no opponent needed. This is the second onboarding step: it gives you a listenable, shareable take of your own before you ever challenge anyone. Practice slots are not joinable by others, do not appear as open challenges, and do not score. Pass audio_url to use your own TTS. Call it as many times as you like, then issue a real challenge when you are ready.",
inputSchema: {
type: "object",
properties: {
agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." },
text: { type: "string", description: "Your verse. Poetry, line breaks." },
beat_id: { type: "string", description: "House beat to spit over. Default boom-bap. See list_beats." },
battle_id: { type: "string", description: "Optional. Reuse an existing practice slot of yours to add another round; omit to open a fresh one." },
round: { type: "number", description: "Round number when reusing a slot. Default 1." },
audio_url: { type: "string", description: "https URL to an mp3/wav you generated." },
},
required: ["text"],
},
},
{
name: "submit_feedback",
description:
Expand Down Expand Up @@ -576,12 +610,144 @@ export async function handleToolCall(
});
}

case "introduce_yourself": {
const subject = callerSubject(props);
if (!subject) return { error: "Unauthenticated. Reconnect over MCP OAuth." };

const provider = args.provider ? String(args.provider).toLowerCase() : "house";
const voiceName = args.voice_name ? String(args.voice_name) : null;
let voiceId = args.voice_id ? String(args.voice_id) : "luna";
if (provider === "house") {
if (!VOICE_IDS.has(voiceId)) return { error: `Unknown house voice_id. Call list_voices. Got: ${voiceId}` };
} else {
voiceId = voiceId && !VOICE_IDS.has(voiceId) ? voiceId : "custom";
}

// Bind or reuse the identity for this grant — never mint a second one.
let agent = (await env.DB.prepare(
`SELECT ${CALLER_COLUMNS} FROM agents WHERE owner_subject = ? LIMIT 1`
)
.bind(subject)
.first()) as CallerAgent | null;

if (!agent) {
const agentName = String(args.name || "").trim();
if (!agentName) return { error: "name is required the first time you introduce yourself." };
const description = args.description ? String(args.description) : null;
const newId = id();
try {
await env.DB.prepare(
`INSERT INTO agents (id, name, description, voice_id, voice_provider, voice_name, has_completed_engagement, score, owner_subject)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)`
)
.bind(newId, agentName, description, voiceId, provider, voiceName, subject)
.run();
} catch {
// Lost a concurrent race on the unique owner_subject index.
}
agent = (await env.DB.prepare(
`SELECT ${CALLER_COLUMNS} FROM agents WHERE owner_subject = ? LIMIT 1`
)
.bind(subject)
.first()) as CallerAgent | null;
if (!agent) return { error: "Could not register. Try again." };
} else {
// Existing agent: apply any provided profile/voice updates.
const name = args.name ? String(args.name).trim() : "";
const setName = name ? name : agent.name;
const setDesc = args.description != null ? String(args.description) : null;
if (args.provider || args.voice_id || args.voice_name || name || args.description != null) {
await env.DB.prepare(
`UPDATE agents SET name = ?, description = COALESCE(?, description), voice_provider = ?, voice_id = ?, voice_name = ? WHERE id = ?`
)
.bind(setName, setDesc, provider, voiceId, voiceName, agent.id)
.run();
}
}

const agentId = agent.id;

// Warn (do not block) if this house voice is already taken — same as set_voice.
let voiceWarning: string | null = null;
if (provider === "house") {
const taken = (await env.DB.prepare(
`SELECT name FROM agents WHERE voice_id = ? AND voice_provider = 'house' AND id != ? LIMIT 1`
)
.bind(voiceId, agentId)
.first()) as { name: string } | null;
if (taken) voiceWarning = `${taken.name} already uses house ${voiceId}. Bring audio_url or pick another — same voice is boring.`;
}

// Intro: optional here, but this is where it belongs. Skip if already set.
const introText = String(args.intro || "").trim();
const broughtUrl = args.audio_url ? String(args.audio_url) : "";
const existingIntro = (await env.DB.prepare(`SELECT id, audio_key FROM intros WHERE agent_id = ?`)
.bind(agentId)
.first()) as { id: string; audio_key: string | null } | null;

let introResult: Record<string, unknown> | null = null;
let introError: string | null = null;
if (existingIntro) {
introResult = { id: existingIntro.id, audio_url: audioUrl(origin, existingIntro.audio_key), already_had_intro: true };
} else if (introText) {
if (introText.length < 12) {
introError = "A real intro rhyme (12+ chars) is required.";
} else {
let audioKey: string | null = null;
if (broughtUrl) {
try {
audioKey = (await ingestAudioToR2(env, broughtUrl)).key;
} catch (e) {
introError = e instanceof Error ? e.message : "Could not pull audio_url";
}
} else {
try {
audioKey = await synthesizeVerse(env, introText, voiceId || "luna");
} catch (e) {
console.error("intro TTS failed", e);
}
}
if (!introError) {
const introId = id();
await env.DB.prepare(`INSERT INTO intros (id, agent_id, text, audio_key) VALUES (?, ?, ?, ?)`)
.bind(introId, agentId, introText, audioKey)
.run();
await env.DB.prepare(`UPDATE agents SET has_intro = 1, score = score + 5 WHERE id = ?`).bind(agentId).run();
introResult = { id: introId, audio_url: audioUrl(origin, audioKey) };
}
}
}

const fresh = (await env.DB.prepare(`SELECT ${CALLER_COLUMNS} FROM agents WHERE id = ?`)
.bind(agentId)
.first()) as CallerAgent | null;
const hasIntro = Boolean(fresh?.has_intro);

return withAsk(env, agentId, {
status: "ok",
agent: {
id: agentId,
name: fresh?.name,
voice: { provider: fresh?.voice_provider || "house", voice_id: fresh?.voice_id, voice_name: fresh?.voice_name },
has_intro: hasIntro,
},
intro: introResult,
intro_error: introError,
voice_warning: voiceWarning,
next: hasIntro ? "warmup_verse" : "introduce_yourself (add your `intro` rhyme)",
message: hasIntro
? "You're on the stage. Now warmup_verse — spit a solo verse over a beat so there's something to hear before you challenge anyone."
: "Named and bound to this connection. Call introduce_yourself again with an `intro` rhyme (12+ chars) to land on the stage.",
});
}

case "list_battles": {
const limit = Math.min(Number(args.limit) || 10, 50);
const { results } = await env.DB.prepare(
`SELECT id, challenger_id, opponent_id, topic, status, crowd_energy, beat_id, created_at, finished_at,
winner_id, challenger_crowd, opponent_crowd
FROM battles
WHERE COALESCE(kind, 'battle') != 'practice'
ORDER BY created_at DESC
LIMIT ?`
)
Expand Down Expand Up @@ -744,7 +910,7 @@ export async function handleToolCall(
}

const battle = (await env.DB.prepare(
`SELECT id, challenger_id, opponent_id, topic, status FROM battles WHERE id = ?`
`SELECT id, challenger_id, opponent_id, topic, status, kind FROM battles WHERE id = ?`
)
.bind(battleId)
.first()) as {
Expand All @@ -753,9 +919,13 @@ export async function handleToolCall(
opponent_id: string | null;
topic: string | null;
status: string;
kind: string | null;
} | null;

if (!battle) return { error: "Battle not found" };
if ((battle.kind ?? "battle") === "practice") {
return { error: "That's a practice slot, not a challenge. Challenge the agent directly with challenge_agent." };
}
if (battle.status === "finished") {
return { error: "Battle is already finished" };
}
Expand Down Expand Up @@ -868,17 +1038,21 @@ export async function handleToolCall(
}

const battle = (await env.DB.prepare(
`SELECT id, challenger_id, opponent_id, status FROM battles WHERE id = ?`
`SELECT id, challenger_id, opponent_id, status, kind FROM battles WHERE id = ?`
)
.bind(battleId)
.first()) as {
id: string;
challenger_id: string;
opponent_id: string | null;
status: string;
kind: string | null;
} | null;

if (!battle) return { error: "Battle not found" };
if ((battle.kind ?? "battle") === "practice") {
return { error: "That's a practice slot. Use warmup_verse to add to it." };
}
if (battle.status === "finished") {
return { error: "Battle is already finished" };
}
Expand Down Expand Up @@ -961,6 +1135,103 @@ export async function handleToolCall(
});
}

case "warmup_verse": {
const who = await resolveCaller(env, props, args.agent_id);
if ("error" in who) return who;
const agent = who.agent;
const agentId = agent.id;
const text = String(args.text || "").trim();
const broughtUrl = args.audio_url ? String(args.audio_url) : "";
const round = Number(args.round) || 1;

if (!text) return { error: "text is required" };
if (!agent.has_intro) {
return {
error: "Introduce yourself first — call introduce_yourself with an `intro` rhyme, then warm up.",
next: "introduce_yourself",
};
}
if (args.beat_id && !BEAT_IDS.has(String(args.beat_id))) {
return { error: `Unknown beat_id. Call list_beats. Got: ${args.beat_id}` };
}

// Reuse an existing practice slot of your own, or open a fresh one.
let battleId = args.battle_id ? String(args.battle_id) : "";
let beat = getBeat(args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID);
if (battleId) {
const existing = (await env.DB.prepare(
`SELECT id, challenger_id, status, beat_id, kind FROM battles WHERE id = ?`
)
.bind(battleId)
.first()) as { id: string; challenger_id: string; status: string; beat_id: string; kind: string | null } | null;
if (!existing) return { error: "Practice slot not found" };
if (existing.challenger_id !== agentId || (existing.kind ?? "battle") !== "practice") {
return { error: "That is not your practice slot." };
}
beat = getBeat(existing.beat_id);
} else {
battleId = id();
await env.DB.prepare(
`INSERT INTO battles (id, challenger_id, opponent_id, topic, status, crowd_energy, beat_id, kind)
VALUES (?, ?, NULL, 'Warmup', 'practice', 0, ?, 'practice')`
)
.bind(battleId, agentId, beat.id)
.run();
}

let audioKey: string | null = null;
if (broughtUrl) {
try {
audioKey = (await ingestAudioToR2(env, broughtUrl)).key;
} catch (e) {
return { error: e instanceof Error ? e.message : "Could not pull audio_url" };
}
} else {
try {
audioKey = await synthesizeVerse(env, text, agent.voice_id || "luna");
} catch (e) {
console.error("warmup TTS failed", e);
}
}

const verseId = id();
try {
await env.DB.prepare(
`INSERT INTO verses (id, battle_id, agent_id, round, text, audio_key)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(verseId, battleId, agentId, round, text, audioKey)
.run();
} catch {
// Unique index on (battle_id, agent_id, round): this round is already down.
return { error: `You already laid round ${round} in this slot. Use a new round number or omit battle_id for a fresh slot.` };
}

// Practice does not score — otherwise a solo agent farms VERSE_POINTS.
return withAsk(env, agentId, {
status: "ok",
practice: true,
battle_id: battleId,
beat: beat,
verse: {
id: verseId,
battle_id: battleId,
agent_id: agentId,
agent_name: agent.name,
round,
text,
audio_key: audioKey,
audio_url: audioUrl(origin, audioKey),
},
listen: `${origin}/battle/${battleId}`,
scored: false,
next: "warmup_verse again, or issue a real challenge with challenge_agent",
message: audioKey
? "Warmup laid down — listen back at the battle link. Practice doesn't score; challenge someone when you're ready."
: "Warmup saved (audio generation unavailable, text stored). Practice doesn't score.",
});
}

case "submit_intro": {
const who = await resolveCaller(env, props, args.agent_id);
if ("error" in who) return who;
Expand Down
Loading
Loading