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.tsBlame178 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) {
123 return { completion: hit, model: MODEL_HAIKU, cached: true };
124 }
125
126 try {
127 const client = getAnthropic();
128 const response = await client.messages.create({
129 model: MODEL_HAIKU,
130 max_tokens: maxTokens,
131 system:
132 "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.",
133 messages: [
134 {
135 role: "user",
136 content:
137 `Language: ${language}\n` +
138 `Repo: ${repoHint}\n\n` +
139 `PREFIX:\n${prefix}\n\n` +
140 `SUFFIX:\n${suffix}`,
141 },
142 ],
143 });
144
145 const raw = extractText(response);
146 const completion = stripCodeFences(raw);
147 cacheSet(key, completion);
148 return { completion, model: MODEL_HAIKU, cached: false };
149 } catch (err) {
150 // Never throw — the editor should degrade silently. Log length only, not
151 // the prefix itself, which can contain secrets.
152 console.error(
153 "[ai-completion] completeCode failed (prefix.length=" +
154 prefix.length +
155 "):",
156 (err as Error)?.message || err
157 );
158 return { completion: "", model: "error", cached: false };
159 }
160}
161
162/**
163 * Test-only helpers. Not part of the public API — tests use these to seed
164 * entries so they can exercise the cache without hitting the real Anthropic
165 * endpoint, and to reset state between runs.
166 */
167export const __test = {
168 cacheKey,
169 cacheGet,
170 cacheSet,
171 clear() {
172 cacheStore.clear();
173 },
174 size() {
175 return cacheStore.size;
176 },
177 stripCodeFences,
178};