@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/index.ts CHANGED
@@ -32,7 +32,8 @@ import {
32
32
  parseIndexingCommand,
33
33
  settingReport,
34
34
  } from "./src/settings-commands.ts"
35
- import type { IndexingSettings, KVStore } from "./src/types.ts"
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
- hasMistralKey: Boolean(settings.credentials.mistralApiKey),
118
- hasOpenAiKey: Boolean(settings.credentials.openAiApiKey),
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 sources = await discoverKiloSources(root, { qdrant: createSettingsQdrant(settings), homeDir: undefined })
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
- message: sources.length > 0 ? list.join(" · ") : "No Kilo index found for this workspace",
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 sources = await discoverKiloSources(workspace, { qdrant: createSettingsQdrant(settings), targetProfile })
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) throw new Error("No Kilo source found for this workspace")
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
- const sessionDirectory = await ctx.session
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 sources = await discoverKiloSources(root, {
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