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

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.

pair-programmer.tsBlame143 lines · 1 contributor
0410f22Claude1/**
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;