@javierkaiser/opencode-indexing 0.2.0 → 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
  }
@@ -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
@@ -116,7 +132,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
116
132
  export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<SettingsPageData> {
117
133
  const [settingsResponse, sourcesResponse, status, workspaces] = await Promise.all([
118
134
  rpc["settings.get"]({}).catch(() => ({ settings: UNKNOWN_SETTINGS })),
119
- rpc["kilo.discover"]().catch(() => ({ sources: [] as KiloSourceView[] })),
135
+ rpc["kilo.discover"]({}).catch(() => ({ sources: [] as KiloSourceView[], problems: [] as string[] })),
120
136
  rpc["status.get"]({ checkFreshness: false }).catch(() => ({ summary: "(status unavailable)", recommendation: "" })),
121
137
  // loadWorkspaces already degrades to an empty view, so it cannot reject.
122
138
  loadWorkspaces(rpc),
@@ -124,6 +140,7 @@ export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<Settin
124
140
  return {
125
141
  settings: settingsResponse.settings,
126
142
  sources: sourcesResponse.sources,
143
+ problems: sourcesResponse.problems ?? [],
127
144
  status,
128
145
  workspaces,
129
146
  }
@@ -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
+ }
package/tui.ts CHANGED
@@ -24,6 +24,7 @@ import type { ThemeLike } from "./tui/page-style.ts"
24
24
  import { PAGE_ACTIONS, PAGE_ROWS, type SettingsPageRpc } from "./tui/settings-page-data.ts"
25
25
  import { footerAction, footerState, type IndexingState } from "./tui/footer.ts"
26
26
  import { activeWorkspace, type WorkspaceSources } from "./tui/footer.ts"
27
+ import { bindWorkspace } from "./tui/workspace-rpc.ts"
27
28
  import { describeRow, forgetWorkspaceFlow, type WorkspacesRpc } from "./tui/workspaces.ts"
28
29
  import { pagePalette } from "./tui/page-style.ts"
29
30
  import type { FooterHandle, FooterState } from "./tui/footer-view.tsx"
@@ -48,8 +49,15 @@ const CUSTOM_MODEL = "\u0000custom-model"
48
49
  type RpcClient = {
49
50
  "settings.get": (input: Record<string, never>) => Promise<{ settings: SettingsView }>
50
51
  "settings.set": (input: { patch: Record<string, unknown> }) => Promise<{ settings: SettingsView }>
51
- "settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo" }) => Promise<{ ok: boolean; message: string }>
52
- "kilo.discover": () => Promise<{ sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }> }>
52
+ "settings.test": (input: {
53
+ target: "qdrant" | "lancedb" | "provider" | "kilo"
54
+ directory?: string
55
+ }) => Promise<{ ok: boolean; message: string }>
56
+ "kilo.discover": (input?: { directory?: string }) => Promise<{
57
+ sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }>
58
+ /** Sources that exist but could not be read. */
59
+ problems?: string[]
60
+ }>
53
61
  "status.get": (input: { checkFreshness?: boolean; directory?: string }) => Promise<{ summary: string; recommendation: string; ownKind: string; ownStore: string; ownPoints?: number | null; ownComplete?: boolean | null; kiloCollection?: string | null; directory?: string; ownStoreReachable?: boolean }>
