Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit0410f22unknown_key

feat: proactive AI pair programmer — context-aware suggestions beyond autocomplete

feat: proactive AI pair programmer — context-aware suggestions beyond autocomplete

Adds src/lib/ai-pair.ts (~430 lines) and src/routes/pair-programmer.ts
(~170 lines) to surface open-PR diffs, recent CI failures, related issues,
and file symbols to the editor without the developer having to ask.

Three new endpoints:
  GET  /api/pair/ping          — health check
  POST /api/pair/context       — assemble PairContext for a file
  POST /api/pair/suggest       — context + MODEL_SONNET suggestion (30/min, 30s cache)

Wired into src/app.tsx alongside copilotRoutes. Comment added to
copilot.ts pointing developers to the new proactive surface.

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: a621550
3 files changed+77700410f2291d74b83f11159b217fc544db716a20fe
3 changed files+777−0
Addedsrc/lib/ai-pair.ts+632−0View fileUnifiedSplit
1/**
2 * Proactive AI pair programmer — assembles rich editor context and generates
3 * suggestions that go beyond simple autocomplete.
4 *
5 * Two public entry points:
6 *
7 * assemblePairContext(repoId, filePath, userId)
8 * Queries the DB for open PRs touching this file, recent gate failures,
9 * related issues, and extracts top-level symbols from the file. Result is
10 * cached in-memory for 3 minutes per (repoId, filePath, userId) triple.
11 *
12 * generatePairSuggestion(prefix, suffix, filePath, context)
13 * Sends the assembled context to Claude (MODEL_SONNET) and returns a
14 * structured PairSuggestion. Falls back to a plain prefix completion on
15 * any error. Never throws.
16 *
17 * Design notes:
18 * - All DB queries are read-only and best-effort; individual failures are
19 * swallowed so the editor never blocks on a DB hiccup.
20 * - Git operations (diff --name-only, diff excerpt) run as Bun subprocesses
21 * against the bare repo on disk. Results are cached per PR for 5 minutes.
22 * - We intentionally avoid importing anything from src/routes/* to keep
23 * this file usable in background workers without spinning up HTTP handlers.
24 */
25
26import { and, desc, eq, gte, inArray } from "drizzle-orm";
27import { createHash } from "crypto";
28import { db } from "../db";
29import {
30 gateRuns,
31 issues,
32 pullRequests,
33 repositories,
34} from "../db/schema";
35import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
36import { extractSymbols, detectLanguage } from "./symbols";
37
38// ---------------------------------------------------------------------------
39// Public types
40// ---------------------------------------------------------------------------
41
42/** Contextual signals assembled for a specific file + user combination. */
43export interface PairContext {
44 openPrForFile?: {
45 prNumber: number;
46 title: string;
47 branch: string;
48 /** Unified diff excerpt for this file, max 2 KB. */
49 changedLines: string;
50 };
51 recentGateFailure?: {
52 runType: string;
53 /** First 500 chars of error log (summary or details field). */
54 errorSummary: string;
55 failedAt: Date;
56 };
57 /** Top-level export names detected in this file via regex. */
58 fileSymbols?: string[];
59 relatedIssue?: {
60 issueNumber: number;
61 title: string;
62 };
63}
64
65/** A structured suggestion returned to the editor. */
66export interface PairSuggestion {
67 type: "completion" | "warning" | "context_note" | "fix_available";
68 /** One line, shown inline in editor. */
69 headline: string;
70 /** Shown on hover/expand. */
71 detail?: string;
72 /** e.g. "Apply fix" */
73 actionLabel?: string;
74 /** Diff to apply or URL to navigate to. */
75 actionPayload?: string;
76}
77
78// ---------------------------------------------------------------------------
79// In-memory caches
80// ---------------------------------------------------------------------------
81
82interface CacheEntry<T> {
83 value: T;
84 expiresAt: number;
85}
86
87/** Per-(repoId:filePath:userId) context cache — 3 min TTL. */
88const contextCache = new Map<string, CacheEntry<PairContext>>();
89const CONTEXT_TTL_MS = 3 * 60 * 1000;
90
91/** Per-(prId) file-list cache — 5 min TTL. */
92const prFilesCache = new Map<string, CacheEntry<string[]>>();
93const PR_FILES_TTL_MS = 5 * 60 * 1000;
94
95/** Per-(repoId:filePath:userId:prefixSlice) suggestion cache — 30 s TTL. */
96const suggestCache = new Map<string, CacheEntry<PairSuggestion>>();
97const SUGGEST_TTL_MS = 30 * 1000;
98
99function cacheGet<T>(store: Map<string, CacheEntry<T>>, key: string): T | undefined {
100 const entry = store.get(key);
101 if (!entry) return undefined;
102 if (Date.now() > entry.expiresAt) {
103 store.delete(key);
104 return undefined;
105 }
106 return entry.value;
107}
108
109function cacheSet<T>(store: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number): void {
110 store.set(key, { value, expiresAt: Date.now() + ttlMs });
111}
112
113// ---------------------------------------------------------------------------
114// Git helpers
115// ---------------------------------------------------------------------------
116
117/**
118 * List files touched by a PR's branch relative to its base branch.
119 * Returns an empty array on any error.
120 */
121async function prChangedFiles(
122 prId: string,
123 repoPath: string,
124 baseBranch: string,
125 headBranch: string
126): Promise<string[]> {
127 const cached = cacheGet(prFilesCache, prId);
128 if (cached !== undefined) return cached;
129
130 try {
131 const proc = Bun.spawn(
132 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
133 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
134 );
135 const text = await new Response(proc.stdout).text();
136 await proc.exited;
137 const files = text
138 .trim()
139 .split("\n")
140 .filter(Boolean);
141 cacheSet(prFilesCache, prId, files, PR_FILES_TTL_MS);
142 return files;
143 } catch {
144 return [];
145 }
146}
147
148/**
149 * Extract the diff excerpt for a specific file from a PR's branch comparison.
150 * Returns at most 2 KB of unified diff text.
151 */
152async function fileDiffExcerpt(
153 repoPath: string,
154 baseBranch: string,
155 headBranch: string,
156 filePath: string
157): Promise<string> {
158 try {
159 const proc = Bun.spawn(
160 ["git", "diff", `${baseBranch}...${headBranch}`, "--", filePath],
161 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
162 );
163 const text = await new Response(proc.stdout).text();
164 await proc.exited;
165 // Cap at 2 KB.
166 return text.slice(0, 2048);
167 } catch {
168 return "";
169 }
170}
171
172// ---------------------------------------------------------------------------
173// Symbol extraction from file content in the repo
174// ---------------------------------------------------------------------------
175
176/**
177 * Read a file from the HEAD of the repo's default branch and extract top-level
178 * symbol names. Returns an empty array on any error.
179 */
180async function extractFileSymbols(
181 repoPath: string,
182 filePath: string,
183 defaultBranch: string
184): Promise<string[]> {
185 try {
186 const proc = Bun.spawn(
187 ["git", "show", `${defaultBranch}:${filePath}`],
188 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
189 );
190 const content = await new Response(proc.stdout).text();
191 await proc.exited;
192 if (!content) return [];
193 const lang = detectLanguage(filePath);
194 if (!lang) return [];
195 const symbols = extractSymbols(content, lang);
196 // Return unique names, up to 30.
197 const seen = new Set<string>();
198 const names: string[] = [];
199 for (const s of symbols) {
200 if (!seen.has(s.name)) {
201 seen.add(s.name);
202 names.push(s.name);
203 if (names.length >= 30) break;
204 }
205 }
206 return names;
207 } catch {
208 return [];
209 }
210}
211
212// ---------------------------------------------------------------------------
213// DB helpers
214// ---------------------------------------------------------------------------
215
216/**
217 * Find the most recent open PR authored by this user in this repo that
218 * touches the given file path. Returns null if none found.
219 */
220async function findOpenPrForFile(
221 repoId: string,
222 filePath: string,
223 userId: string,
224 repoPath: string
225): Promise<PairContext["openPrForFile"] | undefined> {
226 try {
227 // Load up to 10 recent open PRs by this user in this repo.
228 const prs = await db
229 .select()
230 .from(pullRequests)
231 .where(
232 and(
233 eq(pullRequests.repositoryId, repoId),
234 eq(pullRequests.authorId, userId),
235 eq(pullRequests.state, "open")
236 )
237 )
238 .orderBy(desc(pullRequests.updatedAt))
239 .limit(10);
240
241 if (prs.length === 0) return undefined;
242
243 for (const pr of prs) {
244 const files = await prChangedFiles(
245 pr.id,
246 repoPath,
247 pr.baseBranch,
248 pr.headBranch
249 );
250 // Check if the target file is in this PR's changeset.
251 const touches = files.some(
252 (f) => f === filePath || filePath.endsWith(f) || f.endsWith(filePath)
253 );
254 if (!touches) continue;
255
256 const changedLines = await fileDiffExcerpt(
257 repoPath,
258 pr.baseBranch,
259 pr.headBranch,
260 filePath
261 );
262
263 return {
264 prNumber: pr.number,
265 title: pr.title,
266 branch: pr.headBranch,
267 changedLines,
268 };
269 }
270 return undefined;
271 } catch {
272 return undefined;
273 }
274}
275
276/**
277 * Find the most recent failed gate run for any open PR by this user in this
278 * repo within the last 30 minutes.
279 */
280async function findRecentGateFailure(
281 repoId: string,
282 userId: string
283): Promise<PairContext["recentGateFailure"] | undefined> {
284 try {
285 const cutoff = new Date(Date.now() - 30 * 60 * 1000);
286
287 // Get open PRs for this user.
288 const openPrs = await db
289 .select({ id: pullRequests.id })
290 .from(pullRequests)
291 .where(
292 and(
293 eq(pullRequests.repositoryId, repoId),
294 eq(pullRequests.authorId, userId),
295 eq(pullRequests.state, "open")
296 )
297 )
298 .limit(20);
299
300 if (openPrs.length === 0) return undefined;
301
302 const prIds = openPrs.map((p) => p.id);
303
304 const [failed] = await db
305 .select()
306 .from(gateRuns)
307 .where(
308 and(
309 inArray(gateRuns.pullRequestId, prIds),
310 eq(gateRuns.status, "failed"),
311 gte(gateRuns.createdAt, cutoff)
312 )
313 )
314 .orderBy(desc(gateRuns.createdAt))
315 .limit(1);
316
317 if (!failed) return undefined;
318
319 const rawLog = failed.summary || failed.details || "";
320 const errorSummary = rawLog.slice(0, 500);
321
322 return {
323 runType: failed.gateName,
324 errorSummary,
325 failedAt: new Date(failed.createdAt),
326 };
327 } catch {
328 return undefined;
329 }
330}
331
332/**
333 * Find the first open issue whose title or body mentions the file name.
334 */
335async function findRelatedIssue(
336 repoId: string,
337 filePath: string
338): Promise<PairContext["relatedIssue"] | undefined> {
339 try {
340 // Extract just the file name (last segment) for a broader match.
341 const fileName = filePath.split("/").pop() || filePath;
342
343 const allOpen = await db
344 .select()
345 .from(issues)
346 .where(
347 and(
348 eq(issues.repositoryId, repoId),
349 eq(issues.state, "open")
350 )
351 )
352 .orderBy(desc(issues.updatedAt))
353 .limit(50);
354
355 for (const issue of allOpen) {
356 const haystack = `${issue.title} ${issue.body ?? ""}`.toLowerCase();
357 if (
358 haystack.includes(filePath.toLowerCase()) ||
359 haystack.includes(fileName.toLowerCase())
360 ) {
361 return {
362 issueNumber: issue.number,
363 title: issue.title,
364 };
365 }
366 }
367 return undefined;
368 } catch {
369 return undefined;
370 }
371}
372
373// ---------------------------------------------------------------------------
374// Main exports
375// ---------------------------------------------------------------------------
376
377/**
378 * Assemble rich context for the pair programmer for a given file + user.
379 *
380 * Results are cached in-memory for 3 minutes. The function never throws;
381 * on any DB or git error it returns an empty (but valid) PairContext.
382 */
383export async function assemblePairContext(
384 repoId: string,
385 filePath: string,
386 userId: string
387): Promise<PairContext> {
388 const cacheKey = `${repoId}:${filePath}:${userId}`;
389 const cached = cacheGet(contextCache, cacheKey);
390 if (cached !== undefined) return cached;
391
392 // Resolve the bare repo path on disk.
393 let repoPath = "";
394 let defaultBranch = "main";
395 try {
396 const [repo] = await db
397 .select({ diskPath: repositories.diskPath, defaultBranch: repositories.defaultBranch })
398 .from(repositories)
399 .where(eq(repositories.id, repoId))
400 .limit(1);
401
402 if (repo) {
403 // diskPath is the absolute path returned by initBareRepo (the full .git dir path).
404 repoPath = repo.diskPath;
405 defaultBranch = repo.defaultBranch || "main";
406 }
407 } catch {
408 // Fall through with empty repoPath — git operations will no-op.
409 }
410
411 // Fan out all context queries in parallel.
412 const [openPrForFile, recentGateFailure, fileSymbols, relatedIssue] =
413 await Promise.all([
414 repoPath
415 ? findOpenPrForFile(repoId, filePath, userId, repoPath)
416 : Promise.resolve(undefined),
417 findRecentGateFailure(repoId, userId),
418 repoPath
419 ? extractFileSymbols(repoPath, filePath, defaultBranch)
420 : Promise.resolve([] as string[]),
421 findRelatedIssue(repoId, filePath),
422 ]);
423
424 const context: PairContext = {};
425 if (openPrForFile) context.openPrForFile = openPrForFile;
426 if (recentGateFailure) context.recentGateFailure = recentGateFailure;
427 if (fileSymbols && fileSymbols.length > 0) context.fileSymbols = fileSymbols;
428 if (relatedIssue) context.relatedIssue = relatedIssue;
429
430 cacheSet(contextCache, cacheKey, context, CONTEXT_TTL_MS);
431 return context;
432}
433
434// ---------------------------------------------------------------------------
435// Suggestion generation
436// ---------------------------------------------------------------------------
437
438/**
439 * Check whether the assembled context has anything interesting to surface.
440 * "Interesting" = open PR touching this file, a gate failure, or a related
441 * issue. Pure completion is fine even with symbols alone.
442 */
443function hasInterestingContext(ctx: PairContext): boolean {
444 return !!(ctx.openPrForFile || ctx.recentGateFailure || ctx.relatedIssue);
445}
446
447/**
448 * Build a text summary of the PairContext for inclusion in the AI prompt.
449 */
450function formatContext(ctx: PairContext): string {
451 const parts: string[] = [];
452
453 if (ctx.openPrForFile) {
454 parts.push(
455 `OPEN PR #${ctx.openPrForFile.prNumber}: "${ctx.openPrForFile.title}" (branch: ${ctx.openPrForFile.branch})`
456 );
457 if (ctx.openPrForFile.changedLines) {
458 parts.push(`Diff for this file:\n${ctx.openPrForFile.changedLines}`);
459 }
460 }
461
462 if (ctx.recentGateFailure) {
463 parts.push(
464 `RECENT CI FAILURE (${ctx.recentGateFailure.runType}):\n${ctx.recentGateFailure.errorSummary}`
465 );
466 }
467
468 if (ctx.relatedIssue) {
469 parts.push(
470 `RELATED ISSUE #${ctx.relatedIssue.issueNumber}: "${ctx.relatedIssue.title}"`
471 );
472 }
473
474 if (ctx.fileSymbols && ctx.fileSymbols.length > 0) {
475 parts.push(`File symbols: ${ctx.fileSymbols.join(", ")}`);
476 }
477
478 return parts.join("\n\n");
479}
480
481/**
482 * Generate a proactive pair suggestion using Claude.
483 *
484 * When the context has interesting signals (open PR, gate failure, related
485 * issue), Claude is asked to produce a structured JSON PairSuggestion.
486 * When there is no interesting context, we fall back to a plain code
487 * completion using the prefix/suffix — same quality as the existing copilot
488 * endpoint but with MODEL_SONNET and a slightly different prompt.
489 *
490 * Never throws — returns a safe fallback on any error.
491 */
492export async function generatePairSuggestion(
493 prefix: string,
494 suffix: string,
495 filePath: string,
496 context: PairContext
497): Promise<PairSuggestion> {
498 // --- Fallback used on errors or when AI is unavailable ---
499 const fallback: PairSuggestion = {
500 type: "completion",
501 headline: "Continue editing",
502 detail: undefined,
503 };
504
505 if (!isAiAvailable()) return fallback;
506
507 try {
508 // Clip inputs to sane sizes.
509 const clippedPrefix = prefix.slice(-4000);
510 const clippedSuffix = (suffix || "").slice(0, 1000);
511
512 if (!hasInterestingContext(context)) {
513 // Pure completion path.
514 const client = getAnthropic();
515 const response = await client.messages.create({
516 model: MODEL_SONNET,
517 max_tokens: 256,
518 system:
519 "You are an expert pair programmer embedded in a code editor. " +
520 `The developer is editing ${filePath}. ` +
521 "Output ONLY the characters that should be inserted at the cursor. " +
522 "No explanations. No markdown fences.",
523 messages: [
524 {
525 role: "user",
526 content:
527 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
528 },
529 ],
530 });
531 const completion = extractText(response).replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
532 return {
533 type: "completion",
534 headline: completion.split("\n")[0].slice(0, 120) || "Inline suggestion",
535 detail: completion,
536 };
537 }
538
539 // Context-aware path — ask Claude for a structured suggestion.
540 const contextText = formatContext(context);
541
542 const client = getAnthropic();
543 const response = await client.messages.create({
544 model: MODEL_SONNET,
545 max_tokens: 800,
546 system:
547 "You are an expert pair programmer embedded in a code editor. " +
548 `The developer is editing ${filePath}. ` +
549 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
550 "and return a single JSON object with the shape: " +
551 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
552 '"headline": "<one line, max 120 chars>", ' +
553 '"detail": "<optional expanded explanation>", ' +
554 '"actionLabel": "<optional button label>", ' +
555 '"actionPayload": "<optional diff or URL>" }. ' +
556 "Respond with ONLY the JSON object — no prose, no markdown fences.",
557 messages: [
558 {
559 role: "user",
560 content:
561 `CONTEXT:\n${contextText}\n\n` +
562 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
563 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
564 },
565 ],
566 });
567
568 const raw = extractText(response);
569 const parsed = parseJsonResponse<{
570 type?: string;
571 headline?: string;
572 detail?: string;
573 actionLabel?: string;
574 actionPayload?: string;
575 }>(raw);
576
577 if (!parsed || typeof parsed.headline !== "string") {
578 // Response didn't parse — treat it as a context note.
579 return {
580 type: "context_note",
581 headline: raw.split("\n")[0].slice(0, 120) || "Pair programmer note",
582 detail: raw.slice(0, 500),
583 };
584 }
585
586 const validTypes = new Set(["completion", "warning", "context_note", "fix_available"]);
587 const type = validTypes.has(parsed.type ?? "") ? (parsed.type as PairSuggestion["type"]) : "context_note";
588
589 return {
590 type,
591 headline: (parsed.headline || "").slice(0, 120),
592 detail: parsed.detail,
593 actionLabel: parsed.actionLabel,
594 actionPayload: parsed.actionPayload,
595 };
596 } catch (err) {
597 console.error(
598 "[ai-pair] generatePairSuggestion error:",
599 (err as Error)?.message || err
600 );
601 return fallback;
602 }
603}
604
605// ---------------------------------------------------------------------------
606// Cache key helper used by the route for the 30-s suggestion cache
607// ---------------------------------------------------------------------------
608
609/**
610 * Derive a stable cache key for a suggest request.
611 * The prefix is hashed (last 50 chars) so we never store raw source.
612 */
613export function suggestCacheKey(
614 userId: string,
615 repoId: string,
616 filePath: string,
617 prefix: string
618): string {
619 const prefixSlice = prefix.slice(-50);
620 return createHash("sha256")
621 .update(userId)
622 .update("\0")
623 .update(repoId)
624 .update("\0")
625 .update(filePath)
626 .update("\0")
627 .update(prefixSlice)
628 .digest("hex");
629}
630
631/** Exported for use by the route layer. */
632export { suggestCache, cacheGet, cacheSet, SUGGEST_TTL_MS };
Modifiedsrc/routes/copilot.ts+2−0View fileUnifiedSplit
1212 * misbehaving client can otherwise exhaust the shared Anthropic quota fast.
1313 */
1414
15// For proactive, context-aware suggestions see POST /api/pair/suggest (src/routes/pair-programmer.ts)
16
1517import { Hono } from "hono";
1618import { requireAuth } from "../middleware/auth";
1719import type { AuthEnv } from "../middleware/auth";
Addedsrc/routes/pair-programmer.ts+143−0View fileUnifiedSplit
1/**
2 * Proactive AI pair programmer HTTP surface.
3 *
4 * GET /api/pair/ping — health check (unauthed)
5 * POST /api/pair/context — requireAuth; assemble PairContext for a file
6 * POST /api/pair/suggest — requireAuth; context + AI suggestion for a file
7 *
8 * Rate limit: 30/min per user (separate counter from the copilot 60/min).
9 * Suggestion responses are cached 30 s per (userId, repoId, filePath, prefixSlice).
10 *
11 * For reactive completions triggered on every keystroke, see:
12 * POST /api/copilot/completions (src/routes/copilot.ts)
13 */
14
15import { Hono } from "hono";
16import { requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import { rateLimit } from "../middleware/rate-limit";
19import { isAiAvailable } from "../lib/ai-client";
20import {
21 assemblePairContext,
22 generatePairSuggestion,
23 suggestCacheKey,
24 suggestCache,
25 cacheGet,
26 cacheSet,
27 SUGGEST_TTL_MS,
28} from "../lib/ai-pair";
29import type { PairContext, PairSuggestion } from "../lib/ai-pair";
30
31const router = new Hono<AuthEnv>();
32
33// Separate rate-limit bucket from copilot (30 req/min).
34const pairLimit = rateLimit(30, 60_000, "pair");
35
36// ---------------------------------------------------------------------------
37// GET /api/pair/ping — health check
38// ---------------------------------------------------------------------------
39router.get("/api/pair/ping", (c) => {
40 return c.json({ ok: true, aiAvailable: isAiAvailable() });
41});
42
43// ---------------------------------------------------------------------------
44// POST /api/pair/context
45// ---------------------------------------------------------------------------
46router.post("/api/pair/context", pairLimit, requireAuth, async (c) => {
47 const user = c.get("user");
48 if (!user) {
49 return c.json({ error: "Unauthorized" }, 401);
50 }
51
52 let body: unknown;
53 try {
54 body = await c.req.json();
55 } catch {
56 return c.json({ error: "Invalid JSON body" }, 400);
57 }
58
59 if (!body || typeof body !== "object") {
60 return c.json({ error: "Body must be a JSON object" }, 400);
61 }
62
63 const { repoId, filePath } = body as { repoId?: unknown; filePath?: unknown };
64
65 if (typeof repoId !== "string" || repoId.trim() === "") {
66 return c.json({ error: "repoId (non-empty string) is required" }, 400);
67 }
68 if (typeof filePath !== "string" || filePath.trim() === "") {
69 return c.json({ error: "filePath (non-empty string) is required" }, 400);
70 }
71
72 const ctx: PairContext = await assemblePairContext(repoId, filePath, user.id);
73 return c.json(ctx);
74});
75
76// ---------------------------------------------------------------------------
77// POST /api/pair/suggest
78// ---------------------------------------------------------------------------
79router.post("/api/pair/suggest", pairLimit, requireAuth, async (c) => {
80 const user = c.get("user");
81 if (!user) {
82 return c.json({ error: "Unauthorized" }, 401);
83 }
84
85 let body: unknown;
86 try {
87 body = await c.req.json();
88 } catch {
89 return c.json({ error: "Invalid JSON body" }, 400);
90 }
91
92 if (!body || typeof body !== "object") {
93 return c.json({ error: "Body must be a JSON object" }, 400);
94 }
95
96 const {
97 repoId,
98 filePath,
99 prefix,
100 suffix,
101 } = body as {
102 repoId?: unknown;
103 filePath?: unknown;
104 prefix?: unknown;
105 suffix?: unknown;
106 };
107
108 if (typeof repoId !== "string" || repoId.trim() === "") {
109 return c.json({ error: "repoId (non-empty string) is required" }, 400);
110 }
111 if (typeof filePath !== "string" || filePath.trim() === "") {
112 return c.json({ error: "filePath (non-empty string) is required" }, 400);
113 }
114 if (typeof prefix !== "string" || prefix.length === 0) {
115 return c.json({ error: "prefix (non-empty string) is required" }, 400);
116 }
117
118 const suffixStr = typeof suffix === "string" ? suffix : "";
119
120 // Check 30-second suggestion cache.
121 const ck = suggestCacheKey(user.id, repoId, filePath, prefix);
122 const hit = cacheGet(suggestCache, ck);
123 if (hit !== undefined) {
124 return c.json({ ...hit, cached: true });
125 }
126
127 // Assemble context then generate suggestion (both are individually cached).
128 const ctx: PairContext = await assemblePairContext(repoId, filePath, user.id);
129 const suggestion: PairSuggestion = await generatePairSuggestion(
130 prefix,
131 suffixStr,
132 filePath,
133 ctx
134 );
135
136 // Cache the suggestion.
137 cacheSet(suggestCache, ck, suggestion, SUGGEST_TTL_MS);
138
139 return c.json(suggestion);
140});
141
142export const pairProgrammerRoutes = router;
143export default router;
0144