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

personal-chat.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.

personal-chat.tsBlame456 lines · 1 contributor
ee7e577Claude1/**
2 * Personal chat — user-scoped sibling of `src/lib/repo-chat.ts`.
3 *
4 * Same contract: create chats, append user messages, stream assistant
5 * replies grounded in semantic retrieval. The retrieval difference: we
6 * call `searchPersonalSemantic` (cross-repo, opt-in) instead of
7 * `searchSemantic` (per-repo). Citations carry `repo_name` alongside the
8 * file path so the UI can render "owner/repo · path".
9 *
10 * Hard rules (mirrors repo-chat):
11 * - Never throw at the boundary.
12 * - Streamer is the same Anthropic streaming wrapper as repo-chat;
13 * we reuse its module-level helpers via a shared internal API
14 * (`__personalChatStreamer`) so test-seam injection is consistent.
15 * - If the user has not opted in (or has no accessible repos), the
16 * assistant reply is a short advisory message — we never silently
17 * succeed with an empty context and pretend to know things.
18 */
19
20import { and, asc, desc, eq } from "drizzle-orm";
21import { db } from "../db";
22import {
23 personalChats,
24 personalChatMessages,
25 type PersonalChat,
26 type PersonalChatMessage,
27} from "../db/schema";
28import {
29 isPersonalSemanticEnabled,
30 searchPersonalSemantic,
31 type PersonalSemanticHit,
32} from "./personal-semantic";
33import { getAnthropic, isAiAvailable, MODEL_SONNET } from "./ai-client";
34
35// ---------------------------------------------------------------------------
36// Constants
37// ---------------------------------------------------------------------------
38
39const DEFAULT_TOP_K = 8;
40const MAX_SNIPPET_CHARS = 1500;
41const TITLE_LIMIT = 80;
42const ASSISTANT_REPLY_CAP = 32_000;
43
44// ---------------------------------------------------------------------------
45// Types
46// ---------------------------------------------------------------------------
47
48export interface PersonalCitation {
49 file_path: string;
50 blob_sha: string;
51 repo_name: string;
52}
53
54export interface CreatePersonalChatOpts {
55 ownerUserId: string;
56 title?: string | null;
57}
58
59export interface PersonalStreamReplyOpts {
60 chatId: string;
61 userId: string;
62 userMessage: string;
63 onChunk?: (chunk: string) => void;
64 topK?: number;
65}
66
67// ---------------------------------------------------------------------------
68// Streamer test seam — kept separate from the repo-chat seam so tests can
69// drive the two surfaces independently.
70// ---------------------------------------------------------------------------
71
72export type PersonalStreamerFn = (args: {
73 systemPrompt: string;
74 userMessage: string;
75}) => AsyncIterable<string>;
76
77let _streamerOverride: PersonalStreamerFn | null = null;
78
79export function __setPersonalStreamerForTests(
80 fn: PersonalStreamerFn | null
81): void {
82 _streamerOverride = fn;
83}
84
85// ---------------------------------------------------------------------------
86// Public API
87// ---------------------------------------------------------------------------
88
89export async function createPersonalChat(
90 opts: CreatePersonalChatOpts
91): Promise<PersonalChat | null> {
92 if (!opts.ownerUserId) return null;
93 try {
94 const [row] = await db
95 .insert(personalChats)
96 .values({
97 ownerUserId: opts.ownerUserId,
98 title: (opts.title || "").slice(0, TITLE_LIMIT) || null,
99 })
100 .returning();
101 return row || null;
102 } catch (err) {
103 if (process.env.DEBUG_PERSONAL_CHAT === "1") {
104 console.error("[personal-chat] createPersonalChat failed:", err);
105 }
106 return null;
107 }
108}
109
110export async function appendPersonalUserMessage(
111 chatId: string,
112 content: string
113): Promise<PersonalChatMessage | null> {
114 if (!chatId || !content) return null;
115 try {
116 const [row] = await db
117 .insert(personalChatMessages)
118 .values({
119 chatId,
120 role: "user",
121 content,
122 citations: [],
123 tokenCost: 0,
124 })
125 .returning();
126 try {
127 await db
128 .update(personalChats)
129 .set({ updatedAt: new Date() })
130 .where(eq(personalChats.id, chatId));
131 } catch {
132 /* tolerate */
133 }
134 return row || null;
135 } catch (err) {
136 if (process.env.DEBUG_PERSONAL_CHAT === "1") {
137 console.error("[personal-chat] appendPersonalUserMessage failed:", err);
138 }
139 return null;
140 }
141}
142
143/**
144 * Full pipeline. Resolves cross-repo semantic context (gated on the
145 * user's opt-in flag), streams Claude's reply, persists the assistant
146 * row with citations carrying repo_name annotations.
147 */
148export async function streamPersonalAssistantReply(
149 opts: PersonalStreamReplyOpts
150): Promise<PersonalChatMessage | null> {
151 const { chatId, userId, userMessage } = opts;
152 const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 20));
153
154 // 1. Opt-in gate (the personal-semantic layer also checks this, but
155 // we short-circuit here so the assistant gives a clear advisory
156 // rather than blandly hallucinating off zero context).
157 const enabled = await isPersonalSemanticEnabled(userId);
158
159 // 2. Resolve grounding (empty array when disabled).
160 const { citations, contextBlock } = await buildPersonalContext({
161 userId,
162 userMessage,
163 topK,
164 enabled,
165 });
166
167 // 3. Build the system prompt.
168 const systemPrompt = buildPersonalSystemPrompt({
169 enabled,
170 contextBlock,
171 citationCount: citations.length,
172 });
173
174 // 4. Stream.
175 let reply = "";
176 try {
177 const stream = _streamerOverride
178 ? _streamerOverride({ systemPrompt, userMessage })
179 : claudeStreamPersonal({ systemPrompt, userMessage });
180
181 for await (const chunk of stream) {
182 if (!chunk) continue;
183 reply += chunk;
184 if (opts.onChunk) {
185 try {
186 opts.onChunk(chunk);
187 } catch {
188 /* ignore caller errors */
189 }
190 }
191 if (reply.length >= ASSISTANT_REPLY_CAP) break;
192 }
193 } catch (err) {
194 if (process.env.DEBUG_PERSONAL_CHAT === "1") {
195 console.error("[personal-chat] stream failed:", err);
196 }
197 if (!reply) {
198 reply =
199 "Sorry — I couldn't reach the AI service to answer that. Please retry in a moment.";
200 }
201 }
202
203 if (reply.length > ASSISTANT_REPLY_CAP) {
204 reply = reply.slice(0, ASSISTANT_REPLY_CAP);
205 }
206
207 const tokenCost = Math.ceil(
208 (systemPrompt.length + userMessage.length + reply.length) / 4
209 );
210
211 // 5. Persist.
212 try {
213 const [row] = await db
214 .insert(personalChatMessages)
215 .values({
216 chatId,
217 role: "assistant",
218 content: reply,
219 citations,
220 tokenCost,
221 })
222 .returning();
223 try {
224 await db
225 .update(personalChats)
226 .set({ updatedAt: new Date() })
227 .where(eq(personalChats.id, chatId));
228 } catch {
229 /* tolerate */
230 }
231 return row || null;
232 } catch (err) {
233 if (process.env.DEBUG_PERSONAL_CHAT === "1") {
234 console.error("[personal-chat] persist assistant failed:", err);
235 }
236 return null;
237 }
238}
239
240export async function listPersonalChatsForUser(
241 ownerUserId: string,
242 limit = 30
243): Promise<PersonalChat[]> {
244 if (!ownerUserId) return [];
245 try {
246 return await db
247 .select()
248 .from(personalChats)
249 .where(eq(personalChats.ownerUserId, ownerUserId))
250 .orderBy(desc(personalChats.updatedAt))
251 .limit(Math.max(1, Math.min(limit, 100)));
252 } catch {
253 return [];
254 }
255}
256
257export async function listPersonalMessages(
258 chatId: string
259): Promise<PersonalChatMessage[]> {
260 if (!chatId) return [];
261 try {
262 return await db
263 .select()
264 .from(personalChatMessages)
265 .where(eq(personalChatMessages.chatId, chatId))
266 .orderBy(asc(personalChatMessages.createdAt));
267 } catch {
268 return [];
269 }
270}
271
272export async function getPersonalChatForUser(
273 chatId: string,
274 ownerUserId: string
275): Promise<PersonalChat | null> {
276 if (!chatId || !ownerUserId) return null;
277 try {
278 const [row] = await db
279 .select()
280 .from(personalChats)
281 .where(
282 and(
283 eq(personalChats.id, chatId),
284 eq(personalChats.ownerUserId, ownerUserId)
285 )
286 )
287 .limit(1);
288 return row || null;
289 } catch {
290 return null;
291 }
292}
293
294// ---------------------------------------------------------------------------
295// Grounding context
296// ---------------------------------------------------------------------------
297
298async function buildPersonalContext(args: {
299 userId: string;
300 userMessage: string;
301 topK: number;
302 enabled: boolean;
303}): Promise<{ citations: PersonalCitation[]; contextBlock: string }> {
304 if (!args.enabled) {
305 return { citations: [], contextBlock: "" };
306 }
307
308 let hits: PersonalSemanticHit[] = [];
309 try {
310 hits = await searchPersonalSemantic({
311 userId: args.userId,
312 query: args.userMessage,
313 limit: args.topK,
314 });
315 } catch {
316 hits = [];
317 }
318
319 if (!hits.length) {
320 return { citations: [], contextBlock: "" };
321 }
322
323 const citations: PersonalCitation[] = [];
324 const sections: string[] = [];
325 for (const hit of hits) {
326 const snippet = (hit.snippet || "").slice(0, MAX_SNIPPET_CHARS);
327 if (!snippet) continue;
328 citations.push({
329 file_path: hit.filePath,
330 blob_sha: hit.blobSha,
331 repo_name: hit.repoName,
332 });
333 sections.push(
334 `### ${hit.repoName} · ${hit.filePath}\n\`\`\`\n${snippet}\n\`\`\``
335 );
336 }
337
338 return { citations, contextBlock: sections.join("\n\n") };
339}
340
341function buildPersonalSystemPrompt(args: {
342 enabled: boolean;
343 contextBlock: string;
344 citationCount: number;
345}): string {
346 if (!args.enabled) {
347 return [
348 "You are Gluecron's personal cross-repo chat assistant.",
349 "",
350 "The user has NOT enabled personal cross-repo semantic search.",
351 "Tell them clearly that you can't see their code until they enable",
352 "the toggle at /settings (Personal cross-repo semantic index). Do",
353 "not attempt to answer code-specific questions in this mode.",
354 ].join("\n");
355 }
356
357 const lines = [
358 "You are Gluecron's personal cross-repo chat assistant.",
359 "",
360 "You have access to semantic-index snippets across every repository",
361 "the user owns or is an accepted collaborator on. Citations name the",
362 "source repo as `owner/repo`; always include the repo name when you",
363 "reference a file, e.g. `owner/repo:src/lib/foo.ts`.",
364 "",
365 "Most relevant context:",
366 "",
367 args.contextBlock || "(no semantic hits — say so plainly)",
368 "",
369 "Answer concisely. Prefer code snippets over prose when explaining",
370 "concrete behaviour. If the grounding context doesn't cover the",
371 "question, say so rather than guessing.",
372 ];
373 return lines.join("\n");
374}
375
376// ---------------------------------------------------------------------------
377// Claude streaming
378// ---------------------------------------------------------------------------
379
380async function* claudeStreamPersonal(args: {
381 systemPrompt: string;
382 userMessage: string;
383}): AsyncGenerator<string, void, unknown> {
384 if (!isAiAvailable()) {
385 yield "AI is not configured on this Gluecron instance — set ANTHROPIC_API_KEY to enable personal chat.";
386 return;
387 }
388 const client = getAnthropic();
389 const stream = client.messages.stream({
390 model: MODEL_SONNET,
391 max_tokens: 2048,
392 system: args.systemPrompt,
393 messages: [{ role: "user", content: args.userMessage }],
394 });
395
396 let inputTokens = 0;
397 let outputTokens = 0;
398 for await (const event of stream as AsyncIterable<unknown>) {
399 const ev = event as Record<string, unknown> | null;
400 if (ev && typeof ev === "object") {
401 const msg = (ev as { message?: { usage?: { input_tokens?: number; output_tokens?: number } } }).message;
402 if (msg && msg.usage) {
403 if (typeof msg.usage.input_tokens === "number")
404 inputTokens = msg.usage.input_tokens;
405 if (typeof msg.usage.output_tokens === "number")
406 outputTokens = msg.usage.output_tokens;
407 }
408 const usage = (ev as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
409 if (usage) {
410 if (typeof usage.input_tokens === "number")
411 inputTokens = usage.input_tokens;
412 if (typeof usage.output_tokens === "number")
413 outputTokens = usage.output_tokens;
414 }
415 }
416 const delta = extractTextDelta(event);
417 if (delta) yield delta;
418 }
419
420 try {
421 const { recordAiCost } = await import("./ai-cost-tracker");
422 await recordAiCost({
423 model: MODEL_SONNET,
424 inputTokens,
425 outputTokens,
426 category: "chat",
427 sourceKind: "personal_chat",
428 });
429 } catch {
430 /* best-effort */
431 }
432}
433
434function extractTextDelta(event: unknown): string {
435 if (!event || typeof event !== "object") return "";
436 const e = event as Record<string, unknown>;
437 if (e.type !== "content_block_delta") return "";
438 const delta = e.delta as Record<string, unknown> | undefined;
439 if (!delta) return "";
440 if (delta.type === "text_delta" && typeof delta.text === "string") {
441 return delta.text;
442 }
443 return "";
444}
445
446// ---------------------------------------------------------------------------
447// Test-only exports
448// ---------------------------------------------------------------------------
449
450export const __test = {
451 buildPersonalContext,
452 buildPersonalSystemPrompt,
453 extractTextDelta,
454 ASSISTANT_REPLY_CAP,
455 MAX_SNIPPET_CHARS,
456};