Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame561 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 });
0c3eee5Claude272 try {
273 const { recordAiCost, extractUsage } = await import(
274 "./ai-cost-tracker"
275 );
276 const usage = extractUsage(msg);
277 await recordAiCost({
278 model: MODEL_SONNET,
279 inputTokens: usage.input,
280 outputTokens: usage.output,
281 category: "voice",
282 sourceKind: "voice_transcript",
283 });
284 } catch {
285 /* swallow — best-effort */
286 }
45f3b73Claude287 const text = extractText(msg);
288 const parsed = safeParseJson(text);
289 return {
290 ok: true,
291 suggestion: normaliseInterpretation(parsed, transcript),
292 };
293 } catch (err) {
294 // Fall back to the heuristic so the demo doesn't dead-end on a 429.
295 const fallback = classifyHeuristically(transcript);
296 if (process.env.DEBUG_VOICE === "1") {
297 console.warn(
298 "[voice] Claude call failed, using heuristic:",
299 err instanceof Error ? err.message : err
300 );
301 }
302 return { ok: true, suggestion: fallback };
303 }
304}
305
306function safeParseJson(text: string): unknown {
307 if (!text) return null;
308 // Try a fenced block first.
309 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
310 const candidate = fenced ? fenced[1] : null;
311 if (candidate) {
312 try {
313 return JSON.parse(candidate);
314 } catch {
315 /* fall through */
316 }
317 }
318 const braced = text.match(/\{[\s\S]*\}/);
319 if (braced) {
320 try {
321 return JSON.parse(braced[0]);
322 } catch {
323 return null;
324 }
325 }
326 return null;
327}
328
329// ---------------------------------------------------------------------------
330// shipAsSpec
331// ---------------------------------------------------------------------------
332
333/**
334 * Resolve repo + owner + author rows for a given repository / user combo.
335 * Returns null on any miss so callers can return a clean error.
336 */
337async function resolveRepoAndAuthor(
338 repositoryId: string,
339 userId: string
340): Promise<
341 | {
342 ownerName: string;
343 repoName: string;
344 defaultBranch: string;
345 authorName: string;
346 authorEmail: string;
347 }
348 | null
349> {
350 try {
351 const [row] = await db
352 .select({
353 repoName: repositories.name,
354 defaultBranch: repositories.defaultBranch,
355 ownerName: users.username,
356 })
357 .from(repositories)
358 .leftJoin(users, eq(users.id, repositories.ownerId))
359 .where(eq(repositories.id, repositoryId))
360 .limit(1);
361 if (!row || !row.ownerName) return null;
362
363 const [authorRow] = await db
364 .select({ username: users.username, email: users.email })
365 .from(users)
366 .where(eq(users.id, userId))
367 .limit(1);
368 if (!authorRow) return null;
369
370 return {
371 ownerName: row.ownerName,
372 repoName: row.repoName,
373 defaultBranch: row.defaultBranch || "main",
374 authorName: authorRow.username,
375 authorEmail:
376 authorRow.email || `${authorRow.username}@users.noreply.gluecron`,
377 };
378 } catch {
379 return null;
380 }
381}
382
383/**
384 * Commit a `.gluecron/specs/voice-<slug>.md` spec to the repo's default
385 * branch with `status: ready`. The autopilot picks it up on the next tick.
386 */
387export async function shipAsSpec(
388 args: ShipSpecArgs
389): Promise<ShipSpecResult> {
390 const transcript =
391 typeof args.transcript === "string" ? args.transcript.trim() : "";
392 if (!transcript) return { ok: false, error: "transcript is empty" };
393 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
394 if (!args.userId) return { ok: false, error: "userId required" };
395
396 const resolved = await resolveRepoAndAuthor(args.repositoryId, args.userId);
397 if (!resolved) return { ok: false, error: "repo or user not found" };
398
399 // Use the supplied interpretation when present, otherwise fall back to
400 // the heuristic so we always have a polished title.
401 const interp =
402 args.interpretation || classifyHeuristically(transcript);
403 const slug =
404 args.slugOverride && args.slugOverride.trim()
405 ? voiceSlug(args.slugOverride)
406 : voiceSlug(interp.title || transcript);
407 // Stamp the filename with a short timestamp so back-to-back voice notes
408 // with the same slug don't collide on the autopilot dedup.
409 const specPath = `.gluecron/specs/voice-${slug}-${Date.now().toString(36)}.md`;
410
411 const fm: Record<string, string> = {
412 title: interp.title || "Voice spec",
413 status: "ready",
414 source: "voice-to-pr",
415 };
416 const body = `# ${interp.title || "Voice spec"}\n\n${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._\n`;
417 const content = serialiseSpec(fm, body);
418 const bytes = new TextEncoder().encode(content);
419
420 const res = await createOrUpdateFileOnBranch({
421 owner: resolved.ownerName,
422 name: resolved.repoName,
423 branch: resolved.defaultBranch,
424 filePath: specPath,
425 bytes,
426 message: `voice: ${interp.title || "captured spec"}`,
427 authorName: resolved.authorName,
428 authorEmail: resolved.authorEmail,
429 });
430 if ("error" in res) {
431 return { ok: false, error: `git write failed: ${res.error}` };
432 }
433 return {
434 ok: true,
435 specPath,
436 commitSha: res.commitSha,
437 branch: resolved.defaultBranch,
438 };
439}
440
441// ---------------------------------------------------------------------------
442// createIssueFromVoice
443// ---------------------------------------------------------------------------
444
445/**
446 * Insert an issue row mirroring `src/routes/issues.tsx`. The route handler
447 * wires up the AI triage trigger separately so this function stays
448 * dependency-light + easy to test.
449 */
450export async function createIssueFromVoice(
451 args: CreateIssueArgs
452): Promise<CreateIssueResult> {
453 const transcript =
454 typeof args.transcript === "string" ? args.transcript.trim() : "";
455 if (!transcript) return { ok: false, error: "transcript is empty" };
456 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
457 if (!args.userId) return { ok: false, error: "userId required" };
458
459 let repoRow:
460 | {
461 id: string;
462 name: string;
463 issueCount: number;
464 ownerName: string | null;
465 }
466 | undefined;
467 try {
468 const [row] = await db
469 .select({
470 id: repositories.id,
471 name: repositories.name,
472 issueCount: repositories.issueCount,
473 ownerName: users.username,
474 })
475 .from(repositories)
476 .leftJoin(users, eq(users.id, repositories.ownerId))
477 .where(eq(repositories.id, args.repositoryId))
478 .limit(1);
479 repoRow = row;
480 } catch {
481 return { ok: false, error: "db lookup failed" };
482 }
483 if (!repoRow || !repoRow.ownerName) {
484 return { ok: false, error: "repo not found" };
485 }
486
487 const interp = args.interpretation || classifyHeuristically(transcript);
488 const title = (interp.title || transcript.slice(0, 80)).slice(0, 200);
489 const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`;
490
491 try {
492 const [issue] = await db
493 .insert(issues)
494 .values({
495 repositoryId: repoRow.id,
496 authorId: args.userId,
497 title,
498 body,
499 })
500 .returning();
501 if (!issue) return { ok: false, error: "issue insert returned no row" };
502 // Best-effort counter update; mirrors src/routes/issues.tsx.
503 try {
504 await db
505 .update(repositories)
506 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
507 .where(eq(repositories.id, repoRow.id));
508 } catch {
509 /* non-fatal */
510 }
511 return {
512 ok: true,
513 issueNumber: issue.number,
514 ownerName: repoRow.ownerName,
515 repoName: repoRow.name,
516 };
517 } catch (err) {
518 return {
519 ok: false,
520 error: `issue insert failed: ${err instanceof Error ? err.message : String(err)}`,
521 };
522 }
523}
524
525// ---------------------------------------------------------------------------
526// Test-only exports
527// ---------------------------------------------------------------------------
528
529export const __voiceTest = {
530 classifyHeuristically,
531 safeParseJson,
532 resolveRepoAndAuthor,
533};
534
535/** True if access to the user's repo list should fan out (used by the route). */
536export async function listUserRepos(
537 userId: string
538): Promise<Array<{ id: string; fullName: string }>> {
539 try {
540 const rows = await db
541 .select({
542 id: repositories.id,
543 name: repositories.name,
544 ownerName: users.username,
545 })
546 .from(repositories)
547 .leftJoin(users, eq(users.id, repositories.ownerId))
548 .where(
549 and(
550 eq(repositories.ownerId, userId),
551 eq(repositories.isArchived, false)
552 )
553 )
554 .limit(100);
555 return rows
556 .filter((r) => r.ownerName)
557 .map((r) => ({ id: r.id, fullName: `${r.ownerName}/${r.name}` }));
558 } catch {
559 return [];
560 }
561}