CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pair-programmer.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.
| 0410f22 | 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 | ||
| 15 | import { Hono } from "hono"; | |
| 16 | import { requireAuth } from "../middleware/auth"; | |
| 17 | import type { AuthEnv } from "../middleware/auth"; | |
| 18 | import { rateLimit } from "../middleware/rate-limit"; | |
| 19 | import { isAiAvailable } from "../lib/ai-client"; | |
| 20 | import { | |
| 21 | assemblePairContext, | |
| 22 | generatePairSuggestion, | |
| 23 | suggestCacheKey, | |
| 24 | suggestCache, | |
| 25 | cacheGet, | |
| 26 | cacheSet, | |
| 27 | SUGGEST_TTL_MS, | |
| 28 | } from "../lib/ai-pair"; | |
| 29 | import type { PairContext, PairSuggestion } from "../lib/ai-pair"; | |
| 30 | ||
| 31 | const router = new Hono<AuthEnv>(); | |
| 32 | ||
| 33 | // Separate rate-limit bucket from copilot (30 req/min). | |
| 34 | const pairLimit = rateLimit(30, 60_000, "pair"); | |
| 35 | ||
| 36 | // --------------------------------------------------------------------------- | |
| 37 | // GET /api/pair/ping — health check | |
| 38 | // --------------------------------------------------------------------------- | |
| 39 | router.get("/api/pair/ping", (c) => { | |
| 40 | return c.json({ ok: true, aiAvailable: isAiAvailable() }); | |
| 41 | }); | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // POST /api/pair/context | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | router.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 | // --------------------------------------------------------------------------- | |
| 79 | router.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 | ||
| 142 | export const pairProgrammerRoutes = router; | |
| 143 | export default router; |