54
62
  "index.build": (input: { rebuild?: boolean; skipImport?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
55
63
  "index.refresh": (input: { maxFiles?: number; directory?: string }) => Promise<{ summary: string; error?: string }>
@@ -77,10 +85,46 @@ interface SettingsView {
77
85
  const plugin = {
78
86
  id: ID,
79
87
  async setup(context: Plugin.Context): Promise<void> {
88
+ /**
89
+ * The workspace the caller is working in.
90
+ *
91
+ * The server plugin serves its own instance directory, so every RPC that
92
+ * reads or writes an index must be told which workspace it is about. Without
93
+ * this the footer would report — and the palette command would index — the
94
+ * service's directory instead of the user's: the ACTIVE tab is what follows
95
+ * the user, and the instance directory is the fallback for a host with tabs
96
+ * switched off.
97
+ *
98
+ * Every accessor is optional: a host may expose neither `ui.tabs` nor
99
+ * `data.session`, and resolving the workspace must degrade rather than
100
+ * throw. Read from a reactive context (the footer slot render) the lookups
101
+ * re-run when the host's tab or session state changes.
102
+ *
103
+ * Declared before `rpc` because `rpc` binds the directory into every
104
+ * workspace-scoped call: a call made before this binding exists would be
105
+ * answered against the service's own directory.
106
+ */
107
+ const workspaceSources: WorkspaceSources = {
108
+ tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
109
+ tabs: () => context.ui.tabs?.list?.() ?? [],
110
+ sessions: () => context.data?.session?.list?.() ?? [],
111
+ instanceDirectory: () => context.location?.directory,
112
+ }
113
+
114
+ const workspace = (): string | undefined => activeWorkspace(workspaceSources)
115
+
80
116
  // Resolve the RPC subclient lazily: the TUI may start before the server
81
117
  // plugin is ready, and calls are retried on first use.
118
+ //
119
+ // Every call goes through `bindWorkspace`, so a workspace-scoped method
120
+ // always names the caller's directory. Passing it per call site is what
121
+ // failed before: the footer did it and the palette did not, and the palette
122
+ // silently read and wrote the service's index instead of the user's.
82
123
  const rpc = (): RpcClient =>
83
- (context.client as unknown as { rpc: (def: unknown, options?: unknown) => RpcClient }).rpc(IndexingRpc)
124
+ bindWorkspace(
125
+ (context.client as unknown as { rpc: (def: unknown, options?: unknown) => RpcClient }).rpc(IndexingRpc),
126
+ workspace,
127
+ )
84
128
 
85
129
  const toast = (message: string, variant: "success" | "error" | "info" = "info"): void => {
86
130
  context.ui.toast.show({ title: "Indexing", message, variant, duration: 5000 })
@@ -446,14 +490,24 @@ const plugin = {
446
490
 
447
491
  async function kiloMenu(): Promise<void> {
448
492
  let sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }> = []
493
+ let problems: string[] = []
449
494
  try {
450
- sources = (await rpc()["kilo.discover"]()).sources
495
+ const found = await rpc()["kilo.discover"]({})
496
+ sources = found.sources
497
+ problems = found.problems ?? []
451
498
  } catch (error) {
452
499
  fail(error)
453
500
  return
454
501
  }
455
502
  if (sources.length === 0) {
456
- toast("No Kilo index found for this workspace", "info")
503
+ // "Found but unreadable" and "never indexed" look identical from here,
504
+ // and only one of them is fixed by indexing. Say which one it is.
505
+ toast(
506
+ problems.length > 0
507
+ ? `No importable Kilo index: ${problems.join(" · ")}`
508
+ : "No Kilo index found for this workspace",
509
+ problems.length > 0 ? "error" : "info",
510
+ )
457
511
  return
458
512
  }
459
513
  const choice = await context.ui.dialog.select({
@@ -502,7 +556,7 @@ const plugin = {
502
556
  toast("Running…", "info")
503
557
  try {
504
558
  if (action === "refresh") {
505
- reportRun(await rpc()["index.refresh"]({ directory: workspace() }))
559
+ reportRun(await rpc()["index.refresh"]({}))
506
560
  } else if (action === "build") {
507
561
  reportRun(await rpc()["index.build"]({}))
508
562
  } else if (action === "build-scratch") {
@@ -582,30 +636,6 @@ const plugin = {
582
636
  return route?.type === "plugin" && route.name === SETTINGS_PAGE_NAME
583
637
  }
584
638
 
585
- /**
586
- * The workspace the caller is working in.
587
- *
588
- * The server plugin serves its own instance directory, so every RPC that
589
- * reads or writes an index must be told which workspace it is about. Without
590
- * this the footer would report — and the palette command would index — the
591
- * service's directory instead of the user's: the ACTIVE tab is what follows
592
- * the user, and the instance directory is the fallback for a host with tabs
593
- * switched off.
594
- *
595
- * Every accessor is optional: a host may expose neither `ui.tabs` nor
596
- * `data.session`, and resolving the workspace must degrade rather than
597
- * throw. Read from a reactive context (the footer slot render) the lookups
598
- * re-run when the host's tab or session state changes.
599
- */
600
- const workspaceSources: WorkspaceSources = {
601
- tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
602
- tabs: () => context.ui.tabs?.list?.() ?? [],
603
- sessions: () => context.data?.session?.list?.() ?? [],
604
- instanceDirectory: () => context.location?.directory,
605
- }
606
-
607
- const workspace = (): string | undefined => activeWorkspace(workspaceSources)
608
-
609
639
  /** Opens the dialog editor, holding the page keys off for its duration. */
610
640
  async function openEditor(): Promise<void> {
611
641
  dialogOpen = true
@@ -820,8 +850,8 @@ const plugin = {
820
850
  toast("Indexing started…", "info")
821
851
  const summary =
822
852
  from.state === "stale"
823
- ? await rpc()["index.refresh"]({ directory: workspace() })
824
- : await rpc()["index.build"]({ skipImport: false, directory: workspace() })
853
+ ? await rpc()["index.refresh"]({})
854
+ : await rpc()["index.build"]({ skipImport: false })
825
855
  // A failed run comes back as data, not as a rejection: the reason is far
826
856
  // more useful than "something went wrong", and it is already readable.
827
857
  reportRun(summary)