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.
| ee7e577 | 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 | ||
| 41 | import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; | |
| 42 | import { db } from "../db"; | |
| 43 | import { | |
| 44 | codeEmbeddings, | |
| 45 | repoCollaborators, | |
| 46 | repositories, | |
| 47 | users, | |
| 48 | } from "../db/schema"; | |
| 49 | import { embedOne, EMBEDDING_DIM } from "./semantic-index"; | |
| 50 | ||
| 51 | // --------------------------------------------------------------------------- | |
| 52 | // Types | |
| 53 | // --------------------------------------------------------------------------- | |
| 54 | ||
| 55 | export 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 | ||
| 72 | export interface PersonalSearchOpts { | |
| 73 | userId: string; | |
| 74 | query: string; | |
| 75 | limit?: number; | |
| 76 | } | |
| 77 | ||
| 78 | // --------------------------------------------------------------------------- | |
| 79 | // Repo enumeration — owned + accepted collaborator rows. | |
| 80 | // --------------------------------------------------------------------------- | |
| 81 | ||
| 82 | interface 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 | */ | |
| 100 | async 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 | */ | |
| 178 | export 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 | */ | |
| 198 | export 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 | */ | |
| 234 | export 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 | */ | |
| 314 | export async function searchAcrossAllReposForUser( | |
| 315 | opts: PersonalSearchOpts | |
| 316 | ): Promise<PersonalSemanticHit[]> { | |
| 317 | return searchPersonalSemantic(opts); | |
| 318 | } | |
| 319 | ||
| 320 | // --------------------------------------------------------------------------- | |
| 321 | // Test-only exports | |
| 322 | // --------------------------------------------------------------------------- | |
| 323 | ||
| 324 | export const __test = { | |
| 325 | listAccessibleRepoIds, | |
| 326 | }; |