CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(ai): migrate generator/PR call sites to callModel #5629

Merged⚡ AI-generatedXSccantynz wants to mergefeat/ai-metering-migrate-generatorsmainopened 18h ago
11 changed files+567−167
Modifiedsrc/__tests__/ai-metering-coverage.test.ts+0−8View fileUnifiedSplit
3333const LEGACY_DIRECT_CALLERS = new Set([
3434 "lib/advancement-scanner.ts",
3535 "lib/ai-archaeology.ts",
36 "lib/ai-chat.ts",
3736 "lib/ai-ci-healer.ts",
3837 "lib/ai-commit-message.ts",
39 "lib/ai-completion.ts",
4038 "lib/ai-cost-tracker.ts",
4139 "lib/ai-doc-updater.ts",
42 "lib/ai-explain.ts",
4340 "lib/ai-incident.ts",
44 "lib/ai-pair.ts",
4541 "lib/ai-patch-generator.ts",
4642 "lib/ai-proactive-monitor.ts",
4743 "lib/ai-release-notes.ts",
4945 "lib/ai-review.ts",
5046 "lib/ai-standup.ts",
5147 "lib/ai-test-generator.ts",
52 "lib/ai-workspace.ts",
5348 "lib/auto-repair.ts",
5449 "lib/ci-autofix.ts",
55 "lib/claude-semantic-search.ts",
5650 "lib/codebase-migrator.ts",
5751 "lib/cross-repo-impact.ts",
5852 "lib/debt-analyzer.ts",
6357 "lib/merge-resolver.ts",
6458 "lib/migration-assistant.ts",
6559 "lib/multi-repo-refactor.ts",
66 "lib/nl-search.ts",
6760 "lib/org-health.ts",
6861 "lib/pattern-detector.ts",
6962 "lib/personal-chat.ts",
7871 "lib/streaming-review.ts",
7972 "lib/test-gaps.ts",
8073 "lib/voice-to-pr.ts",
81 "routes/ai-editor.ts",
8274]);
8375
8476function walk(dir: string, out: string[] = []): string[] {
Addedsrc/__tests__/ai-provider-streaming.test.ts+126−0View fileUnifiedSplit
1/**
2 * ai-provider streaming — the half of the adapter that was missing.
3 *
4 * The 2026-09-02 audit found `AiClientLike` implemented only
5 * `messages.create`, so setting AI_PROVIDER=openai crashed the four
6 * streaming features (personal-chat, repo-chat, spec-ai, streaming-review)
7 * with `messages.stream is not a function`. These tests pin the stream
8 * contract in BOTH consumption styles those features use:
9 * - async iteration over Anthropic-shaped events, and
10 * - `.on("text")` + `.finalMessage()`.
11 */
12
13import { describe, expect, it } from "bun:test";
14import { createOpenAiCompatibleClient } from "../lib/ai-provider";
15
16function sseResponse(frames: string[]): Response {
17 const body = new ReadableStream<Uint8Array>({
18 start(controller) {
19 const enc = new TextEncoder();
20 for (const f of frames) controller.enqueue(enc.encode(f));
21 controller.close();
22 },
23 });
24 return new Response(body, {
25 status: 200,
26 headers: { "Content-Type": "text/event-stream" },
27 });
28}
29
30const FRAMES = [
31 `data: {"id":"c1","choices":[{"delta":{"content":"Hel"}}]}\n\n`,
32 `data: {"id":"c1","choices":[{"delta":{"content":"lo"}}]}\n\n`,
33 // finish + usage arrive on the tail chunks (stream_options.include_usage)
34 `data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n`,
35 `data: {"id":"c1","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2}}\n\n`,
36 `data: [DONE]\n\n`,
37];
38
39function client(frames: string[] = FRAMES, status = 200) {
40 return createOpenAiCompatibleClient({
41 baseUrl: "http://localhost:9999",
42 fetchImpl: (async () =>
43 status === 200
44 ? sseResponse(frames)
45 : new Response("nope", { status, statusText: "Bad" })) as unknown as typeof fetch,
46 });
47}
48
49const REQ = {
50 model: "test-model",
51 max_tokens: 32,
52 messages: [{ role: "user", content: "hi" }],
53};
54
55describe("messages.stream — async iteration (streaming-review style)", () => {
56 it("yields Anthropic-shaped text deltas and a usage-bearing message_delta", async () => {
57 const stream = client().messages.stream(REQ);
58 const events: any[] = [];
59 for await (const ev of stream) events.push(ev);
60
61 const deltas = events.filter((e) => e.type === "content_block_delta");
62 expect(deltas.map((d) => d.delta.text).join("")).toBe("Hello");
63 expect(deltas.every((d) => d.delta.type === "text_delta")).toBe(true);
64
65 const md = events.find((e) => e.type === "message_delta");
66 expect(md.usage).toEqual({ input_tokens: 7, output_tokens: 2 });
67 expect(events.at(-1)?.type).toBe("message_stop");
68 });
69});
70
71describe("messages.stream — on('text') + finalMessage (spec-ai style)", () => {
72 it("fires text listeners and resolves finalMessage with the full text + usage", async () => {
73 const stream = client().messages.stream(REQ);
74 let heard = "";
75 stream.on("text", (d) => {
76 heard += d;
77 });
78 const final = await stream.finalMessage();
79 expect(heard).toBe("Hello");
80 expect(final.content[0].text).toBe("Hello");
81 expect(final.usage).toEqual({ input_tokens: 7, output_tokens: 2 });
82 expect(final.stop_reason).toBe("stop");
83 });
84
85 it("finalMessage alone drains the wire — no iteration required", async () => {
86 const final = await client().messages.stream(REQ).finalMessage();
87 expect(final.content[0].text).toBe("Hello");
88 });
89});
90
91describe("messages.stream — failure paths", () => {
92 it("a non-2xx response rejects finalMessage and throws from iteration", async () => {
93 const s1 = client(FRAMES, 500).messages.stream(REQ);
94 await expect(s1.finalMessage()).rejects.toThrow(/stream failed \(500/);
95
96 const s2 = client(FRAMES, 500).messages.stream(REQ);
97 let threw = false;
98 try {
99 for await (const _ of s2) {
100 /* drain */
101 }
102 } catch {
103 threw = true;
104 }
105 expect(threw).toBe(true);
106 });
107
108 it("a torn SSE frame is skipped, not fatal", async () => {
109 const frames = [
110 `data: {"choices":[{"delta":{"content":"ok"}}]}\n\n`,
111 `data: {not json\n\n`,
112 `data: [DONE]\n\n`,
113 ];
114 const final = await client(frames).messages.stream(REQ).finalMessage();
115 expect(final.content[0].text).toBe("ok");
116 });
117
118 it("frames split across reads reassemble", async () => {
119 const frames = [
120 `data: {"choices":[{"delta":{"con`,
121 `tent":"ab"}}]}\n\ndata: [DONE]\n\n`,
122 ];
123 const final = await client(frames).messages.stream(REQ).finalMessage();
124 expect(final.content[0].text).toBe("ab");
125 });
126});
Modifiedsrc/lib/ai-chat.ts+19−16View fileUnifiedSplit
1515 */
1616
1717import {
18 getAnthropic,
18 callModel,
1919 MODEL_SONNET,
2020 extractText,
2121 isAiAvailable,
119119 citedFiles: [],
120120 };
121121 }
122 const client = getAnthropic();
123
124122 const mentioned = extractFileMentions(userMessage);
125123 const { context: repoContext, files } = repo
126124 ? await buildRepoContext(owner, repo, mentioned)
140138 },
141139 ];
142140
143 const response = await client.messages.create({
141 const response = await callModel({
144142 model: MODEL_SONNET,
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
143 category: "chat",
144 request: {
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
148 },
148149 });
149150
150151 return {
165166 if (!isAiAvailable()) {
166167 return "AI explanations are not available — server needs ANTHROPIC_API_KEY.";
167168 }
168 const client = getAnthropic();
169 const message = await client.messages.create({
169 const message = await callModel({
170170 model: MODEL_SONNET,
171 max_tokens: 1024,
172 messages: [
173 {
174 role: "user",
175 content: `Explain this file from ${owner}/${repo} in plain English.
171 category: "chat",
172 request: {
173 max_tokens: 1024,
174 messages: [
175 {
176 role: "user",
177 content: `Explain this file from ${owner}/${repo} in plain English.
176178
177179Structure:
1781801. **Purpose** — one sentence
186188\`\`\`
187189${content.slice(0, 40000)}
188190\`\`\``,
189 },
190 ],
191 },
192 ],
193 },
191194 });
192195 return extractText(message).trim();
193196}
Modifiedsrc/lib/ai-completion.ts+18−16View fileUnifiedSplit
2020 */
2121
2222import {
23 getAnthropic,
23 callModel,
2424 MODEL_HAIKU,
2525 MODEL_SONNET,
2626 extractText,
125125 }
126126
127127 try {
128 const client = getAnthropic();
129 const response = await client.messages.create({
128 const response = await callModel({
130129 model: MODEL_SONNET,
131 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 ],
130 category: "other",
131 request: {
132 max_tokens: maxTokens,
133 system:
134 "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.",
135 messages: [
136 {
137 role: "user",
138 content:
139 `Language: ${language}\n` +
140 `Repo: ${repoHint}\n\n` +
141 `PREFIX:\n${prefix}\n\n` +
142 `SUFFIX:\n${suffix}`,
143 },
144 ],
145 },
144146 });
145147
146148 const raw = extractText(response);
Modifiedsrc/lib/ai-explain.ts+10−6View fileUnifiedSplit
1717import type { GitTreeEntry } from "../git/repository";
1818import {
1919 MODEL_SONNET,
20 callModel,
2021 extractText,
21 getAnthropic,
2222 isAiAvailable,
2323} from "./ai-client";
2424
115115 let result: { summary: string; markdown: string; model: string };
116116 if (isAiAvailable() && samples.files.length > 0) {
117117 try {
118 result = await callClaude(args.owner, args.repo, samples);
118 result = await callClaude(args.owner, args.repo, args.repositoryId, samples);
119119 } catch {
120120 result = buildFallbackMarkdown(args.owner, args.repo, samples);
121121 }
383383async function callClaude(
384384 owner: string,
385385 repo: string,
386 repositoryId: string,
386387 samples: Samples
387388): Promise<{ summary: string; markdown: string; model: string }> {
388 const client = getAnthropic();
389389 const treeListing = samples.topLevelTree
390390 .slice(0, 80)
391391 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
428428Representative files:
429429${fileBlob}`;
430430
431 const message = await client.messages.create({
431 const message = await callModel({
432432 model: MODEL_SONNET,
433 max_tokens: 2048,
434 messages: [{ role: "user", content: prompt }],
433 category: "other",
434 repositoryId,
435 request: {
436 max_tokens: 2048,
437 messages: [{ role: "user", content: prompt }],
438 },
435439 });
436440
437441 const markdown = extractText(message).trim();
Modifiedsrc/lib/ai-pair.ts+43−39View fileUnifiedSplit
3232 pullRequests,
3333 repositories,
3434} from "../db/schema";
35import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
35import { callModel, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
3636import { extractSymbols, detectLanguage } from "./symbols";
3737import { refRange, treeish } from "../git/repository";
3838
521521
522522 if (!hasInterestingContext(context)) {
523523 // Pure completion path.
524 const client = getAnthropic();
525 const response = await client.messages.create({
524 const response = await callModel({
526525 model: MODEL_SONNET,
527 max_tokens: 256,
528 system:
529 "You are an expert pair programmer embedded in a code editor. " +
530 `The developer is editing ${filePath}. ` +
531 "Output ONLY the characters that should be inserted at the cursor. " +
532 "No explanations. No markdown fences.",
533 messages: [
534 {
535 role: "user",
536 content:
537 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
538 },
539 ],
526 category: "chat",
527 request: {
528 max_tokens: 256,
529 system:
530 "You are an expert pair programmer embedded in a code editor. " +
531 `The developer is editing ${filePath}. ` +
532 "Output ONLY the characters that should be inserted at the cursor. " +
533 "No explanations. No markdown fences.",
534 messages: [
535 {
536 role: "user",
537 content:
538 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
539 },
540 ],
541 },
540542 });
541543 const completion = extractText(response).replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
542544 return {
549551 // Context-aware path — ask Claude for a structured suggestion.
550552 const contextText = formatContext(context);
551553
552 const client = getAnthropic();
553 const response = await client.messages.create({
554 const response = await callModel({
554555 model: MODEL_SONNET,
555 max_tokens: 800,
556 system:
557 "You are an expert pair programmer embedded in a code editor. " +
558 `The developer is editing ${filePath}. ` +
559 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
560 "and return a single JSON object with the shape: " +
561 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
562 '"headline": "<one line, max 120 chars>", ' +
563 '"detail": "<optional expanded explanation>", ' +
564 '"actionLabel": "<optional button label>", ' +
565 '"actionPayload": "<optional diff or URL>" }. ' +
566 "Respond with ONLY the JSON object — no prose, no markdown fences.",
567 messages: [
568 {
569 role: "user",
570 content:
571 `CONTEXT:\n${contextText}\n\n` +
572 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
573 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
574 },
575 ],
556 category: "chat",
557 request: {
558 max_tokens: 800,
559 system:
560 "You are an expert pair programmer embedded in a code editor. " +
561 `The developer is editing ${filePath}. ` +
562 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
563 "and return a single JSON object with the shape: " +
564 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
565 '"headline": "<one line, max 120 chars>", ' +
566 '"detail": "<optional expanded explanation>", ' +
567 '"actionLabel": "<optional button label>", ' +
568 '"actionPayload": "<optional diff or URL>" }. ' +
569 "Respond with ONLY the JSON object — no prose, no markdown fences.",
570 messages: [
571 {
572 role: "user",
573 content:
574 `CONTEXT:\n${contextText}\n\n` +
575 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
576 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
577 },
578 ],
579 },
576580 });
577581
578582 const raw = extractText(response);
Modifiedsrc/lib/ai-provider.ts+225−1View fileUnifiedSplit
5151 usage: { input_tokens: number; output_tokens: number };
5252}
5353
54/**
55 * The streaming slice of the Anthropic surface the platform consumes.
56 *
57 * Four features stream (personal-chat, repo-chat, spec-ai,
58 * streaming-review), and between them they use BOTH consumption styles the
59 * Anthropic SDK offers:
60 * - `for await (const event of stream)` over Anthropic-shaped events
61 * (content_block_delta / message_delta with usage), and
62 * - `stream.on("text", cb)` + `await stream.finalMessage()`.
63 * An adapter stream must serve both or those features crash the moment
64 * AI_PROVIDER=openai — which is exactly what the 2026-09-02 audit found:
65 * `messages.stream is not a function` on all four.
66 */
67export interface AiStreamLike extends AsyncIterable<unknown> {
68 on(event: "text", cb: (delta: string) => void): AiStreamLike;
69 finalMessage(): Promise<AiMessageResponse>;
70}
71
5472/** The slice of the Anthropic client the platform actually consumes. */
5573export interface AiClientLike {
56 messages: { create(req: AiMessageRequest): Promise<AiMessageResponse> };
74 messages: {
75 create(req: AiMessageRequest): Promise<AiMessageResponse>;
76 stream(req: AiMessageRequest): AiStreamLike;
77 };
5778}
5879
5980/**
204225
205226 return fromOpenAiResponse(await res.json(), req.model);
206227 },
228
229 stream(req: AiMessageRequest): AiStreamLike {
230 return createOpenAiSseStream({
231 url,
232 apiKey: opts.apiKey,
233 fetchImpl: doFetch,
234 timeoutMs,
235 req,
236 });
237 },
238 },
239 };
240}
241
242// ---------------------------------------------------------------------------
243// SSE streaming — OpenAI chat-completions chunks translated live into the
244// Anthropic event shapes the four streaming features already consume.
245// ---------------------------------------------------------------------------
246
247/**
248 * One pump drives the connection regardless of how the caller consumes:
249 * events are teed to a queue (for `for await`) AND to text listeners
250 * (`on("text")`), while `finalMessage()` resolves when the wire closes.
251 * That mirrors the Anthropic SDK's MessageStream contract — a caller that
252 * only awaits finalMessage without iterating must still drain the socket.
253 */
254function createOpenAiSseStream(args: {
255 url: string;
256 apiKey?: string;
257 fetchImpl: typeof fetch;
258 timeoutMs: number;
259 req: AiMessageRequest;
260}): AiStreamLike {
261 const textListeners: Array<(delta: string) => void> = [];
262 const queue: unknown[] = [];
263 let queueResolve: (() => void) | null = null;
264 let done = false;
265 let failure: unknown = null;
266
267 let accumulated = "";
268 let usageIn = 0;
269 let usageOut = 0;
270 let stopReason: string | null = null;
271
272 const notify = () => {
273 queueResolve?.();
274 queueResolve = null;
275 };
276 const push = (event: unknown) => {
277 queue.push(event);
278 notify();
279 };
280
281 const finalPromise = (async (): Promise<AiMessageResponse> => {
282 const headers: Record<string, string> = {
283 "content-type": "application/json",
284 };
285 if (args.apiKey) headers.authorization = `Bearer ${args.apiKey}`;
286
287 const controller = new AbortController();
288 const timer = setTimeout(() => controller.abort(), args.timeoutMs);
289 try {
290 const res = await args.fetchImpl(args.url, {
291 method: "POST",
292 headers,
293 body: JSON.stringify({
294 ...toOpenAiBody(args.req),
295 stream: true,
296 // Ask for usage on the final chunk (OpenAI, vLLM honour this;
297 // servers that don't simply omit it and cost falls back to 0 —
298 // visible as "unknown usage", never NaN in the ledger).
299 stream_options: { include_usage: true },
300 }),
301 signal: controller.signal,
302 });
303
304 if (!res.ok || !res.body) {
305 let detail = "";
306 try {
307 detail = (await res.text()).slice(0, 500);
308 } catch {
309 detail = "";
310 }
311 throw new Error(
312 `AI provider stream failed (${res.status} ${res.statusText})` +
313 (detail ? `: ${detail}` : "")
314 );
315 }
316
317 push({
318 type: "message_start",
319 message: {
320 role: "assistant",
321 model: args.req.model,
322 usage: { input_tokens: 0, output_tokens: 0 },
323 },
324 });
325
326 const reader = res.body.getReader();
327 const decoder = new TextDecoder();
328 let buffer = "";
329 for (;;) {
330 const { value, done: eof } = await reader.read();
331 if (eof) break;
332 buffer += decoder.decode(value, { stream: true });
333 // SSE frames are newline-delimited; keep the trailing partial line.
334 const lines = buffer.split("\n");
335 buffer = lines.pop() ?? "";
336 for (const line of lines) {
337 const payload = line.startsWith("data:") ? line.slice(5).trim() : "";
338 if (!payload) continue;
339 if (payload === "[DONE]") continue;
340 let chunk: Record<string, any>;
341 try {
342 chunk = JSON.parse(payload);
343 } catch {
344 continue; // a torn frame is the server's bug, not a crash of ours
345 }
346 const choice = Array.isArray(chunk.choices)
347 ? chunk.choices[0]
348 : undefined;
349 const delta: string =
350 typeof choice?.delta?.content === "string"
351 ? choice.delta.content
352 : "";
353 if (delta) {
354 accumulated += delta;
355 for (const cb of textListeners) {
356 try {
357 cb(delta);
358 } catch {
359 /* a listener error must not kill the stream */
360 }
361 }
362 push({
363 type: "content_block_delta",
364 index: 0,
365 delta: { type: "text_delta", text: delta },
366 });
367 }
368 if (choice?.finish_reason) stopReason = choice.finish_reason;
369 const u = chunk.usage as Record<string, unknown> | undefined;
370 if (u) {
371 if (typeof u.prompt_tokens === "number") usageIn = u.prompt_tokens;
372 if (typeof u.completion_tokens === "number")
373 usageOut = u.completion_tokens;
374 }
375 }
376 }
377
378 push({
379 type: "message_delta",
380 delta: { stop_reason: stopReason },
381 usage: { input_tokens: usageIn, output_tokens: usageOut },
382 });
383 push({ type: "message_stop" });
384
385 return {
386 id: "aiprov_stream",
387 model: args.req.model,
388 role: "assistant",
389 type: "message",
390 stop_reason: stopReason,
391 content: [{ type: "text", text: accumulated }],
392 usage: { input_tokens: usageIn, output_tokens: usageOut },
393 };
394 } catch (err) {
395 // Recorded HERE, not in a .catch on the promise: the finally below
396 // wakes the iterator, and a microtask-later .catch would lose the
397 // race — the iterator would see done=true, failure=null, and end
398 // cleanly on an error. Silent truncation is the worst failure mode a
399 // stream has.
400 failure = err;
401 throw err;
402 } finally {
403 clearTimeout(timer);
404 done = true;
405 notify();
406 }
407 })();
408
409 // A finalMessage nobody awaits must not surface as an unhandled rejection.
410 finalPromise.catch(() => {});
411
412 const stream: AiStreamLike = {
413 on(event, cb) {
414 if (event === "text") textListeners.push(cb);
415 return stream;
416 },
417 finalMessage() {
418 return finalPromise;
419 },
420 async *[Symbol.asyncIterator]() {
421 let cursor = 0;
422 for (;;) {
423 while (cursor < queue.length) yield queue[cursor++];
424 if (failure) throw failure;
425 if (done) return;
426 await new Promise<void>((resolve) => {
427 queueResolve = resolve;
428 });
429 }
207430 },
208431 };
432 return stream;
209433}
Modifiedsrc/lib/ai-workspace.ts+38−16View fileUnifiedSplit
2525 pullRequests,
2626 workspaceJobs,
2727} from "../db/schema";
28import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
28import { callModel, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
2929import { applyEditsToNewBranch, type FileEdit } from "./spec-git";
3030import { getBlob } from "../git/repository";
3131import { getBotUserIdOrFallback } from "./bot-user";
216216 const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`);
217217
218218 const fileTree = await getFileTree(repoDiskPath);
219 const relevantFiles = await pickRelevantFiles(issueCtx, fileTree);
219 const relevantFiles = await pickRelevantFiles(
220 issueCtx,
221 fileTree,
222 job.repoId,
223 triggeredByUserId
224 );
220225 const fileContents = await readFileContents(
221226 job.ownerName,
222227 job.repoName,
224229 );
225230
226231 // Step 3 — Generate implementation plan
227 const plan = await generatePlan(issueCtx, fileContents);
232 const plan = await generatePlan(
233 issueCtx,
234 fileContents,
235 job.repoId,
236 triggeredByUserId
237 );
228238
229239 // Post plan as issue comment
230240 const planBody = formatPlanComment(plan);
370380
371381async function pickRelevantFiles(
372382 ctx: IssueContext,
373 fileTree: string[]
383 fileTree: string[],
384 repoId: string,
385 userId: string
374386): Promise<string[]> {
375387 if (fileTree.length === 0) return [];
376388
381393 `Return JSON: {"files": string[]}`;
382394
383395 try {
384 const client = getAnthropic();
385 const msg = await client.messages.create({
396 const msg = await callModel({
386397 model: MODEL_SONNET,
387 max_tokens: 512,
388 temperature: 0,
389 messages: [{ role: "user", content: prompt }],
398 category: "other",
399 repositoryId: repoId,
400 ownerUserId: userId,
401 request: {
402 max_tokens: 512,
403 temperature: 0,
404 messages: [{ role: "user", content: prompt }],
405 },
390406 });
391407 const text = extractText(msg);
392408 const parsed = parseJsonResponse<{ files: string[] }>(text);
442458
443459async function generatePlan(
444460 ctx: IssueContext,
445 fileContents: Array<{ path: string; content: string }>
461 fileContents: Array<{ path: string; content: string }>,
462 repoId: string,
463 userId: string
446464): Promise<WorkspacePlan> {
447465 const fileSection = fileContents
448466 .map((f) => `=== ${f.path} ===\n${f.content}`)
473491 "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " +
474492 "Return only valid JSON — no prose, no markdown fences.";
475493
476 const client = getAnthropic();
477 const msg = await client.messages.create({
494 const msg = await callModel({
478495 model: MODEL_SONNET,
479 max_tokens: 4096,
480 temperature: 0.2,
481 system: systemPrompt,
482 messages: [{ role: "user", content: userPrompt }],
496 category: "other",
497 repositoryId: repoId,
498 ownerUserId: userId,
499 request: {
500 max_tokens: 4096,
501 temperature: 0.2,
502 system: systemPrompt,
503 messages: [{ role: "user", content: userPrompt }],
504 },
483505 });
484506
485507 const text = extractText(msg);
Modifiedsrc/lib/claude-semantic-search.ts+11−8View fileUnifiedSplit
2323 * Fallback: if ANTHROPIC_API_KEY is not set, falls back to `git grep`.
2424 */
2525
26import { getAnthropic, MODEL_HAIKU, MODEL_SONNET, parseJsonResponse } from "./ai-client";
26import { callModel, MODEL_HAIKU, MODEL_SONNET, parseJsonResponse } from "./ai-client";
2727import { config } from "./config";
2828import { getRepoPath } from "../git/repository";
2929
296296
297297async function rankFilesWithClaude(
298298 query: string,
299 fileIndex: Array<{ path: string; head: string }>
299 fileIndex: Array<{ path: string; head: string }>,
300 repoId: string
300301): Promise<ClaudeRankedFile[]> {
301 const client = getAnthropic();
302
303302 // Build a compact text index, respecting the char cap.
304303 let indexText = "";
305304 for (const { path, head } of fileIndex) {
321320Only include files with confidence > 0.2. Return [] if nothing is relevant.`;
322321
323322 try {
324 const message = await client.messages.create({
323 const message = await callModel({
325324 model: MODEL_SONNET,
326 max_tokens: 1000,
327 messages: [{ role: "user", content: prompt }],
325 category: "other",
326 repositoryId: repoId,
327 request: {
328 max_tokens: 1000,
329 messages: [{ role: "user", content: prompt }],
330 },
328331 });
329332
330333 const text =
499502 }
500503
501504 // Step 4: ask Claude to rank files
502 const ranked = await rankFilesWithClaude(q, fileIndex);
505 const ranked = await rankFilesWithClaude(q, fileIndex, repoId);
503506
504507 if (ranked.length === 0) {
505508 // Claude found nothing — try keyword fallback
Modifiedsrc/lib/nl-search.ts+35−26View fileUnifiedSplit
2020 * 5. Cache results in-memory per `${repoId}:${query}` for 15 minutes.
2121 */
2222
23import { getAnthropic, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
23import { callModel, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
2424import { getRepoPath } from "../git/repository";
2525import { db } from "../db";
2626import { codeChunks } from "../db/schema";
205205 fileTypes: string[];
206206}
207207
208async function extractKeywords(query: string): Promise<KeywordExtraction> {
209 const client = getAnthropic();
208async function extractKeywords(
209 query: string,
210 repoId: string
211): Promise<KeywordExtraction> {
210212 try {
211 const msg = await client.messages.create({
213 const msg = await callModel({
212214 model: MODEL_SONNET,
213 max_tokens: 256,
214 messages: [
215 {
216 role: "user",
217 content:
218 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
219 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
220 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
221 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
222 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
223 `Query: ${query}`,
224 },
225 ],
215 category: "other",
216 repositoryId: repoId,
217 request: {
218 max_tokens: 256,
219 messages: [
220 {
221 role: "user",
222 content:
223 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
224 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
225 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
226 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
227 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
228 `Query: ${query}`,
229 },
230 ],
231 },
226232 });
227233 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
228234 const parsed = parseJsonResponse<KeywordExtraction>(text);
374380
375381async function reasonWithClaude(
376382 query: string,
377 contextStr: string
383 contextStr: string,
384 repoId: string
378385): Promise<NlSearchResult[]> {
379 const client = getAnthropic();
380
381386 const systemPrompt =
382387 `You are a code analysis expert. Find all places in the provided code that match the user's query. ` +
383388 `Be precise about file paths and line numbers. Only return matches that genuinely satisfy the query — ` +
400405 `Return {"results": []} if nothing matches. Sort by confidence descending. Max 10 results.`;
401406
402407 try {
403 const msg = await client.messages.create({
408 const msg = await callModel({
404409 model: MODEL_SONNET,
405 max_tokens: 3000,
406 system: systemPrompt,
407 messages: [{ role: "user", content: userPrompt }],
410 category: "other",
411 repositoryId: repoId,
412 request: {
413 max_tokens: 3000,
414 system: systemPrompt,
415 messages: [{ role: "user", content: userPrompt }],
416 },
408417 });
409418
410419 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
473482
474483 try {
475484 // Step 1 — Extract keywords
476 const extraction = await extractKeywords(q);
485 const extraction = await extractKeywords(q, repoId);
477486
478487 // Step 2 — Gather candidate files
479488 const candidates = await gatherCandidates(
496505 }
497506
498507 // Step 4 — Claude reasoning pass
499 const results = await reasonWithClaude(q, contextStr);
508 const results = await reasonWithClaude(q, contextStr, repoId);
500509
501510 const response: NlSearchResponse = {
502511 query: q,
Modifiedsrc/routes/ai-editor.ts+42−31View fileUnifiedSplit
1212
1313import { Hono } from "hono";
1414import { requireAuth } from "../middleware/auth";
15import { isAiAvailable, getAnthropic, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
15import { isAiAvailable, callModel, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
1616import type { AuthEnv } from "../middleware/auth";
1717
1818const aiEditor = new Hono<AuthEnv>();
8888 "Complete the code at the cursor position. Return ONLY the completion text to insert, no explanation, no markdown, no code fences.";
8989
9090 try {
91 const anthropic = getAnthropic();
92 const message = await anthropic.messages.create({
91 const message = await callModel({
9392 model: MODEL_SONNET,
94 max_tokens: 256,
95 system:
96 "You are a code completion AI. Complete the code snippet at the cursor. " +
97 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
98 "Keep completions concise (prefer single-line or a few lines). " +
99 "If you have nothing useful to add, return an empty string.",
100 messages: [{ role: "user", content: prompt }],
93 category: "other",
94 ownerUserId: user.id,
95 request: {
96 max_tokens: 256,
97 system:
98 "You are a code completion AI. Complete the code snippet at the cursor. " +
99 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
100 "Keep completions concise (prefer single-line or a few lines). " +
101 "If you have nothing useful to add, return an empty string.",
102 messages: [{ role: "user", content: prompt }],
103 },
101104 });
102105
103106 const suggestion = extractText(message).trimEnd();
132135 }
133136
134137 try {
135 const anthropic = getAnthropic();
136 const message = await anthropic.messages.create({
138 const user = c.get("user")!;
139 const message = await callModel({
137140 model: MODEL_SONNET,
138 max_tokens: 512,
139 system:
140 "You are a senior engineer explaining code to a fellow developer. " +
141 "Be concise and precise. Use plain text (no markdown headers). " +
142 "One or two short paragraphs maximum.",
143 messages: [
144 {
145 role: "user",
146 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
147 },
148 ],
141 category: "other",
142 ownerUserId: user.id,
143 request: {
144 max_tokens: 512,
145 system:
146 "You are a senior engineer explaining code to a fellow developer. " +
147 "Be concise and precise. Use plain text (no markdown headers). " +
148 "One or two short paragraphs maximum.",
149 messages: [
150 {
151 role: "user",
152 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
153 },
154 ],
155 },
149156 });
150157
151158 const explanation = extractText(message).trim();
189196 '"explanation" (one short sentence describing what was wrong).';
190197
191198 try {
192 const anthropic = getAnthropic();
193 const message = await anthropic.messages.create({
199 const user = c.get("user")!;
200 const message = await callModel({
194201 model: MODEL_SONNET,
195 max_tokens: 1024,
196 system:
197 "You are an expert code fixer. Given an error and code, return valid JSON with " +
198 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
199 "Return ONLY the JSON object, nothing else.",
200 messages: [{ role: "user", content: prompt }],
202 category: "other",
203 ownerUserId: user.id,
204 request: {
205 max_tokens: 1024,
206 system:
207 "You are an expert code fixer. Given an error and code, return valid JSON with " +
208 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
209 "Return ONLY the JSON object, nothing else.",
210 messages: [{ role: "user", content: prompt }],
211 },
201212 });
202213
203214 const raw = extractText(message).trim();
204215
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts