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-semantic.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-semantic.tsBlame326 lines · 1 contributor
ee7e577Claude1/**
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};