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

ai-completion.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.

ai-completion.tsBlame202 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D9 — Copilot-style code completion engine.
3 *
4 * Exposes a single async function `completeCode` that turns a prefix (+ optional
5 * suffix) into the characters that should be inserted at the cursor. Used by
6 * the `/api/copilot/completions` endpoint, which IDE plugins (VS Code, Neovim,
7 * JetBrains) call on every keystroke.
8 *
9 * Design notes:
10 * - Uses Haiku because latency matters more than depth for inline suggestions.
11 * - Input is clipped aggressively (8k chars before, 2k chars after) so a huge
12 * file doesn't blow the token budget.
13 * - Never throws. On any error (bad key, timeout, rate limit) we return an
14 * empty completion so the editor just stays silent rather than popping a
15 * scary modal at the user.
16 * - Inline LRU keeps identical requests (the editor firing the same
17 * completion twice in rapid succession) from each burning an API call.
18 * - We log prefix.length only — never the content itself, which may contain
19 * API keys, private tokens, or proprietary source.
20 */
21
22import {
23 getAnthropic,
24 MODEL_HAIKU,
25 extractText,
26 isAiAvailable,
27} from "./ai-client";
28import { createHash } from "crypto";
29
30export interface CompleteArgs {
31 prefix: string;
32 suffix?: string;
33 language?: string;
34 maxTokens?: number;
35 repoHint?: string;
36}
37
38export interface CompleteResult {
39 completion: string;
40 model: string;
41 cached: boolean;
42}
43
44// ---------- Inline LRU (size cap 200, TTL ~5 min) ----------
45// Intentionally standalone rather than reusing src/lib/cache.ts: completion
46// cache entries have a very different shape + access pattern (write-heavy,
47// short-lived, never invalidated by repo events) and keeping the logic local
48// makes the file easier to reason about and test.
49
50interface CacheEntry {
51 value: string;
52 expiresAt: number;
53}
54
55const CACHE_MAX = 200;
56const CACHE_TTL_MS = 5 * 60 * 1000;
57const cacheStore = new Map<string, CacheEntry>();
58
59function cacheKey(prefix: string, suffix: string, language: string): string {
60 return createHash("sha256")
61 .update(prefix)
62 .update("\0")
63 .update(suffix)
64 .update("\0")
65 .update(language)
66 .digest("hex");
67}
68
69function cacheGet(key: string): string | undefined {
70 const entry = cacheStore.get(key);
71 if (!entry) return undefined;
72 if (Date.now() > entry.expiresAt) {
73 cacheStore.delete(key);
74 return undefined;
75 }
76 // Move to end (MRU).
77 cacheStore.delete(key);
78 cacheStore.set(key, entry);
79 return entry.value;
80}
81
82function cacheSet(key: string, value: string): void {
83 cacheStore.delete(key);
84 if (cacheStore.size >= CACHE_MAX) {
85 const oldest = cacheStore.keys().next().value;
86 if (oldest !== undefined) cacheStore.delete(oldest);
87 }
88 cacheStore.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
89}
90
91/**
92 * Strip leading/trailing markdown code fences that Claude sometimes emits
93 * despite the system prompt forbidding them. Handles:
94 * ```lang\n...\n```
95 * ```\n...\n```
96 * Leaves un-fenced content untouched.
97 */
98function stripCodeFences(text: string): string {
99 let out = text;
100 // Leading fence (optionally with language label)
101 out = out.replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "");
102 // Trailing fence
103 out = out.replace(/\n?\s*```\s*$/, "");
104 return out;
105}
106
107export async function completeCode(
108 args: CompleteArgs
109): Promise<CompleteResult> {
110 const prefix = (args.prefix || "").slice(-8000);
111 const suffix = (args.suffix || "").slice(0, 2000);
112 const language = args.language || "unknown";
113 const repoHint = args.repoHint || "unknown";
114 const maxTokens = args.maxTokens ?? 256;
115
116 if (!isAiAvailable()) {
117 return { completion: "", model: "fallback", cached: false };
118 }
119
120 const key = cacheKey(prefix, suffix, language);
121 const hit = cacheGet(key);
122 if (hit !== undefined) {
40e7738Claude123 // Log the cache hit too — useful for cost-tracking and rate insight.
124 try {
125 const { logAiEvent } = await import("./ai-flywheel");
126 logAiEvent({
127 actionType: "completion",
128 model: MODEL_HAIKU,
129 summary: `cached completion (${language})`,
130 latencyMs: 0,
131 success: true,
132 metadata: { cached: true, language, repoHint },
133 });
134 } catch {
135 /* telemetry must not break completion */
136 }
3cbe3d6Claude137 return { completion: hit, model: MODEL_HAIKU, cached: true };
138 }
139
140 try {
40e7738Claude141 const { recordAi } = await import("./ai-flywheel");
3cbe3d6Claude142 const client = getAnthropic();
40e7738Claude143 const response = await recordAi(
144 {
145 actionType: "completion",
146 model: MODEL_HAIKU,
147 summary: `code completion (${language})`,
148 metadata: { language, repoHint, prefixLen: prefix.length },
149 },
150 () =>
151 client.messages.create({
152 model: MODEL_HAIKU,
153 max_tokens: maxTokens,
154 system:
155 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
156 messages: [
157 {
158 role: "user",
159 content:
160 `Language: ${language}\n` +
161 `Repo: ${repoHint}\n\n` +
162 `PREFIX:\n${prefix}\n\n` +
163 `SUFFIX:\n${suffix}`,
164 },
165 ],
166 })
167 );
3cbe3d6Claude168
169 const raw = extractText(response);
170 const completion = stripCodeFences(raw);
171 cacheSet(key, completion);
172 return { completion, model: MODEL_HAIKU, cached: false };
173 } catch (err) {
174 // Never throw — the editor should degrade silently. Log length only, not
175 // the prefix itself, which can contain secrets.
176 console.error(
177 "[ai-completion] completeCode failed (prefix.length=" +
178 prefix.length +
179 "):",
180 (err as Error)?.message || err
181 );
182 return { completion: "", model: "error", cached: false };
183 }
184}
185
186/**
187 * Test-only helpers. Not part of the public API — tests use these to seed
188 * entries so they can exercise the cache without hitting the real Anthropic
189 * endpoint, and to reset state between runs.
190 */
191export const __test = {
192 cacheKey,
193 cacheGet,
194 cacheSet,
195 clear() {
196 cacheStore.clear();
197 },
198 size() {
199 return cacheStore.size;
200 },
201 stripCodeFences,
202};