Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

voice-to-pr.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

voice-to-pr.tsBlame546 lines · 1 contributor
45f3b73Claude1/**
2 * Voice-to-PR — phone-first dictation pipeline.
3 *
4 * The `/voice` route captures a browser Web Speech API transcript and posts
5 * it here. This module turns that transcript into one of three outcomes:
6 *
7 * 1. `interpretVoiceTranscript` — sends the transcript to Claude with a
8 * tight classification prompt. Returns a discriminated union telling
9 * the route whether to treat the utterance as a *spec*, an *issue*,
10 * or "unclear" (ambiguous → user picks). The model also returns a
11 * polished title + body markdown so the eventual spec/issue isn't
12 * just raw dictation noise.
13 *
14 * 2. `shipAsSpec` — writes a `.gluecron/specs/voice-<slug>.md` file to
15 * the repo's default branch with `status: ready`. The autopilot loop
16 * (`autopilot-spec-to-pr.ts`) picks it up on the next tick and runs
17 * the full spec-to-PR pipeline against it. This is intentionally a
18 * thin wrapper around `createOrUpdateFileOnBranch` + the spec
19 * front-matter format — it reuses the existing flow rather than
20 * duplicating any of `runSpecToPr`.
21 *
22 * 3. `createIssueFromVoice` — inserts a row directly into the `issues`
23 * table mirroring `src/routes/issues.tsx`. Kept here so the route
24 * handler stays tiny and so the unit tests can exercise the flow
25 * without going through the HTTP layer.
26 *
27 * Hard contract:
28 * - No function throws. Every failure path returns
29 * `{ok:false, error:string}`.
30 * - When `ANTHROPIC_API_KEY` is missing, `interpretVoiceTranscript`
31 * returns a deterministic best-effort fallback so the demo still
32 * works on machines without an API key.
33 * - The shared layout / locked files are never touched here.
34 */
35
36import { and, eq } from "drizzle-orm";
37import { db } from "../db";
38import { issues, repositories, users } from "../db/schema";
39import { createOrUpdateFileOnBranch } from "../git/repository";
40import { config } from "./config";
41import { serialiseSpec } from "./spec-to-pr";
42
43// ---------------------------------------------------------------------------
44// Public types
45// ---------------------------------------------------------------------------
46
47export type VoiceIntent = "spec" | "issue" | "unclear";
48
49export interface VoiceInterpretation {
50 kind: VoiceIntent;
51 title: string;
52 body_markdown: string;
53 /** Optional hint surfaced by the model (e.g. "the dashboard repo"). */
54 target_repo_id_hint?: string;
55}
56
57export interface InterpretArgs {
58 transcript: string;
59 /** Optional list of the user's repos so the model can suggest one. */
60 knownRepos?: Array<{ id: string; fullName: string }>;
61 /** Test-only injection. */
62 client?: { call: (prompt: string) => Promise<string> };
63}
64
65export type InterpretResult =
66 | { ok: true; suggestion: VoiceInterpretation }
67 | { ok: false; error: string };
68
69export interface ShipSpecArgs {
70 repositoryId: string;
71 transcript: string;
72 userId: string;
73 /** Override the deterministic slug — only used in tests. */
74 slugOverride?: string;
75 /** Pre-computed interpretation; lets the route reuse a single Claude call. */
76 interpretation?: VoiceInterpretation;
77}
78
79export type ShipSpecResult =
80 | {
81 ok: true;
82 specPath: string;
83 commitSha: string;
84 branch: string;
85 }
86 | { ok: false; error: string };
87
88export interface CreateIssueArgs {
89 repositoryId: string;
90 transcript: string;
91 userId: string;
92 interpretation?: VoiceInterpretation;
93}
94
95export type CreateIssueResult =
96 | {
97 ok: true;
98 issueNumber: number;
99 ownerName: string;
100 repoName: string;
101 }
102 | { ok: false; error: string };
103
104// ---------------------------------------------------------------------------
105// Helpers
106// ---------------------------------------------------------------------------
107
108/**
109 * Derive a stable, URL-safe slug for the voice spec filename. Capped at 40
110 * chars so the resulting path stays short.
111 */
112export function voiceSlug(text: string): string {
113 const base = (text || "")
114 .toLowerCase()
115 .replace(/[^a-z0-9]+/g, "-")
116 .replace(/^-+|-+$/g, "")
117 .slice(0, 40);
118 return base || "voice-note";
119}
120
121/** Heuristic fallback used when the Claude API key is missing. */
122function classifyHeuristically(transcript: string): VoiceInterpretation {
123 const t = transcript.trim();
124 const lower = t.toLowerCase();
125 const looksLikeBug =
126 /(bug|broken|crash|error|doesn'?t work|not working|regression|fails?)/i.test(
127 lower
128 );
129 const looksLikeFeature =
130 /(add|build|implement|create|wire up|ship|introduce|support)/i.test(lower);
131 const kind: VoiceIntent = looksLikeBug
132 ? "issue"
133 : looksLikeFeature
134 ? "spec"
135 : "unclear";
136 // First clause becomes the title; cap at 80 chars.
137 const firstClause =
138 t.split(/[.!?\n]/)[0]?.trim() || t.slice(0, 80) || "Voice note";
139 const title = firstClause.length > 80 ? `${firstClause.slice(0, 77)}...` : firstClause;
140 return {
141 kind,
142 title: title.replace(/^[a-z]/, (c) => c.toUpperCase()),
143 body_markdown: t,
144 };
145}
146
147/**
148 * Sanitise the model's response into a `VoiceInterpretation`. Anything that
149 * doesn't conform falls back to "unclear" so the UI shows the picker.
150 */
151export function normaliseInterpretation(
152 raw: unknown,
153 fallbackBody: string
154): VoiceInterpretation {
155 if (!raw || typeof raw !== "object") {
156 return classifyHeuristically(fallbackBody);
157 }
158 const r = raw as Record<string, unknown>;
159 const kindRaw = typeof r.kind === "string" ? r.kind.toLowerCase() : "";
160 const kind: VoiceIntent =
161 kindRaw === "spec" || kindRaw === "issue" || kindRaw === "unclear"
162 ? (kindRaw as VoiceIntent)
163 : "unclear";
164 const title =
165 typeof r.title === "string" && r.title.trim()
166 ? r.title.trim().slice(0, 120)
167 : classifyHeuristically(fallbackBody).title;
168 const body =
169 typeof r.body_markdown === "string" && r.body_markdown.trim()
170 ? r.body_markdown.trim()
171 : fallbackBody.trim();
172 const hint =
173 typeof r.target_repo_id_hint === "string" && r.target_repo_id_hint.trim()
174 ? r.target_repo_id_hint.trim().slice(0, 200)
175 : undefined;
176 return { kind, title, body_markdown: body, target_repo_id_hint: hint };
177}
178
179/**
180 * Build the prompt sent to Claude. Kept in its own function so the test
181 * harness can assert on its contents if needed.
182 */
183export function buildInterpretPrompt(
184 transcript: string,
185 knownRepos: Array<{ id: string; fullName: string }>
186): string {
187 const repoBlock =
188 knownRepos.length > 0
189 ? `\n\nThe user's available repositories (id — name):\n${knownRepos
190 .slice(0, 25)
191 .map((r) => `- ${r.id} — ${r.fullName}`)
192 .join("\n")}`
193 : "";
194 return `You are classifying a phone-dictated note from a developer. They have just spoken into the Gluecron "voice-to-PR" feature and we need to decide what to do with their utterance.
195
196The transcript:
197"""
198${transcript.trim().slice(0, 4000)}
199"""${repoBlock}
200
201Decide which of the following best fits:
202 - "spec" : the user is describing a feature or change they want built. The autopilot will turn this into a draft PR via spec-to-PR.
203 - "issue" : the user is reporting a bug, a question, or an observation that should be filed for discussion, not implemented immediately.
204 - "unclear": you can't confidently decide — let the user pick.
205
206Polish the transcript into:
207 - A short imperative "title" (under 80 chars).
208 - A "body_markdown" expanding the request as a proper feature spec or bug report. Keep it faithful to what was said; do NOT invent acceptance criteria the user didn't mention. Format with short paragraphs or bullet lists where helpful.
209
210Respond ONLY with JSON in this exact shape:
211{
212 "kind": "spec" | "issue" | "unclear",
213 "title": "...",
214 "body_markdown": "...",
215 "target_repo_id_hint": "<repo-id from the list above, or omit>"
216}`;
217}
218
219// ---------------------------------------------------------------------------
220// interpretVoiceTranscript
221// ---------------------------------------------------------------------------
222
223/**
224 * Classify and polish a voice transcript. Never throws.
225 *
226 * Test injection: pass `client.call(prompt)` to swap out the real Claude
227 * call with a stub returning a JSON string.
228 */
229export async function interpretVoiceTranscript(
230 args: InterpretArgs
231): Promise<InterpretResult> {
232 const transcript =
233 typeof args.transcript === "string" ? args.transcript.trim() : "";
234 if (!transcript) return { ok: false, error: "transcript is empty" };
235
236 // Graceful degrade — no key, no model, but still a usable response.
237 if (!args.client && !config.anthropicApiKey) {
238 return { ok: true, suggestion: classifyHeuristically(transcript) };
239 }
240
241 const prompt = buildInterpretPrompt(transcript, args.knownRepos || []);
242
243 // Test injection path.
244 if (args.client) {
245 try {
246 const text = await args.client.call(prompt);
247 const parsed = safeParseJson(text);
248 return {
249 ok: true,
250 suggestion: normaliseInterpretation(parsed, transcript),
251 };
252 } catch (err) {
253 return {
254 ok: false,
255 error: err instanceof Error ? err.message : "client.call threw",
256 };
257 }
258 }
259
260 // Real Claude call. Dynamic import keeps the SDK out of the test bundle
261 // and means a missing dep never crashes the route.
262 try {
263 const { getAnthropic, MODEL_SONNET, extractText } = await import(
264 "./ai-client"
265 );
266 const client = getAnthropic();
267 const msg = await client.messages.create({
268 model: MODEL_SONNET,
269 max_tokens: 1024,
270 messages: [{ role: "user", content: prompt }],
271 });
272 const text = extractText(msg);
273 const parsed = safeParseJson(text);
274 return {
275 ok: true,
276 suggestion: normaliseInterpretation(parsed, transcript),
277 };
278 } catch (err) {
279 // Fall back to the heuristic so the demo doesn't dead-end on a 429.
280 const fallback = classifyHeuristically(transcript);
281 if (process.env.DEBUG_VOICE === "1") {
282 console.warn(
283 "[voice] Claude call failed, using heuristic:",
284 err instanceof Error ? err.message : err
285 );
286 }
287 return { ok: true, suggestion: fallback };
288 }
289}
290
291function safeParseJson(text: string): unknown {
292 if (!text) return null;
293 // Try a fenced block first.
294 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
295 const candidate = fenced ? fenced[1] : null;
296 if (candidate) {
297 try {
298 return JSON.parse(candidate);
299 } catch {
300 /* fall through */
301 }
302 }
303 const braced = text.match(/\{[\s\S]*\}/);
304 if (braced) {
305 try {
306 return JSON.parse(braced[0]);
307 } catch {
308 return null;
309 }
310 }
311 return null;
312}
313
314// ---------------------------------------------------------------------------
315// shipAsSpec
316// ---------------------------------------------------------------------------
317
318/**
319 * Resolve repo + owner + author rows for a given repository / user combo.
320 * Returns null on any miss so callers can return a clean error.
321 */
322async function resolveRepoAndAuthor(
323 repositoryId: string,
324 userId: string
325): Promise<
326 | {
327 ownerName: string;
328 repoName: string;
329 defaultBranch: string;
330 authorName: string;
331 authorEmail: string;
332 }
333 | null
334> {
335 try {
336 const [row] = await db
337 .select({
338 repoName: repositories.name,
339 defaultBranch: repositories.defaultBranch,
340 ownerName: users.username,
341 })
342 .from(repositories)
343 .leftJoin(users, eq(users.id, repositories.ownerId))
344 .where(eq(repositories.id, repositoryId))
345 .limit(1);
346 if (!row || !row.ownerName) return null;
347
348 const [authorRow] = await db
349 .select({ username: users.username, email: users.email })
350 .from(users)
351 .where(eq(users.id, userId))
352 .limit(1);
353 if (!authorRow) return null;
354
355 return {
356 ownerName: row.ownerName,
357 repoName: row.repoName,
358 defaultBranch: row.defaultBranch || "main",
359 authorName: authorRow.username,
360 authorEmail:
361 authorRow.email || `${authorRow.username}@users.noreply.gluecron`,
362 };
363 } catch {
364 return null;
365 }
366}
367
368/**
369 * Commit a `.gluecron/specs/voice-<slug>.md` spec to the repo's default
370 * branch with `status: ready`. The autopilot picks it up on the next tick.
371 */
372export async function shipAsSpec(
373 args: ShipSpecArgs
374): Promise<ShipSpecResult> {
375 const transcript =
376 typeof args.transcript === "string" ? args.transcript.trim() : "";
377 if (!transcript) return { ok: false, error: "transcript is empty" };
378 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
379 if (!args.userId) return { ok: false, error: "userId required" };
380
381 const resolved = await resolveRepoAndAuthor(args.repositoryId, args.userId);
382 if (!resolved) return { ok: false, error: "repo or user not found" };
383
384 // Use the supplied interpretation when present, otherwise fall back to
385 // the heuristic so we always have a polished title.
386 const interp =
387 args.interpretation || classifyHeuristically(transcript);
388 const slug =
389 args.slugOverride && args.slugOverride.trim()
390 ? voiceSlug(args.slugOverride)
391 : voiceSlug(interp.title || transcript);
392 // Stamp the filename with a short timestamp so back-to-back voice notes
393 // with the same slug don't collide on the autopilot dedup.
394 const specPath = `.gluecron/specs/voice-${slug}-${Date.now().toString(36)}.md`;
395
396 const fm: Record<string, string> = {
397 title: interp.title || "Voice spec",
398 status: "ready",
399 source: "voice-to-pr",
400 };
401 const body = `# ${interp.title || "Voice spec"}\n\n${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._\n`;
402 const content = serialiseSpec(fm, body);
403 const bytes = new TextEncoder().encode(content);
404
405 const res = await createOrUpdateFileOnBranch({
406 owner: resolved.ownerName,
407 name: resolved.repoName,
408 branch: resolved.defaultBranch,
409 filePath: specPath,
410 bytes,
411 message: `voice: ${interp.title || "captured spec"}`,
412 authorName: resolved.authorName,
413 authorEmail: resolved.authorEmail,
414 });
415 if ("error" in res) {
416 return { ok: false, error: `git write failed: ${res.error}` };
417 }
418 return {
419 ok: true,
420 specPath,
421 commitSha: res.commitSha,
422 branch: resolved.defaultBranch,
423 };
424}
425
426// ---------------------------------------------------------------------------
427// createIssueFromVoice
428// ---------------------------------------------------------------------------
429
430/**
431 * Insert an issue row mirroring `src/routes/issues.tsx`. The route handler
432 * wires up the AI triage trigger separately so this function stays
433 * dependency-light + easy to test.
434 */
435export async function createIssueFromVoice(
436 args: CreateIssueArgs
437): Promise<CreateIssueResult> {
438 const transcript =
439 typeof args.transcript === "string" ? args.transcript.trim() : "";
440 if (!transcript) return { ok: false, error: "transcript is empty" };
441 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
442 if (!args.userId) return { ok: false, error: "userId required" };
443
444 let repoRow:
445 | {
446 id: string;
447 name: string;
448 issueCount: number;
449 ownerName: string | null;
450 }
451 | undefined;
452 try {
453 const [row] = await db
454 .select({
455 id: repositories.id,
456 name: repositories.name,
457 issueCount: repositories.issueCount,
458 ownerName: users.username,
459 })
460 .from(repositories)
461 .leftJoin(users, eq(users.id, repositories.ownerId))
462 .where(eq(repositories.id, args.repositoryId))
463 .limit(1);
464 repoRow = row;
465 } catch {
466 return { ok: false, error: "db lookup failed" };
467 }
468 if (!repoRow || !repoRow.ownerName) {
469 return { ok: false, error: "repo not found" };
470 }
471
472 const interp = args.interpretation || classifyHeuristically(transcript);
473 const title = (interp.title || transcript.slice(0, 80)).slice(0, 200);
474 const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`;
475
476 try {
477 const [issue] = await db
478 .insert(issues)
479 .values({
480 repositoryId: repoRow.id,
481 authorId: args.userId,
482 title,
483 body,
484 })
485 .returning();
486 if (!issue) return { ok: false, error: "issue insert returned no row" };
487 // Best-effort counter update; mirrors src/routes/issues.tsx.
488 try {
489 await db
490 .update(repositories)
491 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
492 .where(eq(repositories.id, repoRow.id));
493 } catch {
494 /* non-fatal */
495 }
496 return {
497 ok: true,
498 issueNumber: issue.number,
499 ownerName: repoRow.ownerName,
500 repoName: repoRow.name,
501 };
502 } catch (err) {
503 return {
504 ok: false,
505 error: `issue insert failed: ${err instanceof Error ? err.message : String(err)}`,
506 };
507 }
508}
509
510// ---------------------------------------------------------------------------
511// Test-only exports
512// ---------------------------------------------------------------------------
513
514export const __voiceTest = {
515 classifyHeuristically,
516 safeParseJson,
517 resolveRepoAndAuthor,
518};
519
520/** True if access to the user's repo list should fan out (used by the route). */
521export async function listUserRepos(
522 userId: string
523): Promise<Array<{ id: string; fullName: string }>> {
524 try {
525 const rows = await db
526 .select({
527 id: repositories.id,
528 name: repositories.name,
529 ownerName: users.username,
530 })
531 .from(repositories)
532 .leftJoin(users, eq(users.id, repositories.ownerId))
533 .where(
534 and(
535 eq(repositories.ownerId, userId),
536 eq(repositories.isArchived, false)
537 )
538 )
539 .limit(100);
540 return rows
541 .filter((r) => r.ownerName)
542 .map((r) => ({ id: r.id, fullName: `${r.ownerName}/${r.name}` }));
543 } catch {
544 return [];
545 }
546}