@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/src/tools.ts CHANGED
@@ -10,6 +10,13 @@ import type { Qdrant } from "./qdrant.ts"
10
10
 
11
11
  export interface ToolDeps {
12
12
  kv: KVStore
13
+ /**
14
+ * Fallback workspace, used when the host does not report one per call.
15
+ *
16
+ * This is the plugin instance's own directory — the one the service runs in,
17
+ * which is usually not the directory the user is working in. Prefer
18
+ * `context.directory`; this is the last resort, not the normal path.
19
+ */
13
20
  root: string
14
21
  /**
15
22
  * Live settings getter. Tools resolve settings on every execution so config
@@ -18,6 +25,31 @@ export interface ToolDeps {
18
25
  getSettings: () => IndexingSettings | Promise<IndexingSettings>
19
26
  }
20
27
 
28
+ /**
29
+ * Host context as far as the tools read it.
30
+ *
31
+ * `directory` is the session's workspace. Older hosts omit it, which is why it
32
+ * is optional: `toolRoot` falls back rather than indexing nothing.
33
+ */
34
+ export interface ToolContext {
35
+ signal?: AbortSignal
36
+ progress?: (m: unknown) => Promise<void>
37
+ directory?: string
38
+ }
39
+
40
+ /**
41
+ * The workspace one tool call is about.
42
+ *
43
+ * The host knows which directory the user is in; the plugin instance does not.
44
+ * Indexing the service's directory because that is what `deps.root` happened to
45
+ * hold writes a perfectly good index of the wrong codebase and reports it as the
46
+ * user's, so the session directory wins whenever the host reports one.
47
+ */
48
+ function toolRoot(deps: ToolDeps, context: ToolContext): string {
49
+ const directory = context.directory
50
+ return typeof directory === "string" && directory.trim() !== "" ? directory : deps.root
51
+ }
52
+
21
53
  export interface ToolDefinition {
22
54
  name: string
23
55
  description: string
@@ -30,10 +62,7 @@ export interface ToolDefinition {
30
62
  * is why configuration read and write are separate tools.
31
63
  */
32
64
  permission?: string
33
- execute: (
34
- input: Record<string, unknown>,
35
- context: { signal?: AbortSignal; progress?: (m: unknown) => Promise<void> },
36
- ) => Promise<{ output: string; metadata?: Record<string, unknown> }>
65
+ execute: (input: Record<string, unknown>, context: ToolContext) => Promise<{ output: string; metadata?: Record<string, unknown> }>
37
66
  }
38
67
 
39
68
  const SEARCH_DESCRIPTION = `Find code snippets by semantic meaning in the project's code index.
@@ -119,9 +148,10 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
119
148
  },
120
149
  ["query"],
121
150
  ),
122
- async execute(input) {
151
+ async execute(input, context) {
123
152
  const settings = await deps.getSettings()
124
153
  const qdrant = createSettingsQdrant(settings)
154
+ const root = toolRoot(deps, context)
125
155
  const query = typeof input.query === "string" ? input.query.trim() : ""
126
156
  if (!query) throw new Error("query is required")
127
157
  const pathPrefix = typeof input.path === "string" && input.path.trim() !== "" ? input.path.trim() : undefined
@@ -130,13 +160,13 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
130
160
  // Skipped while indexing is paused, otherwise a search would keep writing.
131
161
  if (settings.autoRefresh && settings.enabled !== false) {
132
162
  try {
133
- await maybeAutoRefresh(deps, settings, qdrant)
163
+ await maybeAutoRefresh(root, deps, settings, qdrant)
134
164
  } catch {
135
165
  // Auto-refresh failures never block the search.
136
166
  }
137
167
  }
138
168
 
139
- const outcome = await searchCode({ qdrant, kv: deps.kv, root: deps.root, settings }, query, pathPrefix)
169
+ const outcome = await searchCode({ qdrant, kv: deps.kv, root, settings }, query, pathPrefix)
140
170
  const { hits, meta, errors } = outcome
141
171
  if (hits.length === 0) {
142
172
  const lines: string[] = [`No results for "${query}" in ${meta.root}.`]
@@ -184,10 +214,11 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
184
214
  input: jsonSchema({
185
215
  checkFreshness: { type: "boolean", description: "Scan the workspace to compute file freshness (slower)." },
186
216
  }),
187
- async execute(input) {
217
+ async execute(input, context) {
188
218
  const settings = await deps.getSettings()
189
219
  const qdrant = createSettingsQdrant(settings)
190
- const status = await getStatus({ qdrant, kv: deps.kv, root: deps.root, settings }, { checkFreshness: input.checkFreshness !== false })
220
+ const root = toolRoot(deps, context)
221
+ const status = await getStatus({ qdrant, kv: deps.kv, root, settings }, { checkFreshness: input.checkFreshness !== false })
191
222
  const lines: string[] = [
192
223
  `Workspace: ${status.root}`,
193
224
  `Qdrant: ${status.qdrant.url} (${status.qdrant.reachable ? "reachable" : "UNREACHABLE"})`,
@@ -224,11 +255,12 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
224
255
  const settings = await deps.getSettings()
225
256
  assertIndexingEnabled(settings)
226
257
  const qdrant = createSettingsQdrant(settings)
258
+ const root = toolRoot(deps, context)
227
259
  const maxFiles = typeof input.maxFiles === "number" && input.maxFiles > 0 ? Math.floor(input.maxFiles) : undefined
228
260
  const report = await runIndex(
229
261
  {
230
262
  kv: deps.kv,
231
- root: deps.root,
263
+ root,
232
264
  settings,
233
265
  qdrant,
234
266
  onProgress: makeProgress(context),
@@ -236,7 +268,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
236
268
  { mode: "refresh", maxFiles },
237
269
  )
238
270
  const summary = formatReport("refresh", report)
239
- const warning = scopeWarning(deps.root)
271
+ const warning = scopeWarning(root)
240
272
  return {
241
273
  output: warning ? `${warning}\n${summary}` : summary,
242
274
  metadata: report as unknown as Record<string, unknown>,
@@ -254,10 +286,11 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
254
286
  const settings = await deps.getSettings()
255
287
  assertIndexingEnabled(settings)
256
288
  const qdrant = createSettingsQdrant(settings)
289
+ const root = toolRoot(deps, context)
257
290
  const report = await runIndex(
258
291
  {
259
292
  kv: deps.kv,
260
- root: deps.root,
293
+ root,
261
294
  settings,
262
295
  qdrant,
263
296
  onProgress: makeProgress(context),
@@ -265,7 +298,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
265
298
  { mode: "build", rebuild: input.rebuild === true, skipImport: input.skipImport === true },
266
299
  )
267
300
  const summary = formatReport("build", report)
268
- const warning = scopeWarning(deps.root)
301
+ const warning = scopeWarning(root)
269
302
  return {
270
303
  output: warning ? `${warning}\n${summary}` : summary,
271
304
  metadata: report as unknown as Record<string, unknown>,
@@ -283,7 +316,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
283
316
  const settings = await deps.getSettings()
284
317
  assertIndexingEnabled(settings)
285
318
  const qdrant = createSettingsQdrant(settings)
286
- const root = deps.root
319
+ const root = toolRoot(deps, context)
287
320
  const store = createOwnStore(settings, root)
288
321
  const profile = { provider: settings.provider, modelId: settings.modelId, dimension: settings.dimension }
289
322
 
@@ -304,8 +337,15 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
304
337
  }
305
338
  await store.ensure(dimension, targetProfile)
306
339
 
307
- const sources = await discoverKiloSources(root, { qdrant, targetProfile })
308
- if (sources.length === 0) throw new Error("No Kilo Code index found for this workspace (neither Qdrant collection nor local LanceDB)")
340
+ const problems: string[] = []
341
+ const sources = await discoverKiloSources(root, { qdrant, targetProfile, onProblem: (m) => problems.push(m) })
342
+ if (sources.length === 0) {
343
+ throw new Error(
344
+ problems.length > 0
345
+ ? `No Kilo Code index found for ${root}. ${problems.join(" · ")}`
346
+ : "No Kilo Code index found for this workspace (neither Qdrant collection nor local LanceDB)",
347
+ )
348
+ }
309
349
  const wanted = typeof input.source === "string" && input.source.trim() !== "" ? input.source.trim() : undefined
310
350
  const source = wanted ? sources.find((item) => item.name === wanted) : (sources.find((item) => item.compatible) ?? sources[0])
311
351
  if (!source) throw new Error(`Source not found: ${wanted}. Available: ${sources.map((s) => s.name).join(", ")}`)
@@ -362,12 +402,17 @@ function assertIndexingEnabled(settings: { enabled?: boolean }): void {
362
402
  /** Max changed files that trigger an inline refresh on search. */
363
403
  const AUTO_REFRESH_MAX_FILES = 200
364
404
 
365
- async function maybeAutoRefresh(deps: ToolDeps, settings: IndexingSettings, qdrant: Qdrant): Promise<void> {
405
+ async function maybeAutoRefresh(
406
+ root: string,
407
+ deps: ToolDeps,
408
+ settings: IndexingSettings,
409
+ qdrant: Qdrant,
410
+ ): Promise<void> {
366
411
  // Cheap pre-check: diff the manifest against a bounded workspace scan.
367
412
  const { loadManifest, diffFiles } = await import("./manifest.ts")
368
413
  const { scanWorkspace } = await import("./scanner.ts")
369
- const manifest = await loadManifest(deps.kv, deps.root)
370
- const scan = await scanWorkspace(deps.root, {
414
+ const manifest = await loadManifest(deps.kv, root)
415
+ const scan = await scanWorkspace(root, {
371
416
  extensions: settings.fileExtensions,
372
417
  maxFileSizeBytes: settings.maxFileSizeBytes,
373
418
  maxFiles: AUTO_REFRESH_MAX_FILES + 1,
@@ -376,7 +421,7 @@ async function maybeAutoRefresh(deps: ToolDeps, settings: IndexingSettings, qdra
376
421
  const delta = diff.added.length + diff.maybeStale.length + diff.deleted.length
377
422
  if (delta === 0 || delta > AUTO_REFRESH_MAX_FILES) return
378
423
  await runIndex(
379
- { kv: deps.kv, root: deps.root, settings, qdrant },
424
+ { kv: deps.kv, root, settings, qdrant },
380
425
  { mode: "refresh", maxFiles: AUTO_REFRESH_MAX_FILES },
381
426
  )
382
427
  }
package/src/types.ts CHANGED
@@ -22,6 +22,38 @@ export interface ModelProfile {
22
22
  dimension: number
23
23
  scoreThreshold?: number
24
24
  queryPrefix?: string
25
+ /**
26
+ * Largest input the model accepts, in tokens.
27
+ *
28
+ * Not the same for every model, and the spread is wide: `mxbai-embed-large`
29
+ * takes 512 while `voyage-code-4` takes 32,000. Sizing every request by one
30
+ * global constant silently truncates every chunk past the smallest model's
31
+ * limit, and that truncated vector is stored as if it covered the whole
32
+ * function.
33
+ */
34
+ maxInputTokens?: number
35
+ /** Documented ceiling on items in one request, where the provider publishes one. */
36
+ maxBatchItems?: number
37
+ /** Documented ceiling on tokens in one request, where the provider publishes one. */
38
+ maxBatchTokens?: number
39
+ /**
40
+ * Whether the provider documents its output as already L2-normalised.
41
+ *
42
+ * The provider's claim, not a measurement we rely on. Mistral documents its
43
+ * embeddings as "norm 1" and a live call measured 0.9913, which is why every
44
+ * comparison here uses cosine regardless: it is correct either way, and this
45
+ * field exists only so a future fast path knows whether a bare dot product
46
+ * would be equivalent.
47
+ */
48
+ normalized?: boolean
49
+ /**
50
+ * Marks a model we know about that Kilo's registry does not.
51
+ *
52
+ * The catalog is a mirror, so that query embeddings stay interchangeable with
53
+ * a Kilo index. An entry with no Kilo counterpart is still useful, but it can
54
+ * never be import-compatible, and the status report has to be able to say so.
55
+ */
56
+ kiloMirror?: boolean
25
57
  }
26
58
 
27
59
  export interface ProviderCredentials {
@@ -24,8 +24,8 @@ export interface SettingsView {
24
24
  /** Master switch for writing the own index. */
25
25
  enabled: boolean
26
26
  searchMaxResults: number
27
- hasMistralKey: boolean
28
- hasOpenAiKey: boolean
27
+ /** Providers that have a usable credential or base URL configured. */
28
+ configuredProviders: string[]
29
29
  settingsFile: string
30
30
  }
31
31
 
@@ -43,15 +43,23 @@ export interface StatusView {
43
43
  recommendation: string
44
44
  }
45
45
 
46
+ /**
47
+ * The RPC slice this page needs.
48
+ *
49
+ * Every workspace-scoped method takes an optional `directory`. Callers normally
50
+ * never pass one: `bindWorkspace` injects it into the client the TUI hands this
51
+ * page, so the call sites below cannot forget it. The field stays on the types
52
+ * because the bound client is what actually sends it.
53
+ */
46
54
  export interface SettingsPageRpc {
47
55
  "settings.get": (input: Record<string, never>) => Promise<{ settings: SettingsView }>
48
56
  "settings.set": (input: { patch: Record<string, unknown> }) => Promise<{ settings: SettingsView }>
49
- "settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo" }) => Promise<{ ok: boolean; message: string }>
50
- "kilo.discover": () => Promise<{ sources: KiloSourceView[] }>
51
- "status.get": (input: { checkFreshness?: boolean }) => Promise<StatusView>
52
- "index.build": (input: { rebuild?: boolean; skipImport?: boolean }) => Promise<{ summary: string; error?: string }>
53
- "index.refresh": (input: { maxFiles?: number }) => Promise<{ summary: string; error?: string }>
54
- "index.import": (input: { source?: string; rebuild?: boolean }) => Promise<{ summary: string; error?: string }>
57
+ "settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo"; directory?: string }) => Promise<{ ok: boolean; message: string }>
58
+ "kilo.discover": (input?: { directory?: string }) => Promise<{ sources: KiloSourceView[]; problems?: string[] }>
59
+ "status.get": (input: { checkFreshness?: boolean; directory?: string }) => Promise<StatusView>
60
+ "index.build": (input: { rebuild?: boolean; skipImport?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
61
+ "index.refresh": (input: { maxFiles?: number; directory?: string }) => Promise<{ summary: string; error?: string }>
62
+ "index.import": (input: { source?: string; rebuild?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
55
63
  "workspaces.list": (input: Record<string, never>) => Promise<{ workspaces: WorkspaceRow[] }>
56
64
  "workspace.forget": (input: { store: string }) => Promise<{ ok: boolean; reason?: "refused" | "failed"; summary: string; pointsDeleted: number | null }>
57
65
  }
@@ -78,6 +86,14 @@ export interface SettingsPageProps {
78
86
  export interface SettingsPageData {
79
87
  settings: SettingsView
80
88
  sources: KiloSourceView[]
89
+ /**
90
+ * Sources that exist but could not be read.
91
+ *
92
+ * Kept apart from `sources` on purpose: an empty list with problems is a
93
+ * different answer from an empty list without them, and the page has to be
94
+ * able to tell the user which one they are looking at.
95
+ */
96
+ problems: string[]
81
97
  status: StatusView
82
98
  /** Indexed workspaces, so the page can show what exists on disk. */
83
99
  workspaces: WorkspacesView
@@ -101,8 +117,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
101
117
  enabled: false,
102
118
  autoRefresh: false,
103
119
  searchMaxResults: 0,
104
- hasMistralKey: false,
105
- hasOpenAiKey: false,
120
+ configuredProviders: [],
106
121
  settingsFile: "(unavailable)",
107
122
  }
108
123
 
@@ -117,7 +132,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
117
132
  export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<SettingsPageData> {
118
133
  const [settingsResponse, sourcesResponse, status, workspaces] = await Promise.all([
119
134
  rpc["settings.get"]({}).catch(() => ({ settings: UNKNOWN_SETTINGS })),
120
- rpc["kilo.discover"]().catch(() => ({ sources: [] as KiloSourceView[] })),
135
+ rpc["kilo.discover"]({}).catch(() => ({ sources: [] as KiloSourceView[], problems: [] as string[] })),
121
136
  rpc["status.get"]({ checkFreshness: false }).catch(() => ({ summary: "(status unavailable)", recommendation: "" })),
122
137
  // loadWorkspaces already degrades to an empty view, so it cannot reject.
123
138
  loadWorkspaces(rpc),
@@ -125,6 +140,7 @@ export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<Settin
125
140
  return {
126
141
  settings: settingsResponse.settings,
127
142
  sources: sourcesResponse.sources,
143
+ problems: sourcesResponse.problems ?? [],
128
144
  status,
129
145
  workspaces,
130
146
  }
@@ -149,10 +165,10 @@ export function configRows(settings: SettingsView): ConfigRow[] {
149
165
  { label: "Max results", value: String(settings.searchMaxResults) },
150
166
  { label: "Settings file", value: settings.settingsFile },
151
167
  ]
152
- const keys: string[] = []
153
- if (settings.hasMistralKey) keys.push("mistral")
154
- if (settings.hasOpenAiKey) keys.push("openai")
155
- rows.push({ label: "API keys", value: keys.length > 0 ? `${keys.join(", ")} configured` : "none configured" })
168
+ // Derived from the server's list rather than a pair of booleans, so a
169
+ // provider added anywhere else shows up here without a change to this file.
170
+ const keys = settings.configuredProviders
171
+ rows.push({ label: "Credentials", value: keys.length > 0 ? `${keys.join(", ")} configured` : "none configured" })
156
172
  return rows
157
173
  }
158
174
 
@@ -75,7 +75,13 @@ export function SettingsPage(props: SettingsPageProps): JSX.Element {
75
75
  const action = PAGE_ACTIONS.find((entry) => entry.id === id)
76
76
  if (!action) return
77
77
  if (action.kind === "source" && (data()?.sources ?? []).length === 0) {
78
- props.toast("No Kilo index found for this workspace", "info")
78
+ // Report the reason when there is one: an index that exists but cannot be
79
+ // read is fixed by installing something, not by building one.
80
+ const problems = data()?.problems ?? []
81
+ props.toast(
82
+ problems.length > 0 ? `No importable Kilo index: ${problems.join(" · ")}` : "No Kilo index found for this workspace",
83
+ problems.length > 0 ? "error" : "info",
84
+ )
79
85
  return
80
86
  }
81
87
  // No source passed: the server picks the first compatible one. Use the
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Binds the caller's workspace directory to every workspace-scoped RPC.
3
+ *
4
+ * The server plugin is instantiated for the directory the service runs in, not
5
+ * the one the user has open, so a call that omits `directory` silently acts on
6
+ * the wrong workspace. Every method below takes one for that reason — and every
7
+ * one that takes one has to be passed it, which is a per-call-site obligation
8
+ * that was already forgotten once (the palette and the settings page read and
9
+ * wrote the service's index while the footer, using the same client, correctly
10
+ * reported the user's).
11
+ *
12
+ * Injecting the directory here rather than at each call site makes that class
13
+ * of bug impossible to reintroduce: there is one place that knows which methods
14
+ * are workspace-scoped, and adding a method to that list is the only edit a new
15
+ * workspace-scoped RPC needs.
16
+ */
17
+
18
+ /** RPC methods that act on one workspace and therefore need a directory. */
19
+ export const WORKSPACE_SCOPED_METHODS = [
20
+ "settings.test",
21
+ "kilo.discover",
22
+ "status.get",
23
+ "index.build",
24
+ "index.refresh",
25
+ "index.import",
26
+ ] as const
27
+
28
+ /**
29
+ * The workspace-agnostic methods are deliberately absent: they span every
30
+ * workspace, so naming one would misreport what they act on. In this client
31
+ * that is `settings.get` and `settings.set`; the wider client adds
32
+ * `workspaces.list` and `workspace.forget`, which are global for the same
33
+ * reason.
34
+ */
35
+ function isWorkspaceScoped(method: string): boolean {
36
+ return (WORKSPACE_SCOPED_METHODS as readonly string[]).includes(method)
37
+ }
38
+
39
+ /**
40
+ * Wrap `client` so the listed methods carry `directory` unless the caller set
41
+ * one explicitly.
42
+ *
43
+ * An explicit `directory` always wins: the footer reads reactively and a test
44
+ * (or a future caller) may legitimately want to ask about a different workspace
45
+ * than the one the user is sitting in.
46
+ *
47
+ * The real client methods take a second `options` argument (cancellation
48
+ * signals and headers), so the wrapper forwards everything it is given rather
49
+ * than modelling one signature and silently dropping the rest.
50
+ *
51
+ * Reads `Object.keys` on the client, which is what the host produces: its RPC
52
+ * client is built with `Object.fromEntries`, so every method is an own
53
+ * enumerable key. A client whose methods were reachable only through a proxy
54
+ * would yield an empty wrapper, and every call would fail loudly rather than
55
+ * quietly acting on the wrong workspace.
56
+ */
57
+ export function bindWorkspace<T extends Record<string, unknown>>(client: T, workspace: () => string | undefined): T {
58
+ const bound: Record<string, unknown> = {}
59
+
60
+ for (const key of Object.keys(client)) {
61
+ const method = client[key]
62
+ if (typeof method !== "function" || !isWorkspaceScoped(key)) {
63
+ bound[key] = method
64
+ continue
65
+ }
66
+
67
+ bound[key] = (...args: unknown[]) => {
68
+ const input = args[0]
69
+ // An explicit directory wins, so it is checked before the workspace is
70
+ // resolved: a caller that already knows which workspace it means must not
71
+ // depend on the accessors answering, or on them answering at all.
72
+ const explicit =
73
+ input && typeof input === "object" && !Array.isArray(input)
74
+ ? (input as { directory?: unknown }).directory
75
+ : undefined
76
+ if (typeof explicit === "string" && explicit.trim() !== "") {
77
+ return (method as (...args: unknown[]) => unknown)(...args)
78
+ }
79
+
80
+ const directory = workspace()
81
+ if (typeof directory !== "string" || directory.trim() === "") {
82
+ // No workspace to name: the server falls back to its own directory,
83
+ // which is the only thing it can do.
84
+ return (method as (...args: unknown[]) => unknown)(...args)
85
+ }
86
+
87
+ return (method as (...args: unknown[]) => unknown)(
88
+ {
89
+ ...(input && typeof input === "object" && !Array.isArray(input) ? input : {}),
90
+ directory,
91
+ },
92
+ ...args.slice(1),
93
+ )
94
+ }
95
+ }
96
+
97
+ return bound as T
98
+ }