CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 | /**
* AI rubber-duck chat — repo-grounded conversation backed by the
* continuous semantic index (src/lib/semantic-index.ts) and Claude
* streaming.
*
* Why a new module instead of extending `ai-chat.ts`?
*
* - `ai-chat.ts` is built around a single-blob JSON history in
* `ai_chats.messages`. Streaming partials, per-message citations,
* and token-cost accounting would require rewriting the whole
* blob on each turn. The 0060 migration introduces dedicated
* `repo_chats` + `repo_chat_messages` tables, one row per
* message, which makes streaming and citations first-class.
* - Repo chat retrieves grounding context via the per-push semantic
* index, which is fundamentally different from the static
* README+tree summarisation in `ai-chat.ts`. Keeping the two
* codepaths separate avoids muddling the abstraction.
*
* Public surface:
*
* - `createChat({ repositoryId, ownerUserId, title? })` →
* creates a row in `repo_chats` and returns it.
* - `appendUserMessage(chatId, content)` → stores a `role:'user'`
* row in `repo_chat_messages` and returns it.
* - `streamAssistantReply({ chatId, repoId, userMessage, onChunk })`
* → resolves grounding context, streams Claude tokens via the
* `onChunk` callback, persists the final assistant message with
* citations, returns the stored row.
* - `__setStreamerForTests` → test seam so unit tests can replace
* the Claude streaming call with a deterministic generator.
*
* Hard rules:
* - Never throw at the boundary. Every external dependency
* (semantic index, git blob fetch, Claude API, DB) is wrapped;
* on failure we fall back to a safe default and continue.
* - Graceful when the semantic index is empty: fall back to a
* truncated tree-of-paths summary so the assistant still has
* *some* signal about the repo's shape.
* - All DB writes are best-effort; a DB outage degrades the chat
* to ephemeral mode rather than throwing.
*/
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "../db";
import {
repoChats,
repoChatMessages,
repositories,
users,
type RepoChat,
type RepoChatMessage,
} from "../db/schema";
import { searchSemantic, type SemanticHit } from "./semantic-index";
import {
getBlob,
getDefaultBranch,
getTreeRecursive,
} from "../git/repository";
import { getAnthropic, isAiAvailable, MODEL_SONNET } from "./ai-client";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Max semantic hits we feed the assistant. */
const DEFAULT_TOP_K = 8;
/** Max chars of file content per snippet (after path + heading). */
const MAX_SNIPPET_CHARS = 1500;
/** Max paths in the empty-index fallback tree summary. */
const FALLBACK_TREE_CAP = 120;
/** Hard cap on stored title length. */
const TITLE_LIMIT = 80;
/** Hard cap on the assistant reply we'll persist. */
const ASSISTANT_REPLY_CAP = 32_000;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Citation {
file_path: string;
blob_sha: string;
}
export interface CreateChatOpts {
repositoryId: string;
ownerUserId: string;
title?: string | null;
}
export interface StreamReplyOpts {
chatId: string;
repoId: string;
userMessage: string;
/**
* Called for each token / text delta emitted by the assistant.
* Implementations may ignore the chunk (e.g. tests that only care
* about the final reply) — the helper still accumulates and stores
* the full reply regardless.
*/
onChunk?: (chunk: string) => void;
/**
* Optional override of the top-K semantic hits. Useful for tests
* + the SSE endpoint where the caller wants to thin the context
* for cost reasons.
*/
topK?: number;
}
/**
* Test seam — replace the streaming call with a deterministic generator.
* Pass `null` to reset. Each chunk yielded becomes one token in the
* persisted reply (and one `onChunk` invocation).
*/
export type StreamerFn = (args: {
systemPrompt: string;
userMessage: string;
}) => AsyncIterable<string>;
let _streamerOverride: StreamerFn | null = null;
export function __setStreamerForTests(fn: StreamerFn | null): void {
_streamerOverride = fn;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Create a new chat row scoped to (repository, owner_user). The title
* is optional; if absent we leave it null and the route layer can fill
* it in from the first user message.
*/
export async function createChat(opts: CreateChatOpts): Promise<RepoChat | null> {
if (!opts.repositoryId || !opts.ownerUserId) return null;
try {
const [row] = await db
.insert(repoChats)
.values({
repositoryId: opts.repositoryId,
ownerUserId: opts.ownerUserId,
title: (opts.title || "").slice(0, TITLE_LIMIT) || null,
})
.returning();
return row || null;
} catch (err) {
if (process.env.DEBUG_REPO_CHAT === "1") {
console.error("[repo-chat] createChat failed:", err);
}
return null;
}
}
/**
* Append a `role:'user'` row to the chat and bump `repo_chats.updated_at`
* so the chat list ordering stays fresh.
*/
export async function appendUserMessage(
chatId: string,
content: string
): Promise<RepoChatMessage | null> {
if (!chatId || !content) return null;
try {
const [row] = await db
.insert(repoChatMessages)
.values({
chatId,
role: "user",
content,
citations: [],
tokenCost: 0,
})
.returning();
// Best-effort: keep the parent chat's `updated_at` warm.
try {
await db
.update(repoChats)
.set({ updatedAt: new Date() })
.where(eq(repoChats.id, chatId));
} catch {
/* tolerate */
}
return row || null;
} catch (err) {
if (process.env.DEBUG_REPO_CHAT === "1") {
console.error("[repo-chat] appendUserMessage failed:", err);
}
return null;
}
}
/**
* The full pipeline: resolve repo context (semantic hits → snippets →
* system prompt), stream Claude's reply token-by-token via `onChunk`,
* persist the final assistant row with citations + a coarse token
* cost estimate, and return it.
*
* Never throws. On any failure inside grounding/streaming we still
* try to persist a short advisory assistant message so the UI never
* shows a "phantom" user message with no reply.
*/
export async function streamAssistantReply(
opts: StreamReplyOpts
): Promise<RepoChatMessage | null> {
const { chatId, repoId, userMessage } = opts;
const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 20));
// 1. Resolve grounding context.
const { citations, contextBlock } = await buildGroundingContext({
repoId,
userMessage,
topK,
});
// 2. Build the system prompt.
const systemPrompt = await buildSystemPrompt({
repoId,
contextBlock,
});
// 3. Stream Claude's reply (or canned tokens in tests).
let reply = "";
try {
const stream = _streamerOverride
? _streamerOverride({ systemPrompt, userMessage })
: claudeStream({ systemPrompt, userMessage });
for await (const chunk of stream) {
if (!chunk) continue;
reply += chunk;
if (opts.onChunk) {
try {
opts.onChunk(chunk);
} catch {
// Caller-supplied callback errors mustn't kill the stream.
}
}
// Safety cap — Claude can produce very long outputs; we don't
// want a runaway response to blow up our row.
if (reply.length >= ASSISTANT_REPLY_CAP) break;
}
} catch (err) {
if (process.env.DEBUG_REPO_CHAT === "1") {
console.error("[repo-chat] stream failed:", err);
}
if (!reply) {
reply =
"Sorry — I couldn't reach the AI service to answer that. Please retry in a moment.";
}
}
// Cap the persisted reply.
if (reply.length > ASSISTANT_REPLY_CAP) {
reply = reply.slice(0, ASSISTANT_REPLY_CAP);
}
// 4. Coarse token-cost estimate: ~4 chars/token. Stored as integer.
const tokenCost = Math.ceil(
(systemPrompt.length + userMessage.length + reply.length) / 4
);
// 5. Persist.
try {
const [row] = await db
.insert(repoChatMessages)
.values({
chatId,
role: "assistant",
content: reply,
citations,
tokenCost,
})
.returning();
// Bump parent chat freshness so list ordering reflects the answer.
try {
await db
.update(repoChats)
.set({ updatedAt: new Date() })
.where(eq(repoChats.id, chatId));
} catch {
/* tolerate */
}
return row || null;
} catch (err) {
if (process.env.DEBUG_REPO_CHAT === "1") {
console.error("[repo-chat] persist assistant failed:", err);
}
return null;
}
}
/**
* List all chats for a (user, repo) pair, ordered by most-recently
* updated. Returns [] on any DB failure.
*/
export async function listChatsForRepo(
ownerUserId: string,
repositoryId: string,
limit = 30
): Promise<RepoChat[]> {
if (!ownerUserId || !repositoryId) return [];
try {
return await db
.select()
.from(repoChats)
.where(
and(
eq(repoChats.ownerUserId, ownerUserId),
eq(repoChats.repositoryId, repositoryId)
)
)
.orderBy(desc(repoChats.updatedAt))
.limit(Math.max(1, Math.min(limit, 100)));
} catch {
return [];
}
}
/**
* Fetch all messages in a chat, oldest first. Empty array on DB error.
*/
export async function listMessages(
chatId: string
): Promise<RepoChatMessage[]> {
if (!chatId) return [];
try {
return await db
.select()
.from(repoChatMessages)
.where(eq(repoChatMessages.chatId, chatId))
.orderBy(asc(repoChatMessages.createdAt));
} catch {
return [];
}
}
/**
* Verify the chat belongs to the user. Returns the chat row or null.
* Used by the route handlers + the SSE endpoint to authorise access.
*/
export async function getChatForUser(
chatId: string,
ownerUserId: string
): Promise<RepoChat | null> {
if (!chatId || !ownerUserId) return null;
try {
const [row] = await db
.select()
.from(repoChats)
.where(
and(eq(repoChats.id, chatId), eq(repoChats.ownerUserId, ownerUserId))
)
.limit(1);
return row || null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Grounding context — semantic hits + snippet fetch, with tree fallback.
// ---------------------------------------------------------------------------
async function buildGroundingContext(args: {
repoId: string;
userMessage: string;
topK: number;
}): Promise<{ citations: Citation[]; contextBlock: string }> {
const { repoId, userMessage, topK } = args;
let hits: SemanticHit[] = [];
try {
hits = await searchSemantic({
repositoryId: repoId,
query: userMessage,
limit: topK,
});
} catch {
hits = [];
}
if (!hits.length) {
// Fallback: tree-of-paths summary so Claude still has *some*
// sense of repo shape. Best-effort — empty string on failure.
const treeSummary = await buildTreeFallback(repoId);
return {
citations: [],
contextBlock: treeSummary
? `No semantic index is available for this repo (yet). Repo layout:\n\n${treeSummary}`
: "",
};
}
// Resolve owner/name for getBlob lookups.
const repoMeta = await loadRepoMeta(repoId);
const citations: Citation[] = [];
const sections: string[] = [];
for (const hit of hits) {
// The semantic-index already stores a snippet; prefer that for
// cost, but fetch a slightly larger window via getBlob when meta
// is available so the model sees real code, not a 500-char preview.
let snippet = hit.snippet || "";
if (repoMeta) {
try {
const blob = await getBlob(
repoMeta.owner,
repoMeta.name,
repoMeta.defaultBranch,
hit.filePath
);
if (blob && !blob.isBinary && blob.content) {
snippet = blob.content.slice(0, MAX_SNIPPET_CHARS);
}
} catch {
// tolerate; fall back to whatever snippet we already have
}
}
if (!snippet) continue;
citations.push({
file_path: hit.filePath,
blob_sha: hit.blobSha,
});
sections.push(`### ${hit.filePath}\n\`\`\`\n${snippet}\n\`\`\``);
}
return {
citations,
contextBlock: sections.join("\n\n"),
};
}
async function buildTreeFallback(repoId: string): Promise<string> {
const meta = await loadRepoMeta(repoId);
if (!meta) return "";
try {
const tree = await getTreeRecursive(
meta.owner,
meta.name,
meta.defaultBranch,
FALLBACK_TREE_CAP * 2
);
if (!tree) return "";
const blobs = tree.tree
.filter((e) => e.type === "blob")
.slice(0, FALLBACK_TREE_CAP)
.map((e) => `- ${e.path}`);
return blobs.join("\n");
} catch {
return "";
}
}
async function buildSystemPrompt(args: {
repoId: string;
contextBlock: string;
}): Promise<string> {
const meta = await loadRepoMeta(args.repoId);
const owner = meta?.owner || "unknown";
const name = meta?.name || "repo";
const header = [
`You are Gluecron's repo chat for ${owner}/${name}.`,
`Here are the most relevant files based on the user's question:`,
"",
args.contextBlock || "(no grounding context available)",
"",
`Answer concisely. Cite files as [path](/${owner}/${name}/blob/HEAD/path).`,
`Prefer code snippets over prose when explaining concrete behaviour.`,
`If the grounding context doesn't cover the question, say so plainly rather than guessing.`,
];
return header.join("\n");
}
// ---------------------------------------------------------------------------
// Repo metadata helper — owner/name/defaultBranch for git lookups.
// ---------------------------------------------------------------------------
interface RepoMeta {
owner: string;
name: string;
defaultBranch: string;
}
async function loadRepoMeta(repoId: string): Promise<RepoMeta | null> {
if (!repoId) return null;
try {
const [row] = await db
.select({
name: repositories.name,
defaultBranch: repositories.defaultBranch,
owner: users.username,
})
.from(repositories)
.innerJoin(users, eq(repositories.ownerId, users.id))
.where(eq(repositories.id, repoId))
.limit(1);
if (!row) return null;
let defaultBranch = row.defaultBranch || "main";
// If the configured default branch isn't actually resolvable on
// disk, fall back to whatever HEAD points at.
try {
const real = await getDefaultBranch(row.owner, row.name);
if (real) defaultBranch = real;
} catch {
/* tolerate */
}
return { owner: row.owner, name: row.name, defaultBranch };
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Claude streaming — thin wrapper that yields text deltas.
// ---------------------------------------------------------------------------
async function* claudeStream(args: {
systemPrompt: string;
userMessage: string;
}): AsyncGenerator<string, void, unknown> {
if (!isAiAvailable()) {
yield "AI is not configured on this Gluecron instance — set ANTHROPIC_API_KEY to enable rubber-duck chat.";
return;
}
const client = getAnthropic();
// Anthropic SDK exposes `.stream(...)` returning an event emitter
// with an async iterator over RawMessageStreamEvent chunks. We
// extract `content_block_delta` text deltas.
const stream = client.messages.stream({
model: MODEL_SONNET,
max_tokens: 2048,
system: args.systemPrompt,
messages: [{ role: "user", content: args.userMessage }],
});
// The SDK's stream object is itself async-iterable over events.
for await (const event of stream as AsyncIterable<unknown>) {
const delta = extractTextDelta(event);
if (delta) yield delta;
}
}
function extractTextDelta(event: unknown): string {
if (!event || typeof event !== "object") return "";
const e = event as Record<string, unknown>;
if (e.type !== "content_block_delta") return "";
const delta = e.delta as Record<string, unknown> | undefined;
if (!delta) return "";
if (delta.type === "text_delta" && typeof delta.text === "string") {
return delta.text;
}
return "";
}
// ---------------------------------------------------------------------------
// Test-only exports.
// ---------------------------------------------------------------------------
export const __test = {
buildGroundingContext,
buildSystemPrompt,
buildTreeFallback,
extractTextDelta,
ASSISTANT_REPLY_CAP,
MAX_SNIPPET_CHARS,
FALLBACK_TREE_CAP,
};
|