@javierkaiser/opencode-indexing 0.2.0 → 0.2.3

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/install.mjs CHANGED
@@ -1,17 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cross-platform installer for opencode-indexing.
3
+ * Installer for opencode-indexing.
4
4
  *
5
5
  * Works on Windows, Linux and macOS (Node >= 22.6; Node 26 recommended).
6
6
  *
7
- * node install.mjs [--skip-deps] [--no-restart]
7
+ * node install.mjs [--local|--package] [spec] [--skip-deps] [--no-restart] [--with-codegraph]
8
+ * npx @javierkaiser/opencode-indexing
8
9
  *
9
- * What it does:
10
- * 1. Copies the plugin into <config>/opencode/plugins/opencode-indexing
11
- * (config dir honors $XDG_CONFIG_HOME, defaulting to ~/.config).
12
- * 2. Installs runtime dependencies (ignore + optional lancedb/tree-sitter).
13
- * 3. Merges tool permissions into <config>/opencode/opencode.json.
14
- * 4. Restarts the OpenCode service so the plugin loads.
10
+ * There are two ways to install, because there are two audiences.
11
+ *
12
+ * --local Copies the plugin into <config>/opencode/plugins/opencode-indexing
13
+ * and installs its runtime dependencies there. This is the
14
+ * development path: edits to the source take effect on restart,
15
+ * with no publish in between.
16
+ *
17
+ * --package Hands the job to `opencode plugin add`, which installs the
18
+ * published package into OpenCode's own cache and records it in the
19
+ * global `plugins` array. This is the user-facing path: OpenCode
20
+ * owns the install, so `opencode plugin check` and `plugin update`
21
+ * work on it.
22
+ *
23
+ * The mode is picked from where this file runs. A checkout has `.git` or `test/`
24
+ * beside it and gets `--local`; anything else — the npx cache, a global install,
25
+ * an unpacked tarball — gets `--package`. Pass either flag to override, and the
26
+ * installer says which one it chose and why.
27
+ *
28
+ * Both modes write the tool permission rules and restart the service, so either
29
+ * one leaves a working install.
15
30
  */
16
31
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
17
32
  import { homedir } from "node:os"
@@ -23,112 +38,48 @@ const argv = process.argv.slice(2)
23
38
  const has = (name) => argv.some((arg) => arg.toLowerCase() === name)
24
39
  const skipDeps = has("--skip-deps")
25
40
  const noRestart = has("--no-restart")
41
+ const withCodegraph = has("--with-codegraph")
26
42
 
27
43
  const source = dirname(fileURLToPath(import.meta.url))
44
+ const sourcePkg = JSON.parse(readFileSync(join(source, "package.json"), "utf8"))
28
45
  const configRoot = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config")
29
46
  const configDir = join(configRoot, "opencode")
30
47
  const dest = join(configDir, "plugins", "opencode-indexing")
31
48
  const configFile = join(configDir, "opencode.json")
32
49
 
33
50
  const isWindows = process.platform === "win32"
51
+ const opencodeBin = isWindows ? "opencode.cmd" : "opencode"
34
52
  const log = (message) => console.log(message)
35
53
  const warn = (message) => console.warn(message)
36
54
 
37
- log("Installing opencode-indexing")
38
- log(` from: ${source}`)
39
- log(` to: ${dest}`)
40
-
41
- // ---------------------------------------------------------------------------
42
- // 1. Copy plugin files (keep a previously installed node_modules in place)
43
- // ---------------------------------------------------------------------------
44
55
  /**
45
- * Entries copied into the plugin directory.
56
+ * Whether this looks like the repository rather than an installed copy.
46
57
  *
47
- * An allowlist rather than a blocklist: a blocklist copied whatever local debris
48
- * happened to be in the working tree — a stray `test-run.log` was shipped into
49
- * every install. This mirrors the `files` field in package.json, minus the
50
- * installers, which are not needed inside an installed plugin.
58
+ * `.git` and `test/` are both absent from the published tarball, so their
59
+ * presence means someone is running the installer out of a working tree and
60
+ * almost certainly wants the source they are editing to be what gets loaded.
51
61
  */
