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.tsBlame591 lines · 2 contributors
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";
57cdb50ccantynz-alt37import { db as realDb } from "../db";
45f3b73Claude38import { issues, repositories, users } from "../db/schema";
57cdb50ccantynz-alt39import { createOrUpdateFileOnBranch as realWriteFile } from "../git/repository";
45f3b73Claude40import { 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 */
57cdb50ccantynz-alt112/**
113 * Injectable seams for the database and the git writer.
114 *
115 * These exist so tests do not have to `mock.module("../db")`. That call is
116 * process-global: bun swaps the module registry entry, and it does NOT rebind
117 * namespaces an importer already holds. Because bun loads every test module
118 * before running any test, a module-scope db mock in one file binds itself
119 * into unrelated modules (routes/wikis.tsx, routes/auth.tsx) before any
120 * afterAll can restore it — which is exactly how this file's own test suite
121 * silently broke `wikis — route smoke` and `auth landing pages`, in full runs
122 * only, for a long time.
123 *
124 * Same pattern as push-workflow-sync.ts, first-repo-scaffold.ts and
125 * index-existing-repo.ts. Default to the real implementations so production
126 * call sites are unchanged.
127 */
128export interface VoiceDeps {
129 db: typeof realDb;
130 writeFile: typeof realWriteFile;
131}
132
133let _deps: VoiceDeps = { db: realDb, writeFile: realWriteFile };
134
135/** Test seam. Pass null to restore the real implementations. */
136export function __setVoiceDepsForTests(deps: Partial<VoiceDeps> | null): void {
137 _deps = deps
138 ? { db: deps.db ?? realDb, writeFile: deps.writeFile ?? realWriteFile }
139 : { db: realDb, writeFile: realWriteFile };
140}
141
45f3b73Claude142export function voiceSlug(text: string): string {
143 const base = (text || "")
144 .toLowerCase()
145 .replace(/[^a-z0-9]+/g, "-")
146 .replace(/^-+|-+$/g, "")
147 .slice(0, 40);
148 return base || "voice-note";
149}
150
151/** Heuristic fallback used when the Claude API key is missing. */
152function classifyHeuristically(transcript: string): VoiceInterpretation {
153 const t = transcript.trim();
154 const lower = t.toLowerCase();
155 const looksLikeBug =
156 /(bug|broken|crash|error|doesn'?t work|not working|regression|fails?)/i.test(
157 lower
158 );
159 const looksLikeFeature =
160 /(add|build|implement|create|wire up|ship|introduce|support)/i.test(lower);
161 const kind: VoiceIntent = looksLikeBug
162 ? "issue"
163 : looksLikeFeature
164 ? "spec"
165 : "unclear";
166 // First clause becomes the title; cap at 80 chars.
167 const firstClause =
168 t.split(/[.!?\n]/)[0]?.trim() || t.slice(0, 80) || "Voice note";
169 const title = firstClause.length > 80 ? `${firstClause.slice(0, 77)}...` : firstClause;
170 return {
171 kind,
172 title: title.replace(/^[a-z]/, (c) => c.toUpperCase()),
173 body_markdown: t,
174 };
175}
176
177/**
178 * Sanitise the model's response into a `VoiceInterpretation`. Anything that
179 * doesn't conform falls back to "unclear" so the UI shows the picker.
180 */
181export function normaliseInterpretation(
182 raw: unknown,
183 fallbackBody: string
184): VoiceInterpretation {
185 if (!raw || typeof raw !== "object") {
186 return classifyHeuristically(fallbackBody);
187 }
188 const r = raw as Record<string, unknown>;
189 const kindRaw = typeof r.kind === "string" ? r.kind.toLowerCase() : "";
190 const kind: VoiceIntent =
191 kindRaw === "spec" || kindRaw === "issue" || kindRaw === "unclear"
192 ? (kindRaw as VoiceIntent)
193 : "unclear";
194 const title =
195 typeof r.title === "string" && r.title.trim()
196 ? r.title.trim().slice(0, 120)
197 : classifyHeuristically(fallbackBody).title;
198 const body =
199 typeof r.body_markdown === "string" && r.body_markdown.trim()
200 ? r.body_markdown.trim()
201 : fallbackBody.trim();
202 const hint =
203 typeof r.target_repo_id_hint === "string" && r.target_repo_id_hint.trim()
204 ? r.target_repo_id_hint.trim().slice(0, 200)
205 : undefined;
206 return { kind, title, body_markdown: body, target_repo_id_hint: hint };
207}
208
209/**
210 * Build the prompt sent to Claude. Kept in its own function so the test
211 * harness can assert on its contents if needed.
212 */
213export function buildInterpretPrompt(
214 transcript: string,
215 knownRepos: Array<{ id: string; fullName: string }>
216): string {
217 const repoBlock =
218 knownRepos.length > 0
219 ? `\n\nThe user's available repositories (id — name):\n${knownRepos
220 .slice(0, 25)
221 .map((r) => `- ${r.id} — ${r.fullName}`)
222 .join("\n")}`
223 : "";
224 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.
225
226The transcript:
227"""
228${transcript.trim().slice(0, 4000)}
229"""${repoBlock}
230
231Decide which of the following best fits:
232 - "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.
233 - "issue" : the user is reporting a bug, a question, or an observation that should be filed for discussion, not implemented immediately.
234 - "unclear": you can't confidently decide — let the user pick.
235
236Polish the transcript into:
237 - A short imperative "title" (under 80 chars).
238 - 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.
239
240Respond ONLY with JSON in this exact shape:
241{
242 "kind": "spec" | "issue" | "unclear",
243 "title": "...",
244 "body_markdown": "...",
245 "target_repo_id_hint": "<repo-id from the list above, or omit>"
246}`;
247}
248
249// ---------------------------------------------------------------------------
250// interpretVoiceTranscript
251// ---------------------------------------------------------------------------
252
253/**
254 * Classify and polish a voice transcript. Never throws.
255 *
256 * Test injection: pass `client.call(prompt)` to swap out the real Claude
257 * call with a stub returning a JSON string.
258 */
259export async function interpretVoiceTranscript(
260 args: InterpretArgs
261): Promise<InterpretResult> {
262 const transcript =
263 typeof args.transcript === "string" ? args.transcript.trim() : "";
264 if (!transcript) return { ok: false, error: "transcript is empty" };
265
266 // Graceful degrade — no key, no model, but still a usable response.
267 if (!args.client && !config.anthropicApiKey) {
268 return { ok: true, suggestion: classifyHeuristically(transcript) };
269 }
270
271 const prompt = buildInterpretPrompt(transcript, args.knownRepos || []);
272
273 // Test injection path.
274 if (args.client) {
275 try {
276 const text = await args.client.call(prompt);
277 const parsed = safeParseJson(text);
278 return {
279 ok: true,
280 suggestion: normaliseInterpretation(parsed, transcript),
281 };
282 } catch (err) {
283 return {
284 ok: false,
285 error: err instanceof Error ? err.message : "client.call threw",
286 };
287 }
288 }
289
290 // Real Claude call. Dynamic import keeps the SDK out of the test bundle
291 // and means a missing dep never crashes the route.
292 try {
293 const { getAnthropic, MODEL_SONNET, extractText } = await import(
294 "./ai-client"
295 );
296 const client = getAnthropic();
297 const msg = await client.messages.create({
298 model: MODEL_SONNET,
299 max_tokens: 1024,
300 messages: [{ role: "user", content: prompt }],
301 });
0c3eee5Claude302 try {
303 const { recordAiCost, extractUsage } = await import(
304 "./ai-cost-tracker"
305 );
306 const usage = extractUsage(msg);
307 await recordAiCost({
308 model: MODEL_SONNET,
309 inputTokens: usage.input,
310 outputTokens: usage.output,
311 category: "voice",
312 sourceKind: "voice_transcript",
313 });
314 } catch {
315 /* swallow — best-effort */
316 }
45f3b73Claude317 const text = extractText(msg);
318 const parsed = safeParseJson(text);
319 return {
320 ok: true,
321 suggestion: normaliseInterpretation(parsed, transcript),
322 };
323 } catch (err) {
324 // Fall back to the heuristic so the demo doesn't dead-end on a 429.
325 const fallback = classifyHeuristically(transcript);
326 if (process.env.DEBUG_VOICE === "1") {
327 console.warn(
328 "[voice] Claude call failed, using heuristic:",
329 err instanceof Error ? err.message : err
330 );
331 }
332 return { ok: true, suggestion: fallback };
333 }
334}
335
336function safeParseJson(text: string): unknown {
337 if (!text) return null;
338 // Try a fenced block first.
339 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
340 const candidate = fenced ? fenced[1] : null;
341 if (candidate) {
342 try {
343 return JSON.parse(candidate);
344 } catch {
345 /* fall through */
346 }
347 }
348 const braced = text.match(/\{[\s\S]*\}/);
349 if (braced) {
350 try {
351 return JSON.parse(braced[0]);
352 } catch {
353 return null;
354 }
355 }
356 return null;
357}
358
359// ---------------------------------------------------------------------------
360// shipAsSpec
361// ---------------------------------------------------------------------------
362
363/**
364 * Resolve repo + owner + author rows for a given repository / user combo.
365 * Returns null on any miss so callers can return a clean error.
366 */
367async function resolveRepoAndAuthor(
368 repositoryId: string,
369 userId: string
370): Promise<
371 | {
372 ownerName: string;
373 repoName: string;
374 defaultBranch: string;
375 authorName: string;
376 authorEmail: string;
377 }
378 | null
379> {
380 try {
57cdb50ccantynz-alt381 const [row] = await _deps.db
45f3b73Claude382 .select({
383 repoName: repositories.name,
384 defaultBranch: repositories.defaultBranch,
385 ownerName: users.username,
386 })
387 .from(repositories)
388 .leftJoin(users, eq(users.id, repositories.ownerId))
389 .where(eq(repositories.id, repositoryId))
390 .limit(1);
391 if (!row || !row.ownerName) return null;
392
57cdb50ccantynz-alt393 const [authorRow] = await _deps.db
45f3b73Claude394 .select({ username: users.username, email: users.email })
395 .from(users)
396 .where(eq(users.id, userId))
397 .limit(1);
398 if (!authorRow) return null;
399
400 return {
401 ownerName: row.ownerName,
402 repoName: row.repoName,
403 defaultBranch: row.defaultBranch || "main",
404 authorName: authorRow.username,
405 authorEmail:
406 authorRow.email || `${authorRow.username}@users.noreply.gluecron`,
407 };
408 } catch {
409 return null;
410 }
411}
412
413/**
414 * Commit a `.gluecron/specs/voice-<slug>.md` spec to the repo's default
415 * branch with `status: ready`. The autopilot picks it up on the next tick.
416 */
417export async function shipAsSpec(
418 args: ShipSpecArgs
419): Promise<ShipSpecResult> {
420 const transcript =
421 typeof args.transcript === "string" ? args.transcript.trim() : "";
422 if (!transcript) return { ok: false, error: "transcript is empty" };
423 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
424 if (!args.userId) return { ok: false, error: "userId required" };
425
426 const resolved = await resolveRepoAndAuthor(args.repositoryId, args.userId);
427 if (!resolved) return { ok: false, error: "repo or user not found" };
428
429 // Use the supplied interpretation when present, otherwise fall back to
430 // the heuristic so we always have a polished title.
431 const interp =
432 args.interpretation || classifyHeuristically(transcript);
433 const slug =
434 args.slugOverride && args.slugOverride.trim()
435 ? voiceSlug(args.slugOverride)
436 : voiceSlug(interp.title || transcript);
437 // Stamp the filename with a short timestamp so back-to-back voice notes
438 // with the same slug don't collide on the autopilot dedup.
439 const specPath = `.gluecron/specs/voice-${slug}-${Date.now().toString(36)}.md`;
440
441 const fm: Record<string, string> = {
442 title: interp.title || "Voice spec",
443 status: "ready",
444 source: "voice-to-pr",
445 };
446 const body = `# ${interp.title || "Voice spec"}\n\n${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._\n`;
447 const content = serialiseSpec(fm, body);
448 const bytes = new TextEncoder().encode(content);
449
57cdb50ccantynz-alt450 const res = await _deps.writeFile({
45f3b73Claude451 owner: resolved.ownerName,
452 name: resolved.repoName,
453 branch: resolved.defaultBranch,
454 filePath: specPath,
455 bytes,
456 message: `voice: ${interp.title || "captured spec"}`,
457 authorName: resolved.authorName,
458 authorEmail: resolved.authorEmail,
459 });
460 if ("error" in res) {
461 return { ok: false, error: `git write failed: ${res.error}` };
462 }
463 return {
464 ok: true,
465 specPath,
466 commitSha: res.commitSha,
467 branch: resolved.defaultBranch,
468 };
469}
470
471// ---------------------------------------------------------------------------
472// createIssueFromVoice
473// ---------------------------------------------------------------------------
474
475/**
476 * Insert an issue row mirroring `src/routes/issues.tsx`. The route handler
477 * wires up the AI triage trigger separately so this function stays
478 * dependency-light + easy to test.
479 */
480export async function createIssueFromVoice(
481 args: CreateIssueArgs
482): Promise<CreateIssueResult> {
483 const transcript =
484 typeof args.transcript === "string" ? args.transcript.trim() : "";
485 if (!transcript) return { ok: false, error: "transcript is empty" };
486 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
487 if (!args.userId) return { ok: false, error: "userId required" };
488
489 let repoRow:
490 | {
491 id: string;
492 name: string;
493 issueCount: number;
494 ownerName: string | null;
495 }
496 | undefined;
497 try {
57cdb50ccantynz-alt498 const [row] = await _deps.db
45f3b73Claude499 .select({
500 id: repositories.id,
501 name: repositories.name,
502 issueCount: repositories.issueCount,
503 ownerName: users.username,
504 })
505 .from(repositories)
506 .leftJoin(users, eq(users.id, repositories.ownerId))
507 .where(eq(repositories.id, args.repositoryId))
508 .limit(1);
509 repoRow = row;
510 } catch {
511 return { ok: false, error: "db lookup failed" };
512 }
513 if (!repoRow || !repoRow.ownerName) {
514 return { ok: false, error: "repo not found" };
515 }
516
517 const interp = args.interpretation || classifyHeuristically(transcript);
518 const title = (interp.title || transcript.slice(0, 80)).slice(0, 200);
519 const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`;
520
521 try {
57cdb50ccantynz-alt522 const [issue] = await _deps.db
45f3b73Claude523 .insert(issues)
524 .values({
525 repositoryId: repoRow.id,
526 authorId: args.userId,
527 title,
528 body,
529 })
530 .returning();
531 if (!issue) return { ok: false, error: "issue insert returned no row" };
532 // Best-effort counter update; mirrors src/routes/issues.tsx.
533 try {
57cdb50ccantynz-alt534 await _deps.db
45f3b73Claude535 .update(repositories)
536 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
537 .where(eq(repositories.id, repoRow.id));
538 } catch {
539 /* non-fatal */
540 }
541 return {
542 ok: true,
543 issueNumber: issue.number,
544 ownerName: repoRow.ownerName,
545 repoName: repoRow.name,
546 };
547 } catch (err) {
548 return {
549 ok: false,
550 error: `issue insert failed: ${err instanceof Error ? err.message : String(err)}`,
551 };
552 }
553}
554
555// ---------------------------------------------------------------------------
556// Test-only exports
557// ---------------------------------------------------------------------------
558
559export const __voiceTest = {
560 classifyHeuristically,
561 safeParseJson,
562 resolveRepoAndAuthor,
563};
564
565/** True if access to the user's repo list should fan out (used by the route). */
566export async function listUserRepos(
567 userId: string
568): Promise<Array<{ id: string; fullName: string }>> {
569 try {
57cdb50ccantynz-alt570 const rows = await _deps.db
45f3b73Claude571 .select({
572 id: repositories.id,
573 name: repositories.name,
574 ownerName: users.username,
575 })
576 .from(repositories)
577 .leftJoin(users, eq(users.id, repositories.ownerId))
578 .where(
579 and(
580 eq(repositories.ownerId, userId),
581 eq(repositories.isArchived, false)
582 )
583 )
584 .limit(100);
585 return rows
586 .filter((r) => r.ownerName)
587 .map((r) => ({ id: r.id, fullName: `${r.ownerName}/${r.name}` }));
588 } catch {
589 return [];
590 }
591}