@javierkaiser/opencode-indexing 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -2
- package/README.md +192 -21
- package/index.ts +133 -17
- package/install.mjs +229 -128
- package/install.ps1 +7 -1
- package/install.sh +1 -1
- package/package.json +6 -2
- package/src/config.ts +1 -1
- package/src/embedder.ts +339 -99
- package/src/import.ts +30 -9
- package/src/indexer.ts +23 -1
- package/src/registry.ts +105 -19
- package/src/rpc.ts +27 -3
- package/src/search.ts +41 -2
- package/src/settings-commands.ts +6 -4
- package/src/settings.ts +28 -13
- package/src/status.ts +52 -5
- package/src/tools.ts +65 -20
- package/src/types.ts +32 -0
- package/tui/settings-page-data.ts +31 -15
- package/tui/settings-page.tsx +7 -1
- package/tui/workspace-rpc.ts +98 -0
- package/tui.ts +112 -41
package/index.ts
CHANGED
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
parseIndexingCommand,
|
|
33
33
|
settingReport,
|
|
34
34
|
} from "./src/settings-commands.ts"
|
|
35
|
-
import
|
|
35
|
+
import { getModelDimension, isKiloMirrored, isLiveTested, listModelIds } from "./src/registry.ts"
|
|
36
|
+
import type { EmbeddingProfile, IndexingSettings, KVStore, ProviderCredentials } from "./src/types.ts"
|
|
36
37
|
import type { Qdrant } from "./src/qdrant.ts"
|
|
37
38
|
import { describeError } from "./src/errors.ts"
|
|
38
39
|
|
|
@@ -114,13 +115,76 @@ function settingsView(settings: IndexingSettings, homeDir?: string): Record<stri
|
|
|
114
115
|
autoRefresh: settings.autoRefresh,
|
|
115
116
|
enabled: settings.enabled,
|
|
116
117
|
searchMaxResults: settings.searchMaxResults,
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// Which providers actually have a credential, as a list rather than one
|
|
119
|
+
// boolean per provider. The booleans only ever covered Mistral and OpenAI,
|
|
120
|
+
// so a settings page with a Gemini key configured reported "none
|
|
121
|
+
// configured" - and every provider added afterwards would have needed
|
|
122
|
+
// another field threaded through the RPC, the schema and the page.
|
|
123
|
+
configuredProviders: configuredProviders(settings.credentials),
|
|
119
124
|
settingsFile: settingsFilePath(homeDir),
|
|
120
125
|
warnings: settings.warnings,
|
|
121
126
|
}
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Providers with a usable credential.
|
|
131
|
+
*
|
|
132
|
+
* A base URL counts for the two providers that authenticate with one, since a
|
|
133
|
+
* configured Ollama or OpenAI-compatible server needs no key.
|
|
134
|
+
*/
|
|
135
|
+
function configuredProviders(credentials: ProviderCredentials): string[] {
|
|
136
|
+
const configured: string[] = []
|
|
137
|
+
if (credentials.mistralApiKey) configured.push("mistral")
|
|
138
|
+
if (credentials.openAiApiKey) configured.push("openai")
|
|
139
|
+
if (credentials.geminiApiKey) configured.push("gemini")
|
|
140
|
+
if (credentials.voyageApiKey) configured.push("voyage")
|
|
141
|
+
if (credentials.openRouterApiKey) configured.push("openrouter")
|
|
142
|
+
if (credentials.ollamaBaseUrl) configured.push("ollama")
|
|
143
|
+
if (credentials.openAiCompatibleBaseUrl) configured.push("openai-compatible")
|
|
144
|
+
return configured
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The profile the settings describe.
|
|
149
|
+
*
|
|
150
|
+
* Only the identity of the model is needed to compare against a Kilo index;
|
|
151
|
+
* the dimension is part of the comparison, so it has to come from somewhere.
|
|
152
|
+
* `settings.dimension` is 0 for a model the catalog does not know, and a
|
|
153
|
+
* comparison against a zero dimension would report every source as
|
|
154
|
+
* incompatible, which is as wrong as reporting every one as compatible.
|
|
155
|
+
*/
|
|
156
|
+
function profileFor(settings: IndexingSettings): EmbeddingProfile | undefined {
|
|
157
|
+
const dimension = settings.dimension || getModelDimension(settings.provider, settings.modelId) || 0
|
|
158
|
+
if (!dimension) return undefined
|
|
159
|
+
return { provider: settings.provider, modelId: settings.modelId, dimension }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The models this provider offers, and which of them can import from Kilo.
|
|
164
|
+
*
|
|
165
|
+
* The catalog was only ever used to look up a dimension, so it was invisible:
|
|
166
|
+
* a model id had to be known in advance to be used. Which entries Kilo also
|
|
167
|
+
* knows is the one thing that decides whether an import will be accepted, so
|
|
168
|
+
* it is stated rather than left to be discovered by a refused build.
|
|
169
|
+
*/
|
|
170
|
+
function describeAvailableModels(settings: IndexingSettings): string[] {
|
|
171
|
+
const models = listModelIds(settings.provider)
|
|
172
|
+
const note = isLiveTested(settings.provider)
|
|
173
|
+
? []
|
|
174
|
+
: [
|
|
175
|
+
`- **Note:** the ${settings.provider} adapter has not been exercised against the real API; it is built from the provider's documentation. See "How far each provider has actually been tested" in the README.`,
|
|
176
|
+
]
|
|
177
|
+
if (models.length === 0) {
|
|
178
|
+
return [...note, `- **Models:** this provider has no catalog; set \`model\` to whatever your endpoint serves`]
|
|
179
|
+
}
|
|
180
|
+
const render = (modelId: string) => {
|
|
181
|
+
const dimension = getModelDimension(settings.provider, modelId)
|
|
182
|
+
const importer = isKiloMirrored(settings.provider, modelId) === false ? " — not importable from Kilo" : ""
|
|
183
|
+
return `\`${modelId}\`${dimension ? ` (${dimension}d)` : ""}${importer}`
|
|
184
|
+
}
|
|
185
|
+
return [...note, `- **Models for ${settings.provider}:** ${models.map(render).join(" · ")}`]
|
|
186
|
+
}
|
|
187
|
+
|
|
124
188
|
function summarizeReport(report: {
|
|
125
189
|
durationMs: number
|
|
126
190
|
chunksUpserted: number
|
|
@@ -153,6 +217,19 @@ const plugin = {
|
|
|
153
217
|
const directory = (input as { directory?: string } | undefined)?.directory
|
|
154
218
|
return typeof directory === "string" && directory.trim() !== "" ? directory : root
|
|
155
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Directory of the session a command was typed in.
|
|
223
|
+
*
|
|
224
|
+
* The plugin instance belongs to the directory the service runs in, which is
|
|
225
|
+
* usually a different one entirely, so a command issued from a session has to
|
|
226
|
+
* ask the host where that session is rather than assume its own.
|
|
227
|
+
*/
|
|
228
|
+
const sessionDirectory = async (sessionID: string): Promise<string | undefined> =>
|
|
229
|
+
ctx.session
|
|
230
|
+
.get({ sessionID } as never)
|
|
231
|
+
.then((info: { location?: { directory?: string } }) => info?.location?.directory)
|
|
232
|
+
.catch(() => undefined)
|
|
156
233
|
const options = (ctx.options ?? {}) as unknown as PluginOptionsInput
|
|
157
234
|
const kv: KVStore = createPluginStorage(ctx.storage as never)
|
|
158
235
|
const getSettings = (): Promise<IndexingSettings> => Promise.resolve(resolveSettings(options))
|
|
@@ -293,7 +370,7 @@ Behavior:
|
|
|
293
370
|
* Extracted so the `settings.test` RPC and the `/indexing-config test`
|
|
294
371
|
* subcommand answer identically instead of drifting apart.
|
|
295
372
|
*/
|
|
296
|
-
async function testTarget(target: string): Promise<{ ok: boolean; message: string }> {
|
|
373
|
+
async function testTarget(target: string, workspace: string): Promise<{ ok: boolean; message: string }> {
|
|
297
374
|
const settings = await getSettings()
|
|
298
375
|
try {
|
|
299
376
|
if (target === "qdrant") {
|
|
@@ -315,11 +392,23 @@ Behavior:
|
|
|
315
392
|
return { ok: true, message: `${settings.provider}/${settings.modelId} OK (${vector?.length ?? 0} dimensions)` }
|
|
316
393
|
}
|
|
317
394
|
if (target === "kilo") {
|
|
318
|
-
const
|
|
395
|
+
const problems: string[] = []
|
|
396
|
+
const sources = await discoverKiloSources(workspace, {
|
|
397
|
+
qdrant: createSettingsQdrant(settings),
|
|
398
|
+
homeDir: undefined,
|
|
399
|
+
targetProfile: profileFor(settings),
|
|
400
|
+
onProblem: (message) => problems.push(message),
|
|
401
|
+
})
|
|
319
402
|
const list = sources.map((s) => `${s.kind} ${s.name} (${s.pointsCount ?? "?"} pts, ${s.compatible ? "compatible" : "INCOMPATIBLE"})`)
|
|
403
|
+
// A readable source does not make an unreadable one irrelevant: a
|
|
404
|
+
// partial answer ("Qdrant works, LanceDB does not") is the one that
|
|
405
|
+
// explains why an import picked the source it did.
|
|
406
|
+
const found = sources.length > 0 ? list.join(" · ") : `No importable Kilo index for ${workspace}`
|
|
320
407
|
return {
|
|
321
408
|
ok: sources.length > 0,
|
|
322
|
-
|
|
409
|
+
// With nothing found, a reason beats a bare "not found": the two
|
|
410
|
+
// need completely different actions from the user.
|
|
411
|
+
message: problems.length > 0 ? `${found}. ${problems.join(" · ")}` : found,
|
|
323
412
|
}
|
|
324
413
|
}
|
|
325
414
|
return { ok: false, message: `Unknown test target: ${target}` }
|
|
@@ -344,11 +433,24 @@ Behavior:
|
|
|
344
433
|
const dimension = settings.dimension || (await store.info()).profile?.dimension || 0
|
|
345
434
|
if (!dimension) throw new Error("Embedding dimension unknown")
|
|
346
435
|
const targetProfile = { provider: settings.provider, modelId: settings.modelId, dimension }
|
|
347
|
-
const
|
|
436
|
+
const problems: string[] = []
|
|
437
|
+
const sources = await discoverKiloSources(workspace, {
|
|
438
|
+
qdrant: createSettingsQdrant(settings),
|
|
439
|
+
targetProfile,
|
|
440
|
+
onProblem: (message) => problems.push(message),
|
|
441
|
+
})
|
|
348
442
|
const source = wanted
|
|
349
443
|
? sources.find((item) => item.name === wanted)
|
|
350
444
|
: (sources.find((item) => item.compatible) ?? sources[0])
|
|
351
|
-
if (!source)
|
|
445
|
+
if (!source) {
|
|
446
|
+
// Naming the cause turns "nothing here" into something actionable when
|
|
447
|
+
// the index is present but unreadable.
|
|
448
|
+
throw new Error(
|
|
449
|
+
problems.length > 0
|
|
450
|
+
? `No Kilo source found for ${workspace}. ${problems.join(" · ")}`
|
|
451
|
+
: "No Kilo source found for this workspace",
|
|
452
|
+
)
|
|
453
|
+
}
|
|
352
454
|
if (!source.compatible) throw new Error(`Source ${source.name} has an incompatible embedding profile`)
|
|
353
455
|
await store.ensure(dimension, targetProfile)
|
|
354
456
|
const report = await importFromKilo({
|
|
@@ -462,6 +564,7 @@ class ForgetRefusedError extends Error {}
|
|
|
462
564
|
"",
|
|
463
565
|
`- **Vector store (own index):** ${settings.vectorStore}${settings.vectorStore === "qdrant" ? ` — ${settings.qdrantUrl}` : ` — ${settings.lancedbDirectory}`}`,
|
|
464
566
|
`- **Embedding model:** ${settings.provider}/${settings.modelId}${settings.dimension ? ` (${settings.dimension}d)` : ""}`,
|
|
567
|
+
...describeAvailableModels(settings),
|
|
465
568
|
`- **Import from Kilo:** ${settings.importFromKilo ? "ON (build imports existing Kilo indexes without embedding cost)" : "OFF (always index from scratch)"}`,
|
|
466
569
|
`- **Auto-refresh on search:** ${settings.autoRefresh ? "ON" : "OFF"}`,
|
|
467
570
|
`- **Indexing:** ${settings.enabled === false ? "PAUSED (nothing is written or refreshed; search still reads Kilo's index)" : "ON"}`,
|
|
@@ -479,9 +582,16 @@ class ForgetRefusedError extends Error {}
|
|
|
479
582
|
}
|
|
480
583
|
lines.push("")
|
|
481
584
|
}
|
|
585
|
+
const problems: string[] = []
|
|
482
586
|
const sources = await discoverKiloSources(root, {
|
|
483
587
|
qdrant: createSettingsQdrant(settings),
|
|
484
588
|
homeDir: undefined,
|
|
589
|
+
// Without this every source reported "compatible", including ones whose
|
|
590
|
+
// profile differs: the check returns true when it has no target to
|
|
591
|
+
// compare against, so the report claimed an import would work when it
|
|
592
|
+
// would be refused a moment later.
|
|
593
|
+
targetProfile: profileFor(settings),
|
|
594
|
+
onProblem: (message) => problems.push(message),
|
|
485
595
|
}).catch(() => [])
|
|
486
596
|
lines.push(
|
|
487
597
|
"",
|
|
@@ -489,6 +599,9 @@ class ForgetRefusedError extends Error {}
|
|
|
489
599
|
? `**Kilo sources detected:** ${sources.map((source) => `${source.kind} ${source.name} (${source.pointsCount ?? "?"} pts, ${source.compatible ? "compatible" : "INCOMPATIBLE"})`).join("; ")}`
|
|
490
600
|
: "**Kilo sources detected:** none for this workspace",
|
|
491
601
|
)
|
|
602
|
+
// "None" and "none I could read" are different answers, and only one of
|
|
603
|
+
// them is fixed by indexing from scratch.
|
|
604
|
+
if (problems.length > 0) lines.push("", `**Kilo sources unreadable:** ${problems.join(" · ")}`)
|
|
492
605
|
lines.push(
|
|
493
606
|
"",
|
|
494
607
|
"To change settings:",
|
|
@@ -548,11 +661,15 @@ class ForgetRefusedError extends Error {}
|
|
|
548
661
|
return
|
|
549
662
|
}
|
|
550
663
|
case "test": {
|
|
664
|
+
// Like `import` below, this has to name the session's directory:
|
|
665
|
+
// `test kilo` asks whether an index exists *for a workspace*, and
|
|
666
|
+
// answering about the service's own directory is always wrong.
|
|
551
667
|
const targets =
|
|
552
668
|
parsed.target === "all" ? ["qdrant", "lancedb", "provider", "kilo"] : [parsed.target]
|
|
669
|
+
const workspace = (await sessionDirectory(sessionID)) ?? root
|
|
553
670
|
const results: string[] = []
|
|
554
671
|
for (const target of targets) {
|
|
555
|
-
const result = await testTarget(target)
|
|
672
|
+
const result = await testTarget(target, workspace)
|
|
556
673
|
results.push(`${target}: ${result.ok ? "OK" : "FAIL"} — ${result.message}`)
|
|
557
674
|
}
|
|
558
675
|
await reply(results.join("\n"))
|
|
@@ -562,11 +679,7 @@ class ForgetRefusedError extends Error {}
|
|
|
562
679
|
// The session knows which workspace the user is in; the plugin
|
|
563
680
|
// instance directory is the one the service runs in, which is
|
|
564
681
|
// usually a different directory entirely.
|
|
565
|
-
|
|
566
|
-
.get({ sessionID } as never)
|
|
567
|
-
.then((info: { location?: { directory?: string } }) => info?.location?.directory)
|
|
568
|
-
.catch(() => undefined)
|
|
569
|
-
await reply(await runImport(sessionDirectory, parsed.source))
|
|
682
|
+
await reply(await runImport(await sessionDirectory(sessionID), parsed.source))
|
|
570
683
|
return
|
|
571
684
|
}
|
|
572
685
|
}
|
|
@@ -604,17 +717,19 @@ class ForgetRefusedError extends Error {}
|
|
|
604
717
|
},
|
|
605
718
|
|
|
606
719
|
"settings.test": async (input: unknown) => {
|
|
607
|
-
return testTarget((input as { target: string }).target)
|
|
720
|
+
return testTarget((input as { target: string }).target, rootFor(input))
|
|
608
721
|
},
|
|
609
722
|
|
|
610
|
-
"kilo.discover": async () => {
|
|
723
|
+
"kilo.discover": async (input: unknown) => {
|
|
611
724
|
const settings = await getSettings()
|
|
612
725
|
const targetProfile =
|
|
613
726
|
settings.dimension > 0 ? { provider: settings.provider, modelId: settings.modelId, dimension: settings.dimension } : undefined
|
|
614
|
-
const
|
|
727
|
+
const problems: string[] = []
|
|
728
|
+
const sources = await discoverKiloSources(rootFor(input), {
|
|
615
729
|
qdrant: createSettingsQdrant(settings),
|
|
616
730
|
homeDir: undefined,
|
|
617
731
|
targetProfile,
|
|
732
|
+
onProblem: (message) => problems.push(message),
|
|
618
733
|
})
|
|
619
734
|
return {
|
|
620
735
|
sources: sources.map((source) => ({
|
|
@@ -627,6 +742,7 @@ class ForgetRefusedError extends Error {}
|
|
|
627
742
|
: null,
|
|
628
743
|
compatible: source.compatible,
|
|
629
744
|
})),
|
|
745
|
+
problems,
|
|
630
746
|
}
|
|
631
747
|
},
|
|
632
748
|
|