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.tsBlame179 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:
a5705cfClaude10 * - Uses Sonnet 4.6 for quality inline suggestions.
3cbe3d6Claude11 * - 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,
b271465Claude25 MODEL_SONNET,
3cbe3d6Claude26 extractText,
27 isAiAvailable,
28} from "./ai-client";
29import { createHash } from "crypto";
30
31export interface CompleteArgs {
32 prefix: string;
33 suffix?: string;
34 language?: string;
35 maxTokens?: number;
36 repoHint?: string;
37}
38
39export interface CompleteResult {
40 completion: string;
41 model: string;
42 cached: boolean;
43}
44
45// ---------- Inline LRU (size cap 200, TTL ~5 min) ----------
46// Intentionally standalone rather than reusing src/lib/cache.ts: completion
47// cache entries have a very different shape + access pattern (write-heavy,
48// short-lived, never invalidated by repo events) and keeping the logic local
49// makes the file easier to reason about and test.
50
51interface CacheEntry {
52 value: string;
53 expiresAt: number;
54}
55
56const CACHE_MAX = 200;
57const CACHE_TTL_MS = 5 * 60 * 1000;
58const cacheStore = new Map<string, CacheEntry>();
59
60function cacheKey(prefix: string, suffix: string, language: string): string {
61 return createHash("sha256")
62 .update(prefix)
63 .update("\0")
64 .update(suffix)
65 .update("\0")
66 .update(language)
67 .digest("hex");
68}
69
70function cacheGet(key: string): string | undefined {
71 const entry = cacheStore.get(key);
72 if (!entry) return undefined;
73 if (Date.now() > entry.expiresAt) {
74 cacheStore.delete(key);
75 return undefined;
76 }
77 // Move to end (MRU).
78 cacheStore.delete(key);
79 cacheStore.set(key, entry);
80 return entry.value;
81}
82
83function cacheSet(key: string, value: string): void {
84 cacheStore.delete(key);
85 if (cacheStore.size >= CACHE_MAX) {
86 const oldest = cacheStore.keys().next().value;
87 if (oldest !== undefined) cacheStore.delete(oldest);
88 }
89 cacheStore.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
90}
91
92/**
93 * Strip leading/trailing markdown code fences that Claude sometimes emits
94 * despite the system prompt forbidding them. Handles:
95 * ```lang\n...\n```
96 * ```\n...\n```
97 * Leaves un-fenced content untouched.
98 */
99function stripCodeFences(text: string): string {
100 let out = text;
101 // Leading fence (optionally with language label)
102 out = out.replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "");
103 // Trailing fence
104 out = out.replace(/\n?\s*```\s*$/, "");
105 return out;
106}
107
108export async function completeCode(
109 args: CompleteArgs
110): Promise<CompleteResult> {
111 const prefix = (args.prefix || "").slice(-8000);
112 const suffix = (args.suffix || "").slice(0, 2000);
113 const language = args.language || "unknown";
114 const repoHint = args.repoHint || "unknown";
115 const maxTokens = args.maxTokens ?? 256;
116
117 if (!isAiAvailable()) {
118 return { completion: "", model: "fallback", cached: false };
119 }
120
121 const key = cacheKey(prefix, suffix, language);
122 const hit = cacheGet(key);
123 if (hit !== undefined) {
a5705cfClaude124 return { completion: hit, model: MODEL_SONNET, cached: true };
3cbe3d6Claude125 }
126
127 try {
128 const client = getAnthropic();
129 const response = await client.messages.create({
a5705cfClaude130 model: MODEL_SONNET,
3cbe3d6Claude131 max_tokens: maxTokens,
132 system:
133 "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.",
134 messages: [
135 {
136 role: "user",
137 content:
138 `Language: ${language}\n` +
139 `Repo: ${repoHint}\n\n` +
140 `PREFIX:\n${prefix}\n\n` +
141 `SUFFIX:\n${suffix}`,
142 },
143 ],
144 });
145
146 const raw = extractText(response);
147 const completion = stripCodeFences(raw);
148 cacheSet(key, completion);
a5705cfClaude149 return { completion, model: MODEL_SONNET, cached: false };
3cbe3d6Claude150 } catch (err) {
151 // Never throw — the editor should degrade silently. Log length only, not
152 // the prefix itself, which can contain secrets.
153 console.error(
154 "[ai-completion] completeCode failed (prefix.length=" +
155 prefix.length +
156 "):",
157 (err as Error)?.message || err
158 );
159 return { completion: "", model: "error", cached: false };
160 }
161}
162
163/**
164 * Test-only helpers. Not part of the public API — tests use these to seed
165 * entries so they can exercise the cache without hitting the real Anthropic
166 * endpoint, and to reset state between runs.
167 */
168export const __test = {
169 cacheKey,
170 cacheGet,
171 cacheSet,
172 clear() {
173 cacheStore.clear();
174 },
175 size() {
176 return cacheStore.size;
177 },
178 stripCodeFences,
179};