52
- const COPY_ENTRIES = [
53
- "index.ts",
54
- "tui.ts",
55
- "tui",
56
- "src",
57
- "package.json",
58
- "tsconfig.json",
59
- "README.md",
60
- "LICENSE",
61
- "THIRD_PARTY_NOTICES.md",
62
- ]
63
-
64
- mkdirSync(dest, { recursive: true })
65
- for (const entry of readdirSync(dest)) {
66
- if (entry === "node_modules") continue
67
- rmSync(join(dest, entry), { recursive: true, force: true })
62
+ function looksLikeCheckout() {
63
+ return existsSync(join(source, ".git")) || existsSync(join(source, "test"))
68
64
  }
69
- for (const entry of COPY_ENTRIES) {
70
- const from = join(source, entry)
71
- if (!existsSync(from)) {
72
- warn(` skipping missing entry: ${entry}`)
73
- continue
74
- }
75
- cpSync(from, join(dest, entry), { recursive: true })
76
- }
77
- log(" files copied")
78
65
 
79
- // ---------------------------------------------------------------------------
80
- // 2. Minimal runtime package.json + dependency install
81
- // ---------------------------------------------------------------------------
82
- const sourcePkg = JSON.parse(readFileSync(join(source, "package.json"), "utf8"))
83
- const installedPkg = {
84
- name: sourcePkg.name,
85
- version: sourcePkg.version,
86
- type: "module",
87
- exports: {
88
- ".": "./index.ts",
89
- "./rpc": "./src/rpc.ts",
90
- "./tui": "./tui.ts",
91
- },
92
- dependencies: {
93
- ignore: ">=7.0.0",
94
- },
95
- optionalDependencies: {
96
- "@lancedb/lancedb": "0.26.2",
97
- "web-tree-sitter": "0.25.10",
98
- "tree-sitter-wasms": "0.1.13",
99
- },
100
- // The host (OpenCode TUI) provides the renderer stack; declaring optional
101
- // peers documents the contract without forcing a duplicate install.
102
- peerDependencies: {
103
- "@opencode/theme": ">=2.0.23",
104
- "@opentui/core": ">=0.5.14",
105
- "@opentui/solid": ">=0.5.14",
106
- "solid-js": ">=1.9.12",
107
- },
108
- peerDependenciesMeta: {
109
- "@opencode/theme": { optional: true },
110
- "@opentui/core": { optional: true },
111
- "@opentui/solid": { optional: true },
112
- "solid-js": { optional: true },
113
- },
114
- }
115
- writeFileSync(join(dest, "package.json"), JSON.stringify(installedPkg, null, 2) + "\n", "utf8")
66
+ const mode = has("--local") ? "local" : has("--package") ? "package" : looksLikeCheckout() ? "local" : "package"
67
+ const reason = has("--local") || has("--package") ? "requested" : looksLikeCheckout() ? "a checkout" : "an installed copy"
116
68
 
117
- if (!skipDeps) {
118
- log(" installing runtime deps (ignore + optional lancedb/tree-sitter)...")
119
- const npm = isWindows ? "npm.cmd" : "npm"
120
- const result = spawnSync(
121
- npm,
122
- ["install", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", "--loglevel=error"],
123
- { cwd: dest, stdio: "inherit", shell: isWindows },
124
- )
125
- if (result.error || result.status !== 0) {
126
- warn(` dependency install failed (${result.error?.message ?? `exit ${result.status}`}); the plugin still runs with reduced features`)
127
- }
128
- }
69
+ // A positional argument is the package spec for --package. `@latest` rather than
70
+ // the version this file was published as, because an exact version is skipped by
71
+ // `opencode plugin check` and `plugin update`: pinning would silently opt the
72
+ // install out of the update path OpenCode provides.
73
+ const positional = argv.filter((arg) => !arg.startsWith("-"))
74
+ const spec = positional[0] ?? `${sourcePkg.name}@latest`
75
+
76
+ log(`Installing ${sourcePkg.name} ${sourcePkg.version}`)
77
+ log(` from: ${source}`)
78
+ log(` mode: ${mode} (${reason})`)
79
+ if (withCodegraph) log(" + codegraph (via --with-codegraph, package mode)")
129
80
 
130
81
  // ---------------------------------------------------------------------------
131
- // 3. Merge tool permissions into opencode.json (JSONC tolerant)
82
+ // Tool permissions (both modes)
132
83
  // ---------------------------------------------------------------------------
133
84
  function stripJsonc(input) {
134
85
  // Strip a UTF-8 BOM: editors on Windows may add one and JSON.parse rejects it.
@@ -189,51 +140,267 @@ const TOOLS = ["indexing_search", "indexing_status", "indexing_config", "indexin
189
140
  // Everything else only reads. This one writes the settings file, so the host
190
141
  // asks the user before it runs instead of taking it on trust.
191
142
  const ASK_BEFORE_RUN = new Set(["indexing_config_set"])
192
- try {
193
- let cfg = {}
194
- if (existsSync(configFile)) {
195
- cfg = JSON.parse(stripJsonc(readFileSync(configFile, "utf8")))
196
- if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) cfg = {}
197
- }
198
143
 
199
- // Drop the V1 entries this installer used to write. Anything else the user
200
- // put there is left untouched.
201
- if (cfg.permission && typeof cfg.permission === "object" && !Array.isArray(cfg.permission)) {
202
- for (const tool of TOOLS) delete cfg.permission[tool]
203
- if (Object.keys(cfg.permission).length === 0) delete cfg.permission
204
- }
144
+ /**
145
+ * Add one permission rule per tool to the global config.
146
+ *
147
+ * `opencode plugin add` installs the plugin but knows nothing about its tools,
148
+ * so a package-mode install has to write these itself or the `ask` gate on
149
+ * `indexing_config_set` is lost. The read tools would be allowed by the base
150
+ * `{ "*": "allow" }` policy anyway; they are written out so the set is visible
151
+ * and can be tightened in one place.
152
+ */
153
+ function writePermissions() {
154
+ try {
155
+ let cfg = {}
156
+ if (existsSync(configFile)) {
157
+ cfg = JSON.parse(stripJsonc(readFileSync(configFile, "utf8")))
158
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) cfg = {}
159
+ }
205
160
 
206
- if (!Array.isArray(cfg.permissions)) cfg.permissions = []
207
- for (const tool of TOOLS) {
208
- const effect = ASK_BEFORE_RUN.has(tool) ? "ask" : "allow"
209
- const rule = cfg.permissions.find((entry) => entry && entry.action === tool)
210
- if (rule) {
211
- rule.resource = "*"
212
- rule.effect = effect
213
- } else {
214
- cfg.permissions.push({ action: tool, resource: "*", effect })
161
+ // Drop the V1 entries this installer used to write. Anything else the user
162
+ // put there is left untouched.
163
+ if (cfg.permission && typeof cfg.permission === "object" && !Array.isArray(cfg.permission)) {
164
+ for (const tool of TOOLS) delete cfg.permission[tool]
165
+ if (Object.keys(cfg.permission).length === 0) delete cfg.permission
215
166
  }
216
- }
217
167
 
218
- mkdirSync(configDir, { recursive: true })
219
- writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n", "utf8")
220
- log(` permissions added to ${configFile}`)
221
- } catch (error) {
222
- warn(` could not update ${configFile}: ${error instanceof Error ? error.message : String(error)}`)
168
+ if (!Array.isArray(cfg.permissions)) cfg.permissions = []
169
+ for (const tool of TOOLS) {
170
+ const effect = ASK_BEFORE_RUN.has(tool) ? "ask" : "allow"
171
+ const rule = cfg.permissions.find((entry) => entry && entry.action === tool)
172
+ if (rule) {
173
+ rule.resource = "*"
174
+ rule.effect = effect
175
+ } else {
176
+ cfg.permissions.push({ action: tool, resource: "*", effect })
177
+ }
178
+ }
179
+
180
+ mkdirSync(configDir, { recursive: true })
181
+ writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n", "utf8")
182
+ log(` permissions added to ${configFile}`)
183
+ } catch (error) {
184
+ warn(` could not update ${configFile}: ${error instanceof Error ? error.message : String(error)}`)
185
+ }
223
186
  }
224
187
 
225
188
  // ---------------------------------------------------------------------------
226
- // 4. Restart the OpenCode service so the plugin loads
189
+ // Service restart (both modes)
227
190
  // ---------------------------------------------------------------------------
228
- if (!noRestart) {
191
+ function restartService() {
192
+ if (noRestart) return
229
193
  log(" restarting OpenCode service...")
230
- const opencode = isWindows ? "opencode.cmd" : "opencode"
231
- const result = spawnSync(opencode, ["service", "restart"], { stdio: "inherit", shell: isWindows })
194
+ const result = spawnSync(opencodeBin, ["service", "restart"], { stdio: "inherit", shell: isWindows })
232
195
  if (result.error || result.status !== 0) {
233
196
  warn(" could not restart automatically. Run: opencode service restart")
234
197
  }
235
198
  }
236
199
 
200
+ // ---------------------------------------------------------------------------
201
+ // Local mode: copy the working tree into the config directory
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Entries copied into the plugin directory.
205
+ *
206
+ * An allowlist rather than a blocklist: a blocklist copied whatever local debris
207
+ * happened to be in the working tree — a stray `test-run.log` was shipped into
208
+ * every install. This mirrors the `files` field in package.json, minus the
209
+ * installers, which are not needed inside an installed plugin.
210
+ */
211
+ const COPY_ENTRIES = [
212
+ "index.ts",
213
+ "tui.ts",
214
+ "tui",
215
+ "src",
216
+ "package.json",
217
+ "tsconfig.json",
218
+ "README.md",
219
+ "LICENSE",
220
+ "THIRD_PARTY_NOTICES.md",
221
+ ]
222
+
223
+ function installLocal() {
224
+ log(` to: ${dest}`)
225
+ mkdirSync(dest, { recursive: true })
226
+ for (const entry of readdirSync(dest)) {
227
+ if (entry === "node_modules") continue
228
+ rmSync(join(dest, entry), { recursive: true, force: true })
229
+ }
230
+ for (const entry of COPY_ENTRIES) {
231
+ const from = join(source, entry)
232
+ if (!existsSync(from)) {
233
+ warn(` skipping missing entry: ${entry}`)
234
+ continue
235
+ }
236
+ cpSync(from, join(dest, entry), { recursive: true })
237
+ }
238
+ log(" files copied")
239
+
240
+ // The installed package.json is derived, never hand-written: the list this
241
+ // used to keep in parallel is how `apache-arrow` came to be declared for the
242
+ // installer and missing from the published package, which broke every Kilo
243
+ // LanceDB import for one of the two install paths and not the other.
244
+ const installedPkg = {
245
+ name: sourcePkg.name,
246
+ version: sourcePkg.version,
247
+ type: "module",
248
+ exports: sourcePkg.exports,
249
+ dependencies: sourcePkg.dependencies,
250
+ optionalDependencies: sourcePkg.optionalDependencies,
251
+ peerDependencies: sourcePkg.peerDependencies,
252
+ peerDependenciesMeta: sourcePkg.peerDependenciesMeta,
253
+ }
254
+ writeFileSync(join(dest, "package.json"), JSON.stringify(installedPkg, null, 2) + "\n", "utf8")
255
+
256
+ if (skipDeps) {
257
+ log(" skipping dependency install (--skip-deps)")
258
+ return
259
+ }
260
+
261
+ log(" installing runtime deps (ignore + optional lancedb/tree-sitter)...")
262
+ const npm = isWindows ? "npm.cmd" : "npm"
263
+ // --omit=peer is why apache-arrow has to be a real dependency above: the
264
+ // native LanceDB binary loads without it and throws on first use.
265
+ const result = spawnSync(
266
+ npm,
267
+ ["install", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", "--loglevel=error"],
268
+ { cwd: dest, stdio: "inherit", shell: isWindows },
269
+ )
270
+ if (result.error || result.status !== 0) {
271
+ warn(` dependency install failed (${result.error?.message ?? `exit ${result.status}`}); the plugin still runs with reduced features`)
272
+ }
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Package mode: let OpenCode own the install
277
+ // ---------------------------------------------------------------------------
278
+ /** How many entries `opencode plugin list` reports for this plugin. */
279
+ function registeredCount() {
280
+ const result = spawnSync(opencodeBin, ["plugin", "list"], { encoding: "utf8", shell: isWindows })
281
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return undefined
282
+ // `plugin list` prints a table: `ID VERSION SOURCE`. Compare the first
283
+ // column exactly rather than by prefix, so a longer id that happens to start
284
+ // the same way is not counted as this plugin.
285
+ return result.stdout
286
+ .split("\n")
287
+ .map((line) => line.trim().split(/\s+/)[0])
288
+ .filter((id) => id === "opencode.indexing").length
289
+ }
290
+
291
+ function installPackage() {
292
+ log(` spec: ${spec}`)
293
+
294
+ const added = spawnSync(opencodeBin, ["plugin", "add", spec], { stdio: "inherit", shell: isWindows })
295
+ if (added.error || added.status !== 0) {
296
+ // Nothing has been removed at this point, so a failure here leaves the
297
+ // previous install exactly as it was.
298
+ warn(` \`opencode plugin add\` failed (${added.error?.message ?? `exit ${added.status}`}); the previous install was left untouched`)
299
+ return false
300
+ }
301
+
302
+ // Remove the copied plugin *after* the package is in place. Both sources
303
+ // define `id: "opencode.indexing"`, and the config directory is scanned
304
+ // automatically, so leaving the copy behind would load the plugin twice.
305
+ if (existsSync(dest)) {
306
+ rmSync(dest, { recursive: true, force: true })
307
+ log(` removed the copied plugin at ${dest} (it would load twice)`)
308
+ }
309
+
310
+ return true
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Codegraph as a companion MCP (opt-in, --with-codegraph)
315
+ // ---------------------------------------------------------------------------
316
+ /**
317
+ * Whether a `codegraph` CLI is available on PATH.
318
+ *
319
+ * A plain spawn is the cheapest, shell-independent probe and matches what
320
+ * the bench harness `resolveBinary()` does. It is treated as read-only here.
321
+ */
322
+ function hasCodegraphBin() {
323
+ try {
324
+ const probe = spawnSync("codegraph", ["--version"], { stdio: "ignore", shell: isWindows })
325
+ if (probe.error) return false
326
+ // status is either 0 or, on some wrappers, undefined-but-no-error; treat both as present
327
+ return probe.status === 0 || probe.status === null
328
+ } catch {
329
+ return false
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Install codegraph and register its MCP with OpenCode.
335
+ *
336
+ * The only consent gate is the flag itself, as the user requested: this is
337
+ * an install step, not a recommendation. A failure in either half is reported
338
+ * and tolerated — the plugin is already installed and still works with
339
+ * semantic search only.
340
+ */
341
+ function installCodegraph() {
342
+ log(" installing codegraph as a companion MCP server...")
343
+
344
+ if (!hasCodegraphBin()) {
345
+ log(" codegraph CLI missing; running npm install -g codegraph...")
346
+ const npm = isWindows ? "npm.cmd" : "npm"
347
+ // --omit=... is not needed here: codegraph stands on its own, and a global
348
+ // install is what its own documentation assumes.
349
+ const installed = spawnSync(npm, ["install", "-g", "codegraph"], {
350
+ stdio: "inherit",
351
+ shell: isWindows,
352
+ })
353
+ if (installed.error || installed.status !== 0) {
354
+ warn(" could not install codegraph (npm install -g codegraph failed); skipping MCP registration")
355
+ return
356
+ }
357
+ }
358
+
359
+ const register = spawnSync("codegraph", ["install", "--target", "opencode"], {
360
+ stdio: "inherit",
361
+ shell: isWindows,
362
+ })
363
+ if (register.error || register.status !== 0) {
364
+ warn(" codegraph is present but `codegraph install --target opencode` failed")
365
+ warn(" run it manually once to finish MCP registration: codegraph install --target opencode")
366
+ return
367
+ }
368
+
369
+ log(" codegraph installed and registered with OpenCode")
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Run
374
+ // ---------------------------------------------------------------------------
375
+ if (mode === "local") {
376
+ installLocal()
377
+ if (withCodegraph) warn(" --with-codegraph is ignored in --local mode; codegraph is not installed there")
378
+ } else {
379
+ if (!installPackage()) {
380
+ warn(" not writing permissions or restarting: the install did not complete")
381
+ process.exit(1)
382
+ }
383
+ if (withCodegraph) installCodegraph()
384
+
385
+ }
386
+
387
+ writePermissions()
388
+ // Before the count below, not after: `plugin list` asks the running service,
389
+ // which still holds the configuration from before this install.
390
+ restartService()
391
+
392
+ if (mode === "package") {
393
+ const count = registeredCount()
394
+ if (count !== undefined && count > 1) {
395
+ warn(` ${count} entries report id opencode.indexing; only one should. Check \`opencode plugin list\`.`)
396
+ }
397
+ }
398
+
237
399
  log("Done.")
238
- log("Verify with: opencode api get /api/info")
239
- log("Then run in a session: indexing_status (or /indexing-config in any client)")
400
+ if (mode === "package") {
401
+ log("Update with: opencode plugin update")
402
+ log("Remove with: opencode plugin remove " + spec)
403
+ } else {
404
+ log("Verify with: opencode api get /api/info")
405
+ log("Then run in a session: indexing_status (or /indexing-config in any client)")
406
+ }
package/install.ps1 CHANGED
@@ -3,8 +3,11 @@
3
3
  # and macOS all run the same code instead of drifting copies.
4
4
  #
5
5
  # Usage:
6
- # powershell -ExecutionPolicy Bypass -File install.ps1 [-SkipDeps] [-NoRestart]
6
+ # powershell -ExecutionPolicy Bypass -File install.ps1 [-Local|-Package] [-Spec <spec>] [-SkipDeps] [-NoRestart]
7
7
  param(
8
+ [switch]$Local,
9
+ [switch]$Package,
10
+ [string]$Spec,
8
11
  [switch]$SkipDeps,
9
12
  [switch]$NoRestart
10
13
  )
@@ -12,6 +15,9 @@ param(
12
15
  $ErrorActionPreference = "Stop"
13
16
  $script = Join-Path $PSScriptRoot "install.mjs"
14
17
  $forward = @()
18
+ if ($Local) { $forward += "--local" }
19
+ if ($Package) { $forward += "--package" }
20
+ if ($Spec) { $forward += $Spec }
15
21
  if ($SkipDeps) { $forward += "--skip-deps" }
16
22
  if ($NoRestart) { $forward += "--no-restart" }
17
23
 
package/install.sh CHANGED
@@ -3,7 +3,7 @@
3
3
  # Thin wrapper: the cross-platform logic lives in install.mjs.
4
4
  #
5
5
  # Usage:
6
- # sh install.sh [--skip-deps] [--no-restart]
6
+ # sh install.sh [--local|--package] [spec] [--skip-deps] [--no-restart]
7
7
  set -eu
8
8
 
9
9
  dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@javierkaiser/opencode-indexing",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "OpenCode plugin: semantic code search that reuses Kilo Code indexes (Qdrant/LanceDB) and maintains its own independent index",
5
5
  "license": "MIT",
6
6
  "author": "Carlos Javier Lust <javierkaiser@gmail.com>",
@@ -29,6 +29,9 @@
29
29
  "./rpc": "./src/rpc.ts",
30
30
  "./tui": "./tui.ts"
31
31
  },
32
+ "bin": {
33
+ "opencode-indexing": "./install.mjs"
34
+ },
32
35
  "files": [
33
36
  "index.ts",
34
37
  "tui.ts",
@@ -61,7 +64,8 @@
61
64
  "optionalDependencies": {
62
65
  "@lancedb/lancedb": "0.26.2",
63
66
  "tree-sitter-wasms": "0.1.13",
64
- "web-tree-sitter": "0.25.10"
67
+ "web-tree-sitter": "0.25.10",
68
+ "apache-arrow": "18.1.0"
65
69
  },
66
70
  "devDependencies": {
67
71
  "@opencode/plugin": "2.0.23",
package/src/import.ts CHANGED
@@ -6,6 +6,7 @@ import { getKiloStoreInfo, resolveKiloCollection } from "./kilo-store.ts"
6
6
  import { readLanceDbInfoAt, readLanceDbBatch, lanceDbName } from "./lancedb-store.ts"
7
7
  import { kiloLanceDbDirectory } from "./settings.ts"
8
8
  import { recordWorkspace } from "./workspaces.ts"
9
+ import { describeError } from "./errors.ts"
9
10
  import type { EmbeddingProfile, QdrantPoint, IndexReport } from "./types.ts"
10
11
  import type { VectorStoreAdapter } from "./vector-store.ts"
11
12
  import { profileKey } from "./registry.ts"
@@ -47,9 +48,25 @@ export interface ImportReport {
47
48
  /** Read-only description of Kilo sources available for a workspace. */
48
49
  export async function discoverKiloSources(
49
50
  root: string,
50
- options: { qdrant?: Qdrant; lancedbDirectory?: string; homeDir?: string; targetProfile?: EmbeddingProfile },
51
+ options: {
52
+ qdrant?: Qdrant
53
+ lancedbDirectory?: string
54
+ homeDir?: string
55
+ targetProfile?: EmbeddingProfile
56
+ /**
57
+ * Called when a source exists but could not be read.
58
+ *
59
+ * A backend that is down and a source that does not exist look identical
60
+ * once the error is swallowed, and both are reported to the user as "no Kilo
61
+ * index found". That is a wrong answer in the first case: the index is
62
+ * sitting on disk, and what is missing is a native module or a running
63
+ * server. The caller decides where the message surfaces.
64
+ */
65
+ onProblem?: (message: string) => void
66
+ },
51
67
  ): Promise<KiloSourceInfo[]> {
52
68
  const sources: KiloSourceInfo[] = []
69
+ const problem = options.onProblem ?? ((): void => {})
53
70
  const isCompatible = (profile?: EmbeddingProfile): boolean => {
54
71
  if (!options.targetProfile || !profile) return true
55
72
  return profileKey(profile) === profileKey(options.targetProfile)
@@ -71,15 +88,17 @@ export async function discoverKiloSources(
71
88
  compatible: isCompatible(info.profile),
72
89
  })
73
90
  }
74
- } catch {
75
- // Qdrant unreachable: skip silently; lancedb may still be available.
91
+ } catch (error) {
92
+ // A backend that answers with no matching collection is not a failure; a
93
+ // backend that never answered is.
94
+ problem(`Could not reach the Qdrant server to look for a Kilo index: ${describeError(error)}`)
76
95
  }
77
96
 
78
97
  // LanceDB source
79
- try {
80
- const directory = options.lancedbDirectory ?? kiloLanceDbDirectory(options.homeDir)
81
- const dbPath = path.join(directory, lanceDbName(root))
82
- if (fs.existsSync(dbPath)) {
98
+ const directory = options.lancedbDirectory ?? kiloLanceDbDirectory(options.homeDir)
99
+ const dbPath = path.join(directory, lanceDbName(root))
100
+ if (fs.existsSync(dbPath)) {
101
+ try {
83
102
  const info = await readLanceDbInfoAt(dbPath)
84
103
  if (info.vectorExists) {
85
104
  sources.push({
@@ -92,9 +111,11 @@ export async function discoverKiloSources(
92
111
  compatible: isCompatible(info.profile),
93
112
  })
94
113
  }
114
+ } catch (error) {
115
+ // The directory is there, so this is "found but unreadable", never
116
+ // "not indexed". Say so, with the reason.
117
+ problem(`A Kilo LanceDB index exists at ${dbPath} but could not be read: ${describeError(error)}`)
95
118
  }
96
- } catch {
97
- // LanceDB unavailable: skip.
98
119
  }
99
120
 
100
121
  return sources
package/src/indexer.ts CHANGED
@@ -157,10 +157,15 @@ export async function runIndex(deps: IndexerDeps, options: IndexOptions): Promis
157
157
  if (shouldImport) {
158
158
  await deps.onProgress?.({ phase: "importing", processed: 0, total: 0 })
159
159
  try {
160
+ // Collected rather than thrown: discovery no longer fails on an
161
+ // unreadable source, and the caller has to be told why the scan below is
162
+ // about to re-embed the whole workspace at their expense.
163
+ const problems: string[] = []
160
164
  const sources = await discoverKiloSources(root, {
161
165
  qdrant,
162
166
  homeDir: undefined,
163
167
  targetProfile: profile,
168
+ onProblem: (message) => problems.push(message),
164
169
  })
165
170
  const source = sources.find((item) => item.compatible) ?? sources[0]
166
171
  if (source && source.compatible) {
@@ -190,6 +195,12 @@ export async function runIndex(deps: IndexerDeps, options: IndexOptions): Promis
190
195
  report.errors.push(
191
196
  `Kilo index found (${source.name}) but its embedding profile does not match ${profile.provider}:${profile.modelId}:${profile.dimension}; indexing from scratch`,
192
197
  )
198
+ } else if (problems.length > 0) {
199
+ // No usable source and a reason why. Without this the build reads as a
200
+ // workspace that has no Kilo index at all, and the full re-embedding
201
+ // that follows looks like the normal path rather than the expensive
202
+ // fallback it is.
203
+ report.errors.push(`${problems.join(" · ")} Indexing from scratch instead.`)
193
204
  }
194
205
  } catch (error) {
195
206
  report.errors.push(`import failed: ${describeError(error)}`)