Skip to content

Commit 147171e

Browse files
feat(code-index): add Semble as a local on-the-fly embedding provider (#399)
* feat(code-index): add Semble as a local on-the-fly embedding provider * fix test and translations * Use in built semble * Remove customizable path, only use downloaded semble * fix test * fix windows test * make semble binary upgradable * address coderabbit comments * Update semble download url to zoo code org * Fix semble creating multiple caches * fix unit test * address review comments * Address second round of comments * Fix failing windows test
1 parent 0d21731 commit 147171e

38 files changed

Lines changed: 3151 additions & 73 deletions

packages/types/src/codebase-index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const codebaseIndexConfigSchema = z.object({
3131
"vercel-ai-gateway",
3232
"bedrock",
3333
"openrouter",
34+
"semble",
3435
])
3536
.optional(),
3637
codebaseIndexEmbedderBaseUrl: z.string().optional(),
@@ -67,6 +68,7 @@ export const codebaseIndexModelsSchema = z.object({
6768
"vercel-ai-gateway": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
6869
openrouter: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
6970
bedrock: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
71+
semble: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
7072
})
7173

7274
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>

packages/types/src/embedding.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ export type EmbedderProvider =
66
| "mistral"
77
| "vercel-ai-gateway"
88
| "bedrock"
9-
| "openrouter" // Add other providers as needed.
9+
| "openrouter"
10+
| "semble" // Local hybrid search via semble CLI — no API keys or Qdrant required.
1011

1112
export interface EmbeddingModelProfile {
1213
dimension: number

packages/types/src/vscode-extension-host.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,12 @@ export type ExtensionState = Pick<
379379
deviceName?: string
380380
debug?: boolean
381381

382+
/**
383+
* Platform info for conditional feature support (e.g. semble binary availability).
384+
*/
385+
platform?: string
386+
arch?: string
387+
382388
/**
383389
* Monotonically increasing sequence number for clineMessages state pushes.
384390
* When present, the frontend should only apply clineMessages from a state push
@@ -666,6 +672,7 @@ export interface WebviewMessage {
666672
| "vercel-ai-gateway"
667673
| "bedrock"
668674
| "openrouter"
675+
| "semble"
669676
codebaseIndexEmbedderBaseUrl?: string
670677
codebaseIndexEmbedderModelId: string
671678
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2452,6 +2452,8 @@ export class ClineProvider
24522452
}
24532453
})(),
24542454
...zooCodeState,
2455+
platform: process.platform,
2456+
arch: process.arch,
24552457
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
24562458
}
24572459
}

src/services/code-index/__tests__/config-manager.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,86 @@ describe("CodeIndexConfigManager", () => {
11271127
expect(requiresRestart).toBe(true)
11281128
})
11291129
})
1130+
1131+
describe("semble provider configuration", () => {
1132+
it("should load semble provider configuration", async () => {
1133+
mockContextProxy.getGlobalState.mockReturnValue({
1134+
codebaseIndexEnabled: true,
1135+
codebaseIndexEmbedderProvider: "semble",
1136+
})
1137+
mockContextProxy.getSecret.mockReturnValue(undefined)
1138+
1139+
const result = await configManager.loadConfiguration()
1140+
1141+
expect(result.currentConfig.embedderProvider).toBe("semble")
1142+
expect(result.currentConfig.isConfigured).toBe(true)
1143+
})
1144+
1145+
it("should require restart when switching from openai to semble", async () => {
1146+
// Initial state with OpenAI
1147+
mockContextProxy.getGlobalState.mockReturnValue({
1148+
codebaseIndexEnabled: true,
1149+
codebaseIndexQdrantUrl: "http://qdrant.local",
1150+
codebaseIndexEmbedderProvider: "openai",
1151+
codebaseIndexEmbedderModelId: "text-embedding-3-small",
1152+
})
1153+
setupSecretMocks({
1154+
codeIndexOpenAiKey: "test-key",
1155+
})
1156+
1157+
await configManager.loadConfiguration()
1158+
1159+
// Switch to semble
1160+
mockContextProxy.getGlobalState.mockReturnValue({
1161+
codebaseIndexEnabled: true,
1162+
codebaseIndexEmbedderProvider: "semble",
1163+
})
1164+
mockContextProxy.getSecret.mockReturnValue(undefined)
1165+
1166+
const result = await configManager.loadConfiguration()
1167+
expect(result.requiresRestart).toBe(true)
1168+
})
1169+
1170+
it("should require restart when switching from semble to openai", async () => {
1171+
// Initial state with semble
1172+
mockContextProxy.getGlobalState.mockReturnValue({
1173+
codebaseIndexEnabled: true,
1174+
codebaseIndexEmbedderProvider: "semble",
1175+
})
1176+
mockContextProxy.getSecret.mockReturnValue(undefined)
1177+
1178+
await configManager.loadConfiguration()
1179+
1180+
// Switch to openai
1181+
mockContextProxy.getGlobalState.mockReturnValue({
1182+
codebaseIndexEnabled: true,
1183+
codebaseIndexQdrantUrl: "http://qdrant.local",
1184+
codebaseIndexEmbedderProvider: "openai",
1185+
codebaseIndexEmbedderModelId: "text-embedding-3-small",
1186+
})
1187+
setupSecretMocks({
1188+
codeIndexOpenAiKey: "test-key",
1189+
})
1190+
1191+
const result = await configManager.loadConfiguration()
1192+
expect(result.requiresRestart).toBe(true)
1193+
})
1194+
1195+
it("should not require restart when semble config stays the same", async () => {
1196+
// Initial state with semble
1197+
mockContextProxy.getGlobalState.mockReturnValue({
1198+
codebaseIndexEnabled: true,
1199+
codebaseIndexEmbedderProvider: "semble",
1200+
})
1201+
mockContextProxy.getSecret.mockReturnValue(undefined)
1202+
1203+
await configManager.loadConfiguration()
1204+
1205+
// Same semble config again
1206+
const result = await configManager.loadConfiguration()
1207+
expect(result.requiresRestart).toBe(false)
1208+
})
1209+
})
11301210
})
11311211

11321212
describe("isConfigured", () => {
@@ -1684,6 +1764,30 @@ describe("CodeIndexConfigManager", () => {
16841764
expect(configManager.isConfigured()).toBe(false)
16851765
})
16861766

1767+
it("should always return true for semble provider (no API keys or Qdrant needed)", () => {
1768+
mockContextProxy.getGlobalState.mockReturnValue({
1769+
codebaseIndexEnabled: true,
1770+
codebaseIndexEmbedderProvider: "semble",
1771+
})
1772+
mockContextProxy.getSecret.mockReturnValue(undefined)
1773+
1774+
configManager = new CodeIndexConfigManager(mockContextProxy)
1775+
expect(configManager.isConfigured()).toBe(true)
1776+
})
1777+
1778+
it("should return true for semble even without any other configuration", () => {
1779+
mockContextProxy.getGlobalState.mockReturnValue({
1780+
codebaseIndexEnabled: true,
1781+
codebaseIndexEmbedderProvider: "semble",
1782+
// No qdrant URL, no API keys
1783+
})
1784+
mockContextProxy.getSecret.mockReturnValue(undefined)
1785+
1786+
configManager = new CodeIndexConfigManager(mockContextProxy)
1787+
expect(configManager.isConfigured()).toBe(true)
1788+
expect(configManager.isFeatureConfigured).toBe(true)
1789+
})
1790+
16871791
describe("currentModelDimension", () => {
16881792
beforeEach(() => {
16891793
vi.clearAllMocks()

src/services/code-index/__tests__/service-factory.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,17 @@ describe("CodeIndexServiceFactory", () => {
356356
// Act & Assert
357357
expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType")
358358
})
359+
360+
it("should throw when provider is semble (semble handles its own embedding)", () => {
361+
const testConfig = {
362+
embedderProvider: "semble",
363+
}
364+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
365+
366+
expect(() => factory.createEmbedder()).toThrow(
367+
"Semble provider handles its own embedding. Do not call createEmbedder() for semble",
368+
)
369+
})
359370
})
360371

361372
describe("createVectorStore", () => {
@@ -678,6 +689,17 @@ describe("CodeIndexServiceFactory", () => {
678689
// Act & Assert
679690
expect(() => factory.createVectorStore()).toThrow("serviceFactory.qdrantUrlMissing")
680691
})
692+
693+
it("should throw when provider is semble (semble handles its own vector storage)", () => {
694+
const testConfig = {
695+
embedderProvider: "semble",
696+
}
697+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
698+
699+
expect(() => factory.createVectorStore()).toThrow(
700+
"Semble provider handles its own vector storage. Do not call createVectorStore() for semble",
701+
)
702+
})
681703
})
682704

683705
describe("validateEmbedder", () => {

src/services/code-index/config-manager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export class CodeIndexConfigManager {
120120
this.embedderProvider = "bedrock"
121121
} else if (codebaseIndexEmbedderProvider === "openrouter") {
122122
this.embedderProvider = "openrouter"
123+
} else if (codebaseIndexEmbedderProvider === "semble") {
124+
this.embedderProvider = "semble"
123125
} else {
124126
this.embedderProvider = "openai"
125127
}
@@ -231,6 +233,11 @@ export class CodeIndexConfigManager {
231233
* Checks if the service is properly configured based on the embedder type.
232234
*/
233235
public isConfigured(): boolean {
236+
if (this.embedderProvider === "semble") {
237+
// Semble requires no API keys or Qdrant — it's always configured
238+
return true
239+
}
240+
234241
if (this.embedderProvider === "openai") {
235242
const openAiKey = this.openAiOptions?.openAiNativeApiKey
236243
const qdrantUrl = this.qdrantUrl

src/services/code-index/interfaces/manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export type EmbedderProvider =
8484
| "vercel-ai-gateway"
8585
| "bedrock"
8686
| "openrouter"
87+
| "semble"
8788

8889
export interface IndexProgressUpdate {
8990
systemStatus: IndexingState

0 commit comments

Comments
 (0)