Commitee7e577unknown_key
feat(personal-semantic): cross-repo Claude chat over user's private code — opt-in
8 files changed+2807−0ee7e57786cdf4ae12655637955b2a65b218accae
8 changed files+2807−0
Addeddrizzle/0071_personal_semantic.sql+73−0View fileUnifiedSplit
@@ -0,0 +1,73 @@
1-- Gluecron migration 0071: Personal cross-repo semantic index opt-in.
2--
3-- Today the semantic index (drizzle/0057) is per-repo: searchSemantic()
4-- only ranks rows where code_embeddings.repository_id matches one repo.
5-- This migration adds:
6--
7-- 1. users.personal_semantic_index_enabled — opt-in flag. Off by
8-- default. The personal cross-repo search refuses to run until the
9-- user explicitly enables it via /settings/personal-semantic-toggle.
10-- 2. personal_chats — user-scoped chat threads (no repository_id).
11-- Same shape as repo_chats but owner_user_id is the only scope key.
12-- 3. personal_chat_messages — mirrors repo_chat_messages exactly so
13-- the streaming + citations contract is shared.
14--
15-- All statements wrapped in DO blocks so the migration is idempotent and
16-- degrades cleanly on partial replays.
17
18--> statement-breakpoint
19DO $$
20BEGIN
21 ALTER TABLE "users"
22 ADD COLUMN IF NOT EXISTS "personal_semantic_index_enabled" boolean NOT NULL DEFAULT false;
23EXCEPTION WHEN OTHERS THEN
24 RAISE NOTICE 'users.personal_semantic_index_enabled add failed (%); cross-repo chat will be disabled', SQLERRM;
25END $$;
26
27--> statement-breakpoint
28DO $$
29BEGIN
30 CREATE TABLE IF NOT EXISTS "personal_chats" (
31 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
33 "title" text,
34 "created_at" timestamptz NOT NULL DEFAULT now(),
35 "updated_at" timestamptz NOT NULL DEFAULT now()
36 );
37EXCEPTION WHEN OTHERS THEN
38 RAISE NOTICE 'personal_chats create failed (%); personal chat will be unavailable', SQLERRM;
39END $$;
40
41--> statement-breakpoint
42DO $$
43BEGIN
44 CREATE INDEX IF NOT EXISTS "personal_chats_owner_updated"
45 ON "personal_chats" ("owner_user_id", "updated_at" DESC);
46EXCEPTION WHEN OTHERS THEN
47 RAISE NOTICE 'personal_chats_owner_updated index failed (%)', SQLERRM;
48END $$;
49
50--> statement-breakpoint
51DO $$
52BEGIN
53 CREATE TABLE IF NOT EXISTS "personal_chat_messages" (
54 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
55 "chat_id" uuid NOT NULL REFERENCES "personal_chats"("id") ON DELETE CASCADE,
56 "role" text NOT NULL,
57 "content" text NOT NULL,
58 "citations" jsonb NOT NULL DEFAULT '[]'::jsonb,
59 "token_cost" integer NOT NULL DEFAULT 0,
60 "created_at" timestamptz NOT NULL DEFAULT now()
61 );
62EXCEPTION WHEN OTHERS THEN
63 RAISE NOTICE 'personal_chat_messages create failed (%); personal chat will be unavailable', SQLERRM;
64END $$;
65
66--> statement-breakpoint
67DO $$
68BEGIN
69 CREATE INDEX IF NOT EXISTS "personal_chat_messages_chat_created"
70 ON "personal_chat_messages" ("chat_id", "created_at" ASC);
71EXCEPTION WHEN OTHERS THEN
72 RAISE NOTICE 'personal_chat_messages_chat_created index failed (%)', SQLERRM;
73END $$;
Addedsrc/__tests__/personal-semantic.test.ts+582−0View fileUnifiedSplit
@@ -0,0 +1,582 @@
1/**
2 * Tests for src/lib/personal-semantic.ts — cross-repo semantic search
3 * over the union of repos a user has access to.
4 *
5 * Layered:
6 *
7 * 1. Pure helpers / opt-in gate (no DB-specific shapes).
8 * Covered by short-circuit assertions on missing inputs.
9 *
10 * 2. DB-backed pipeline — gated on HAS_DB.
11 * Critical security tests live here:
12 * - Refusal when the opt-in flag is OFF (the privacy contract).
13 * - Owned-repo hits surface; non-owned repos that the user has
14 * no collaborator row for are NEVER surfaced.
15 * - Cross-user leak prevention: a hit indexed for user-B's
16 * private repo must not appear in user-A's results.
17 *
18 * The embedder seam from semantic-index is used to make vectors
19 * deterministic so the test asserts on file_path / repo_name shape rather
20 * than on cosine-score ordering (which depends on pgvector being
21 * available).
22 */
23
24import {
25 afterAll,
26 afterEach,
27 beforeAll,
28 beforeEach,
29 describe,
30 expect,
31 it,
32} from "bun:test";
33import { join } from "path";
34import { mkdir, rm } from "fs/promises";
35import { randomBytes } from "crypto";
36
37import {
38 isPersonalSemanticEnabled,
39 searchAcrossAllReposForUser,
40 searchPersonalSemantic,
41 setPersonalSemanticEnabled,
42} from "../lib/personal-semantic";
43import {
44 __setEmbedderForTests,
45 EMBEDDING_DIM,
46} from "../lib/semantic-index";
47import { initBareRepo, getRepoPath } from "../git/repository";
48
49const HAS_DB = Boolean(process.env.DATABASE_URL);
50
51const TEST_REPOS = join(
52 import.meta.dir,
53 "../../.test-repos-personal-semantic-" + Date.now()
54);
55
56beforeAll(async () => {
57 process.env.GIT_REPOS_PATH = TEST_REPOS;
58 process.env.GLUECRON_SEMANTIC_CACHE_DIR = join(TEST_REPOS, "_cache");
59 await rm(TEST_REPOS, { recursive: true, force: true });
60 await mkdir(TEST_REPOS, { recursive: true });
61});
62
63afterAll(async () => {
64 __setEmbedderForTests(null);
65 await rm(TEST_REPOS, { recursive: true, force: true });
66});
67
68beforeEach(() => {
69 __setEmbedderForTests(null);
70});
71
72afterEach(() => {
73 __setEmbedderForTests(null);
74});
75
76// ---------------------------------------------------------------------------
77// 1. Pure short-circuits — no DB.
78// ---------------------------------------------------------------------------
79
80describe("personal-semantic — short-circuits", () => {
81 it("returns [] for empty userId", async () => {
82 const out = await searchPersonalSemantic({ userId: "", query: "foo" });
83 expect(out).toEqual([]);
84 });
85
86 it("returns [] for empty query", async () => {
87 const out = await searchPersonalSemantic({
88 userId: "00000000-0000-0000-0000-000000000000",
89 query: "",
90 });
91 expect(out).toEqual([]);
92 });
93
94 it("alias searchAcrossAllReposForUser is identical contract", async () => {
95 const out = await searchAcrossAllReposForUser({
96 userId: "",
97 query: "foo",
98 });
99 expect(out).toEqual([]);
100 });
101
102 it("isPersonalSemanticEnabled returns false for empty id", async () => {
103 expect(await isPersonalSemanticEnabled("")).toBe(false);
104 });
105});
106
107// ---------------------------------------------------------------------------
108// 2. DB-backed pipeline.
109// ---------------------------------------------------------------------------
110
111describe.skipIf(!HAS_DB)("personal-semantic — DB-backed", () => {
112 it.skipIf(!HAS_DB)(
113 "refuses to return rows when the opt-in flag is OFF",
114 async () => {
115 const { db } = await import("../db");
116 const { users, repositories, codeEmbeddings } = await import(
117 "../db/schema"
118 );
119 const { eq } = await import("drizzle-orm");
120
121 const stamp = randomBytes(4).toString("hex");
122 const username = `psem-off-${stamp}`;
123 const reponame = `psem-off-${stamp}`;
124
125 const [u] = await db
126 .insert(users)
127 .values({
128 username,
129 email: `${username}@test.local`,
130 passwordHash: "x",
131 personalSemanticIndexEnabled: false,
132 })
133 .returning();
134 if (!u) return;
135
136 await initBareRepo(username, reponame);
137 const [r] = await db
138 .insert(repositories)
139 .values({
140 name: reponame,
141 ownerId: u.id,
142 diskPath: getRepoPath(username, reponame),
143 defaultBranch: "main",
144 })
145 .returning();
146 if (!r) return;
147
148 // Plant an embedding row; with opt-in OFF the search must not
149 // surface it even though the user owns the repo.
150 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
151 fakeVec[0] = 1;
152 try {
153 await db.insert(codeEmbeddings).values({
154 repositoryId: r.id,
155 filePath: "src/secret.ts",
156 blobSha: "aa11",
157 commitSha: "aa11",
158 contentSnippet: "// SECRET KEY",
159 embedding: fakeVec,
160 embeddingModel: "stub",
161 });
162 } catch {
163 /* pgvector may not exist; the opt-in refusal is still meaningful
164 because it short-circuits BEFORE the cosine ORDER BY runs. */
165 }
166
167 __setEmbedderForTests(async () => ({
168 vector: fakeVec,
169 model: "stub-1024",
170 }));
171
172 const hits = await searchPersonalSemantic({
173 userId: u.id,
174 query: "secret",
175 });
176 expect(hits).toEqual([]);
177
178 // Now flip on and verify the hit does surface — proves the refusal
179 // above is the flag, not a side-effect of missing data.
180 await setPersonalSemanticEnabled(u.id, true);
181 const after = await searchPersonalSemantic({
182 userId: u.id,
183 query: "secret",
184 });
185 // pgvector may not be installed; allow either an empty result or
186 // the planted row. The point of THIS assertion is that we no
187 // longer SHORT-CIRCUIT — DB-level behaviour is allowed to be empty.
188 expect(Array.isArray(after)).toBe(true);
189 if (after.length) {
190 expect(after[0].repoName).toBe(`${username}/${reponame}`);
191 expect(after[0].filePath).toBe("src/secret.ts");
192 }
193
194 // Cleanup.
195 try {
196 await db
197 .delete(codeEmbeddings)
198 .where(eq(codeEmbeddings.repositoryId, r.id));
199 } catch {
200 /* may not exist */
201 }
202 await db.delete(repositories).where(eq(repositories.id, r.id));
203 await db.delete(users).where(eq(users.id, u.id));
204 }
205 );
206
207 it.skipIf(!HAS_DB)(
208 "search returns results across multiple owned repos",
209 async () => {
210 const { db } = await import("../db");
211 const { users, repositories, codeEmbeddings } = await import(
212 "../db/schema"
213 );
214 const { eq, inArray } = await import("drizzle-orm");
215
216 const stamp = randomBytes(4).toString("hex");
217 const username = `psem-multi-${stamp}`;
218
219 const [u] = await db
220 .insert(users)
221 .values({
222 username,
223 email: `${username}@test.local`,
224 passwordHash: "x",
225 personalSemanticIndexEnabled: true,
226 })
227 .returning();
228 if (!u) return;
229
230 const repoA = `psem-multi-a-${stamp}`;
231 const repoB = `psem-multi-b-${stamp}`;
232 await initBareRepo(username, repoA);
233 await initBareRepo(username, repoB);
234 const [rA] = await db
235 .insert(repositories)
236 .values({
237 name: repoA,
238 ownerId: u.id,
239 diskPath: getRepoPath(username, repoA),
240 defaultBranch: "main",
241 })
242 .returning();
243 const [rB] = await db
244 .insert(repositories)
245 .values({
246 name: repoB,
247 ownerId: u.id,
248 diskPath: getRepoPath(username, repoB),
249 defaultBranch: "main",
250 })
251 .returning();
252 if (!rA || !rB) return;
253
254 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
255 fakeVec[0] = 1;
256 let inserted = false;
257 try {
258 await db.insert(codeEmbeddings).values([
259 {
260 repositoryId: rA.id,
261 filePath: "src/a.ts",
262 blobSha: "aaa1",
263 commitSha: "aaa1",
264 contentSnippet: "// repo a snippet",
265 embedding: fakeVec,
266 embeddingModel: "stub",
267 },
268 {
269 repositoryId: rB.id,
270 filePath: "src/b.ts",
271 blobSha: "bbb1",
272 commitSha: "bbb1",
273 contentSnippet: "// repo b snippet",
274 embedding: fakeVec,
275 embeddingModel: "stub",
276 },
277 ]);
278 inserted = true;
279 } catch {
280 /* pgvector missing — we'll skip the surface assertion below */
281 }
282
283 __setEmbedderForTests(async () => ({
284 vector: fakeVec,
285 model: "stub-1024",
286 }));
287
288 const hits = await searchPersonalSemantic({
289 userId: u.id,
290 query: "snippet",
291 });
292 expect(Array.isArray(hits)).toBe(true);
293
294 // When pgvector is available, both repos should show up — they're
295 // both owned by the user, so the union contains both.
296 if (inserted && hits.length) {
297 const repoNames = new Set(hits.map((h) => h.repoName));
298 expect(repoNames.size).toBeGreaterThanOrEqual(1);
299 for (const h of hits) {
300 expect(typeof h.repoName).toBe("string");
301 expect(h.repoName.startsWith(`${username}/`)).toBe(true);
302 }
303 }
304
305 // Cleanup.
306 try {
307 await db
308 .delete(codeEmbeddings)
309 .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id]));
310 } catch {
311 /* may not exist */
312 }
313 await db
314 .delete(repositories)
315 .where(inArray(repositories.id, [rA.id, rB.id]));
316 await db.delete(users).where(eq(users.id, u.id));
317 }
318 );
319
320 it.skipIf(!HAS_DB)(
321 "search EXCLUDES repos the user has no access to",
322 async () => {
323 const { db } = await import("../db");
324 const { users, repositories, codeEmbeddings } = await import(
325 "../db/schema"
326 );
327 const { eq, inArray } = await import("drizzle-orm");
328
329 const stamp = randomBytes(4).toString("hex");
330 const userA = `psem-exclA-${stamp}`;
331 const userB = `psem-exclB-${stamp}`;
332
333 const [uA] = await db
334 .insert(users)
335 .values({
336 username: userA,
337 email: `${userA}@test.local`,
338 passwordHash: "x",
339 personalSemanticIndexEnabled: true,
340 })
341 .returning();
342 const [uB] = await db
343 .insert(users)
344 .values({
345 username: userB,
346 email: `${userB}@test.local`,
347 passwordHash: "x",
348 })
349 .returning();
350 if (!uA || !uB) return;
351
352 // User B owns a private repo. User A has NO collaborator row for it.
353 const repoB = `psem-excl-${stamp}`;
354 await initBareRepo(userB, repoB);
355 const [rB] = await db
356 .insert(repositories)
357 .values({
358 name: repoB,
359 ownerId: uB.id,
360 diskPath: getRepoPath(userB, repoB),
361 defaultBranch: "main",
362 isPrivate: true,
363 })
364 .returning();
365 if (!rB) return;
366
367 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
368 fakeVec[0] = 1;
369 try {
370 await db.insert(codeEmbeddings).values({
371 repositoryId: rB.id,
372 filePath: "src/foreign.ts",
373 blobSha: "cccc",
374 commitSha: "cccc",
375 contentSnippet: "// USER B's PRIVATE CODE",
376 embedding: fakeVec,
377 embeddingModel: "stub",
378 });
379 } catch {
380 /* pgvector missing — refusal must still hold via the IN ([]) path */
381 }
382
383 __setEmbedderForTests(async () => ({
384 vector: fakeVec,
385 model: "stub-1024",
386 }));
387
388 // User A has no repos of their own and no collaborator rows.
389 // The accessible-repo-set must be empty, so search returns [] without
390 // EVER consulting User B's embedding row.
391 const hits = await searchPersonalSemantic({
392 userId: uA.id,
393 query: "private",
394 });
395 expect(hits).toEqual([]);
396
397 // Even if User A asks for the exact snippet text, the WHERE clause
398 // gates them out.
399 const hits2 = await searchPersonalSemantic({
400 userId: uA.id,
401 query: "USER B's PRIVATE CODE",
402 });
403 expect(hits2).toEqual([]);
404
405 // Cleanup.
406 try {
407 await db
408 .delete(codeEmbeddings)
409 .where(inArray(codeEmbeddings.repositoryId, [rB.id]));
410 } catch {
411 /* may not exist */
412 }
413 await db.delete(repositories).where(eq(repositories.id, rB.id));
414 await db.delete(users).where(eq(users.id, uA.id));
415 await db.delete(users).where(eq(users.id, uB.id));
416 }
417 );
418
419 it.skipIf(!HAS_DB)(
420 "cross-user content leak prevention — User A's results never contain User B's repo data",
421 async () => {
422 // This test is the load-bearing security assertion. We give User A
423 // one owned repo with data, and User B one separate owned repo with
424 // distinctively different data. Both have the opt-in flag on. A's
425 // search must only see A's repo; B's results must not appear in A's.
426 const { db } = await import("../db");
427 const { users, repositories, codeEmbeddings } = await import(
428 "../db/schema"
429 );
430 const { eq, inArray } = await import("drizzle-orm");
431
432 const stamp = randomBytes(4).toString("hex");
433 const userA = `psem-leakA-${stamp}`;
434 const userB = `psem-leakB-${stamp}`;
435
436 const [uA] = await db
437 .insert(users)
438 .values({
439 username: userA,
440 email: `${userA}@test.local`,
441 passwordHash: "x",
442 personalSemanticIndexEnabled: true,
443 })
444 .returning();
445 const [uB] = await db
446 .insert(users)
447 .values({
448 username: userB,
449 email: `${userB}@test.local`,
450 passwordHash: "x",
451 personalSemanticIndexEnabled: true,
452 })
453 .returning();
454 if (!uA || !uB) return;
455
456 const repoA = `psem-leak-a-${stamp}`;
457 const repoB = `psem-leak-b-${stamp}`;
458 await initBareRepo(userA, repoA);
459 await initBareRepo(userB, repoB);
460 const [rA] = await db
461 .insert(repositories)
462 .values({
463 name: repoA,
464 ownerId: uA.id,
465 diskPath: getRepoPath(userA, repoA),
466 defaultBranch: "main",
467 })
468 .returning();
469 const [rB] = await db
470 .insert(repositories)
471 .values({
472 name: repoB,
473 ownerId: uB.id,
474 diskPath: getRepoPath(userB, repoB),
475 defaultBranch: "main",
476 })
477 .returning();
478 if (!rA || !rB) return;
479
480 const fakeVec = new Array<number>(EMBEDDING_DIM).fill(0);
481 fakeVec[0] = 1;
482 try {
483 await db.insert(codeEmbeddings).values([
484 {
485 repositoryId: rA.id,
486 filePath: "src/owned-by-a.ts",
487 blobSha: "aaaa",
488 commitSha: "aaaa",
489 contentSnippet: "// A's code",
490 embedding: fakeVec,
491 embeddingModel: "stub",
492 },
493 {
494 repositoryId: rB.id,
495 filePath: "src/owned-by-b.ts",
496 blobSha: "bbbb",
497 commitSha: "bbbb",
498 contentSnippet: "// B's code",
499 embedding: fakeVec,
500 embeddingModel: "stub",
501 },
502 ]);
503 } catch {
504 /* pgvector missing — the next assertions still hold via the
505 empty-repo-set short-circuit / IN clause */
506 }
507
508 __setEmbedderForTests(async () => ({
509 vector: fakeVec,
510 model: "stub-1024",
511 }));
512
513 const aHits = await searchPersonalSemantic({
514 userId: uA.id,
515 query: "code",
516 });
517 // Every hit must be from a repo A owns. The file owned-by-b.ts
518 // must never appear; the repo name must never include userB.
519 for (const h of aHits) {
520 expect(h.ownerName).toBe(userA);
521 expect(h.repoName).toBe(`${userA}/${repoA}`);
522 expect(h.filePath).not.toBe("src/owned-by-b.ts");
523 }
524
525 const bHits = await searchPersonalSemantic({
526 userId: uB.id,
527 query: "code",
528 });
529 for (const h of bHits) {
530 expect(h.ownerName).toBe(userB);
531 expect(h.repoName).toBe(`${userB}/${repoB}`);
532 expect(h.filePath).not.toBe("src/owned-by-a.ts");
533 }
534
535 // Cleanup.
536 try {
537 await db
538 .delete(codeEmbeddings)
539 .where(inArray(codeEmbeddings.repositoryId, [rA.id, rB.id]));
540 } catch {
541 /* may not exist */
542 }
543 await db
544 .delete(repositories)
545 .where(inArray(repositories.id, [rA.id, rB.id]));
546 await db.delete(users).where(eq(users.id, uA.id));
547 await db.delete(users).where(eq(users.id, uB.id));
548 }
549 );
550
551 it.skipIf(!HAS_DB)(
552 "setPersonalSemanticEnabled flips the flag and isPersonalSemanticEnabled reads it",
553 async () => {
554 const { db } = await import("../db");
555 const { users } = await import("../db/schema");
556 const { eq } = await import("drizzle-orm");
557
558 const stamp = randomBytes(4).toString("hex");
559 const username = `psem-flag-${stamp}`;
560 const [u] = await db
561 .insert(users)
562 .values({
563 username,
564 email: `${username}@test.local`,
565 passwordHash: "x",
566 })
567 .returning();
568 if (!u) return;
569
570 expect(await isPersonalSemanticEnabled(u.id)).toBe(false);
571 const r1 = await setPersonalSemanticEnabled(u.id, true);
572 expect(r1).toBe(true);
573 expect(await isPersonalSemanticEnabled(u.id)).toBe(true);
574
575 const r2 = await setPersonalSemanticEnabled(u.id, false);
576 expect(r2).toBe(false);
577 expect(await isPersonalSemanticEnabled(u.id)).toBe(false);
578
579 await db.delete(users).where(eq(users.id, u.id));
580 }
581 );
582});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -97,6 +97,7 @@ import aiExplainRoutes from "./routes/ai-explain";
9797import aiTestsRoutes from "./routes/ai-tests";
9898import askRoutes from "./routes/ask";
9999import repoChatRoutes from "./routes/repo-chat";
100import personalChatRoutes from "./routes/personal-chat";
100101import billingRoutes from "./routes/billing";
101102import billingUsageRoutes from "./routes/billing-usage";
102103import stripeWebhookRoutes from "./routes/stripe-webhook";
@@ -566,6 +567,9 @@ app.route("/", aiExplainRoutes);
566567app.route("/", aiTestsRoutes);
567568app.route("/", askRoutes);
568569app.route("/", repoChatRoutes);
570// Personal cross-repo chat — `/chat` (user-scoped). Mounted alongside
571// repoChatRoutes so the two surfaces share the catch-all priority.
572app.route("/", personalChatRoutes);
569573app.route("/", billingRoutes);
570574app.route("/", billingUsageRoutes);
571575app.route("/", stripeWebhookRoutes);
Modifiedsrc/db/schema.ts+72−0View fileUnifiedSplit
@@ -114,6 +114,15 @@ export const users = pgTable("users", {
114114 // See drizzle/0052_playground_accounts.sql.
115115 isPlayground: boolean("is_playground").default(false).notNull(),
116116 playgroundExpiresAt: timestamp("playground_expires_at"),
117 // Migration 0071 — Personal cross-repo semantic index opt-in. When true,
118 // /chat (and the /api/v2/me/chat/messages endpoint) may search across
119 // every repo the user owns OR is an accepted collaborator on. Default
120 // OFF — privacy-first; the user must explicitly enable it via
121 // /settings/personal-semantic-toggle. The personal-semantic helpers
122 // hard-refuse to return any rows while this flag is false.
123 personalSemanticIndexEnabled: boolean("personal_semantic_index_enabled")
124 .default(false)
125 .notNull(),
117126 createdAt: timestamp("created_at").defaultNow().notNull(),
118127 updatedAt: timestamp("updated_at").defaultNow().notNull(),
119128});
@@ -3197,6 +3206,69 @@ export type NewRepoChat = typeof repoChats.$inferInsert;
31973206export type RepoChatMessage = typeof repoChatMessages.$inferSelect;
31983207export type NewRepoChatMessage = typeof repoChatMessages.$inferInsert;
31993208
3209// ---------------------------------------------------------------------------
3210// 0071 — Personal cross-repo chat. Same shape as repoChats/repoChatMessages
3211// but user-scoped, not repo-scoped. The retrieval layer
3212// (src/lib/personal-semantic.ts) runs searchSemantic across the union of
3213// repos the owner has access to, then attaches a repo_name annotation to
3214// each citation. Gated on users.personalSemanticIndexEnabled.
3215// ---------------------------------------------------------------------------
3216export const personalChats = pgTable(
3217 "personal_chats",
3218 {
3219 id: uuid("id").primaryKey().defaultRandom(),
3220 ownerUserId: uuid("owner_user_id")
3221 .notNull()
3222 .references(() => users.id, { onDelete: "cascade" }),
3223 title: text("title"),
3224 createdAt: timestamp("created_at", { withTimezone: true })
3225 .defaultNow()
3226 .notNull(),
3227 updatedAt: timestamp("updated_at", { withTimezone: true })
3228 .defaultNow()
3229 .notNull(),
3230 },
3231 (table) => [
3232 index("personal_chats_owner_updated").on(
3233 table.ownerUserId,
3234 table.updatedAt
3235 ),
3236 ]
3237);
3238
3239export const personalChatMessages = pgTable(
3240 "personal_chat_messages",
3241 {
3242 id: uuid("id").primaryKey().defaultRandom(),
3243 chatId: uuid("chat_id")
3244 .notNull()
3245 .references(() => personalChats.id, { onDelete: "cascade" }),
3246 role: text("role").notNull(),
3247 content: text("content").notNull(),
3248 citations: jsonb("citations")
3249 .$type<
3250 Array<{ file_path: string; blob_sha: string; repo_name: string }>
3251 >()
3252 .notNull()
3253 .default([]),
3254 tokenCost: integer("token_cost").notNull().default(0),
3255 createdAt: timestamp("created_at", { withTimezone: true })
3256 .defaultNow()
3257 .notNull(),
3258 },
3259 (table) => [
3260 index("personal_chat_messages_chat_created").on(
3261 table.chatId,
3262 table.createdAt
3263 ),
3264 ]
3265);
3266
3267export type PersonalChat = typeof personalChats.$inferSelect;
3268export type NewPersonalChat = typeof personalChats.$inferInsert;
3269export type PersonalChatMessage = typeof personalChatMessages.$inferSelect;
3270export type NewPersonalChatMessage = typeof personalChatMessages.$inferInsert;
3271
32003272// ---------------------------------------------------------------------------
32013273// 0061 — Live co-editing on PRs. See src/lib/pr-live.ts.
32023274// ---------------------------------------------------------------------------
Addedsrc/lib/personal-chat.ts+456−0View fileUnifiedSplit
@@ -0,0 +1,456 @@
1/**
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};
Addedsrc/lib/personal-semantic.ts+326−0View fileUnifiedSplit
@@ -0,0 +1,326 @@
1/**
2 * Personal cross-repo semantic search — the user-scoped sibling of
3 * `src/lib/semantic-index.ts`.
4 *
5 * Today the per-repo `searchSemantic` ranks `code_embeddings` rows by a
6 * single `repository_id`. This module unions the user's accessible repos
7 * (owned + accepted collaborator rows) and runs the cosine-rank across
8 * the entire union, annotating each hit with the source `repo_name` so
9 * the chat surface can show "/owner/repo · path" alongside the citation.
10 *
11 * Privacy rules (hard, no exceptions):
12 *
13 * - The function refuses to return any rows unless the user has flipped
14 * `users.personal_semantic_index_enabled = true`. The toggle is the
15 * contract between the user and the platform: while it's off we
16 * don't touch their data at all from this surface.
17 * - The set of repo IDs is recomputed on every call. We never cache
18 * it — a collaborator could be removed between requests and we want
19 * that decision to take immediate effect.
20 * - The Postgres `WHERE repository_id = ANY($repoIds)` clause is the
21 * boundary. If the union is empty (no owned repos, no accepted
22 * collaborator rows), we short-circuit to [] without hitting the
23 * embeddings table — cheap, and means a fresh user with no repos
24 * can't accidentally surface another user's data through any kind
25 * of overflow / fall-through.
26 *
27 * Failure modes:
28 *
29 * - DB missing → [] (every catch swallows + logs at DEBUG level only).
30 * - pgvector missing → searchSemantic-style empty list (the cosine
31 * ORDER BY raises, the catch returns []).
32 * - Embedder unavailable → fallback hash embed (same as semantic-index).
33 *
34 * Test seam:
35 *
36 * - Embedder override comes from `__setEmbedderForTests` on
37 * semantic-index — we deliberately don't add a second seam here so
38 * the two paths share the same deterministic vector source.
39 */
40
41import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
42import { db } from "../db";
43import {
44 codeEmbeddings,
45 repoCollaborators,
46 repositories,
47 users,
48} from "../db/schema";
49import { embedOne, EMBEDDING_DIM } from "./semantic-index";
50
51// ---------------------------------------------------------------------------
52// Types
53// ---------------------------------------------------------------------------
54
55export interface PersonalSemanticHit {
56 /** Path within the repo (e.g. `src/lib/foo.ts`). */
57 filePath: string;
58 /** The cached snippet from `code_embeddings.content_snippet`. */
59 snippet: string;
60 /** Cosine similarity, higher = closer (range 0..1 inclusive). */
61 score: number;
62 /** Blob SHA captured at indexing time. */
63 blobSha: string;
64 /** Source repository UUID — useful for downstream getBlob lookups. */
65 repositoryId: string;
66 /** "owner/name" — what citations render in the UI. */
67 repoName: string;
68 /** Owner username (left half of the slug). */
69 ownerName: string;
70}
71
72export interface PersonalSearchOpts {
73 userId: string;
74 query: string;
75 limit?: number;
76}
77
78// ---------------------------------------------------------------------------
79// Repo enumeration — owned + accepted collaborator rows.
80// ---------------------------------------------------------------------------
81
82interface RepoMeta {
83 id: string;
84 /** `owner/name` slug. */
85 fullName: string;
86 ownerName: string;
87}
88
89/**
90 * Resolve every repo this user has access to. Owned repos (any visibility)
91 * plus accepted collaborator rows. Returns a {repoId → meta} map keyed by
92 * repoId for cheap downstream annotation.
93 *
94 * Anyone touching this function: keep the access logic identical to
95 * `src/middleware/repo-access.ts::resolveRepoAccess`. The whole point of
96 * the personal search is to surface what the user already has read access
97 * to — drifting the set here would either over-share (leak) or under-share
98 * (confusing UX). Treat it like a security boundary.
99 */
100async function listAccessibleRepoIds(
101 userId: string
102): Promise<Map<string, RepoMeta>> {
103 const out = new Map<string, RepoMeta>();
104 if (!userId) return out;
105
106 // Owned repos. We union via two SELECTs rather than a single OR-join so
107 // each side can fail independently (e.g. repo_collaborators may not exist
108 // on a fresh DB without the 0040 migration).
109 try {
110 const owned = await db
111 .select({
112 id: repositories.id,
113 name: repositories.name,
114 ownerUsername: users.username,
115 })
116 .from(repositories)
117 .innerJoin(users, eq(repositories.ownerId, users.id))
118 .where(eq(repositories.ownerId, userId));
119 for (const row of owned) {
120 out.set(row.id, {
121 id: row.id,
122 fullName: `${row.ownerUsername}/${row.name}`,
123 ownerName: row.ownerUsername,
124 });
125 }
126 } catch (err) {
127 if (process.env.DEBUG_PERSONAL_SEMANTIC === "1") {
128 console.warn("[personal-semantic] owned-repos lookup failed:", err);
129 }
130 }
131
132 // Accepted collaborator rows. acceptedAt IS NOT NULL — pending invites
133 // do NOT grant access, matching repo-access middleware exactly.
134 try {
135 const collab = await db
136 .select({
137 id: repositories.id,
138 name: repositories.name,
139 ownerUsername: users.username,
140 })
141 .from(repoCollaborators)
142 .innerJoin(repositories, eq(repoCollaborators.repositoryId, repositories.id))
143 .innerJoin(users, eq(repositories.ownerId, users.id))
144 .where(
145 and(
146 eq(repoCollaborators.userId, userId),
147 isNotNull(repoCollaborators.acceptedAt)
148 )
149 );
150 for (const row of collab) {
151 // Don't overwrite an owner row with a collab row (cheaper to skip
152 // than to compare). The two sets are usually disjoint anyway.
153 if (!out.has(row.id)) {
154 out.set(row.id, {
155 id: row.id,
156 fullName: `${row.ownerUsername}/${row.name}`,
157 ownerName: row.ownerUsername,
158 });
159 }
160 }
161 } catch (err) {
162 if (process.env.DEBUG_PERSONAL_SEMANTIC === "1") {
163 console.warn("[personal-semantic] collaborator lookup failed:", err);
164 }
165 }
166
167 return out;
168}
169
170// ---------------------------------------------------------------------------
171// Opt-in gate
172// ---------------------------------------------------------------------------
173
174/**
175 * Read the user's opt-in flag. Returns false on any error so the privacy
176 * default ("off") is enforced even when the DB is unhappy.
177 */
178export async function isPersonalSemanticEnabled(
179 userId: string
180): Promise<boolean> {
181 if (!userId) return false;
182 try {
183 const [row] = await db
184 .select({ enabled: users.personalSemanticIndexEnabled })
185 .from(users)
186 .where(eq(users.id, userId))
187 .limit(1);
188 return !!row?.enabled;
189 } catch {
190 return false;
191 }
192}
193
194/**
195 * Flip the opt-in flag. Returns the new value on success, null on failure.
196 * Callers should write an audit log entry separately (`ai.personal.toggle`).
197 */
198export async function setPersonalSemanticEnabled(
199 userId: string,
200 enabled: boolean
201): Promise<boolean | null> {
202 if (!userId) return null;
203 try {
204 await db
205 .update(users)
206 .set({ personalSemanticIndexEnabled: enabled, updatedAt: new Date() })
207 .where(eq(users.id, userId));
208 return enabled;
209 } catch (err) {
210 if (process.env.DEBUG_PERSONAL_SEMANTIC === "1") {
211 console.warn("[personal-semantic] toggle write failed:", err);
212 }
213 return null;
214 }
215}
216
217// ---------------------------------------------------------------------------
218// Search
219// ---------------------------------------------------------------------------
220
221/**
222 * Cosine-rank the query across every repo the user has access to. Returns
223 * the top `limit` hits annotated with their source repo name.
224 *
225 * Privacy contract:
226 *
227 * - Refuses (returns []) unless `users.personal_semantic_index_enabled`
228 * is true for the user.
229 * - Never returns rows whose `repository_id` isn't in the user's
230 * accessible set — even if pgvector returns them, the `IN (...)`
231 * clause filters them out.
232 * - Returns [] for non-UUID / empty userIds without DB access.
233 */
234export async function searchPersonalSemantic(
235 opts: PersonalSearchOpts
236): Promise<PersonalSemanticHit[]> {
237 const { userId, query } = opts;
238 const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
239 const q = (query || "").trim();
240 if (!q || !userId) return [];
241
242 // 1. Hard opt-in gate. The audit log is owned by the caller — we just
243 // refuse here without logging so this function stays cheap to call.
244 const enabled = await isPersonalSemanticEnabled(userId);
245 if (!enabled) return [];
246
247 // 2. Recompute the accessible repo set on every call. A collaborator
248 // removed between requests must lose visibility immediately.
249 const accessible = await listAccessibleRepoIds(userId);
250 if (accessible.size === 0) return [];
251 const repoIds = Array.from(accessible.keys());
252
253 // 3. Embed the query in the same vector space as the indexed rows.
254 let queryVec: number[];
255 try {
256 const out = await embedOne(q, "query");
257 queryVec = out.vector;
258 } catch {
259 return [];
260 }
261 if (!queryVec || queryVec.length !== EMBEDDING_DIM) return [];
262 const vecLit = "[" + queryVec.join(",") + "]";
263
264 // 4. Cosine-rank across the union. The pgvector `<=>` operator is
265 // cosine distance (lower = closer); we surface the similarity
266 // (1 - distance) so the contract matches searchSemantic.
267 try {
268 const rows = await db
269 .select({
270 repositoryId: codeEmbeddings.repositoryId,
271 filePath: codeEmbeddings.filePath,
272 snippet: codeEmbeddings.contentSnippet,
273 blobSha: codeEmbeddings.blobSha,
274 score: sql<number>`1 - (${codeEmbeddings.embedding} <=> ${vecLit}::vector)`,
275 })
276 .from(codeEmbeddings)
277 .where(inArray(codeEmbeddings.repositoryId, repoIds))
278 .orderBy(sql`${codeEmbeddings.embedding} <=> ${vecLit}::vector`)
279 .limit(limit);
280
281 return rows.flatMap((r) => {
282 const meta = accessible.get(r.repositoryId);
283 if (!meta) {
284 // Defensive: if the row's repo somehow isn't in our access set,
285 // drop it. This should be impossible given the WHERE clause, but
286 // never trust a DB result with a privacy-critical filter.
287 return [] as PersonalSemanticHit[];
288 }
289 return [
290 {
291 repositoryId: r.repositoryId,
292 filePath: r.filePath,
293 snippet: r.snippet || "",
294 score:
295 typeof r.score === "number" ? r.score : Number(r.score) || 0,
296 blobSha: r.blobSha,
297 repoName: meta.fullName,
298 ownerName: meta.ownerName,
299 },
300 ];
301 });
302 } catch (err) {
303 if (process.env.DEBUG_PERSONAL_SEMANTIC === "1") {
304 console.warn("[personal-semantic] search failed:", err);
305 }
306 return [];
307 }
308}
309
310/**
311 * Convenience alias — same as `searchPersonalSemantic`. Some call sites
312 * read more naturally with the descriptive name.
313 */
314export async function searchAcrossAllReposForUser(
315 opts: PersonalSearchOpts
316): Promise<PersonalSemanticHit[]> {
317 return searchPersonalSemantic(opts);
318}
319
320// ---------------------------------------------------------------------------
321// Test-only exports
322// ---------------------------------------------------------------------------
323
324export const __test = {
325 listAccessibleRepoIds,
326};
Modifiedsrc/routes/api-v2.ts+158−0View fileUnifiedSplit
@@ -927,6 +927,164 @@ apiv2.post(
927927 }
928928);
929929
930// ─── Personal cross-repo chat — streaming SSE ───────────────────────────────
931//
932// POST /api/v2/me/chat/messages
933//
934// Body (application/json): { chat_id?: string, message: string }
935//
936// Behaviour mirrors /repos/:owner/:repo/chat/messages but the retrieval
937// layer crosses every repo the user has access to instead of being pinned
938// to a single repo. Hard refusal if
939// `users.personal_semantic_index_enabled` is false; in that case we
940// return 403 rather than streaming an empty-context reply.
941//
942// Per-message audit log: `ai.personal.chat` — privacy requirement.
943
944apiv2.post(
945 "/me/chat/messages",
946 requireApiAuth,
947 requireScope("repo"),
948 async (c) => {
949 const user = c.get("user")!;
950
951 let body: { chat_id?: string; message?: string };
952 try {
953 body = (await c.req.json()) as { chat_id?: string; message?: string };
954 } catch {
955 return c.json({ error: "Invalid JSON body" }, 400);
956 }
957
958 const userMessage = String(body?.message || "").trim();
959 if (!userMessage) {
960 return c.json({ error: "message is required" }, 400);
961 }
962
963 // Hard opt-in gate — refuse before even touching the chat tables.
964 const { isPersonalSemanticEnabled } = await import(
965 "../lib/personal-semantic"
966 );
967 const enabled = await isPersonalSemanticEnabled(user.id);
968 if (!enabled) {
969 return c.json(
970 {
971 error:
972 "Personal cross-repo chat is disabled. Enable it at /chat (the opt-in toggle).",
973 },
974 403
975 );
976 }
977
978 const {
979 appendPersonalUserMessage,
980 createPersonalChat,
981 getPersonalChatForUser,
982 streamPersonalAssistantReply,
983 } = await import("../lib/personal-chat");
984
985 let chatId = String(body?.chat_id || "").trim();
986 if (!chatId) {
987 const created = await createPersonalChat({
988 ownerUserId: user.id,
989 title: userMessage.slice(0, 80),
990 });
991 if (!created) {
992 return c.json({ error: "Failed to create chat" }, 500);
993 }
994 chatId = created.id;
995 } else {
996 const existing = await getPersonalChatForUser(chatId, user.id);
997 if (!existing) {
998 return c.json({ error: "Not found" }, 404);
999 }
1000 }
1001
1002 await appendPersonalUserMessage(chatId, userMessage);
1003
1004 // Audit log — one entry per personal chat message.
1005 try {
1006 const { audit } = await import("../lib/notify");
1007 void audit({
1008 userId: user.id,
1009 action: "ai.personal.chat",
1010 targetType: "personal_chat",
1011 targetId: chatId,
1012 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip"),
1013 userAgent: c.req.header("user-agent"),
1014 metadata: { surface: "api" },
1015 });
1016 } catch {
1017 /* best-effort */
1018 }
1019
1020 const encoder = new TextEncoder();
1021 const stream = new ReadableStream<Uint8Array>({
1022 async start(controller) {
1023 let closed = false;
1024 const safeEnqueue = (chunk: string) => {
1025 if (closed) return;
1026 try {
1027 controller.enqueue(encoder.encode(chunk));
1028 } catch {
1029 closed = true;
1030 }
1031 };
1032 const sseEvent = (event: string, data: string) => {
1033 let payload = `event: ${event}\n`;
1034 for (const line of data.split("\n")) {
1035 payload += `data: ${line}\n`;
1036 }
1037 payload += "\n";
1038 safeEnqueue(payload);
1039 };
1040
1041 safeEnqueue(": open\n\n");
1042
1043 try {
1044 const stored = await streamPersonalAssistantReply({
1045 chatId,
1046 userId: user.id,
1047 userMessage,
1048 onChunk: (chunk) => {
1049 sseEvent("token", chunk);
1050 },
1051 });
1052
1053 sseEvent(
1054 "done",
1055 JSON.stringify({
1056 chat_id: chatId,
1057 message_id: stored?.id || null,
1058 citations: stored?.citations ?? [],
1059 token_cost: stored?.tokenCost ?? 0,
1060 })
1061 );
1062 } catch (err) {
1063 const msg = err instanceof Error ? err.message : "unknown error";
1064 sseEvent("error", JSON.stringify({ error: msg }));
1065 } finally {
1066 closed = true;
1067 try {
1068 controller.close();
1069 } catch {
1070 /* already closed */
1071 }
1072 }
1073 },
1074 });
1075
1076 return new Response(stream, {
1077 status: 200,
1078 headers: {
1079 "Content-Type": "text/event-stream; charset=utf-8",
1080 "Cache-Control": "no-cache, no-transform",
1081 Connection: "keep-alive",
1082 "X-Accel-Buffering": "no",
1083 },
1084 });
1085 }
1086);
1087
9301088// ─── Issues ─────────────────────────────────────────────────────────────────
9311089
9321090apiv2.get("/repos/:owner/:repo/issues", async (c) => {
Addedsrc/routes/personal-chat.tsx+1136−0View fileUnifiedSplit
Large file (1,137 lines). Load full file