@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/CHANGELOG.md CHANGED
@@ -5,7 +5,71 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.1] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - **`opencode plugin add @javierkaiser/opencode-indexing` is now the documented
13
+ install.** It was already possible — the package was published and exported
14
+ `./tui` — but the README told people to run `npm install` and edit
15
+ `opencode.json` by hand, which puts the package in whatever directory they were
16
+ standing in and never tells OpenCode about it. The CLI command installs into
17
+ OpenCode's own cache, records the entry, and brings the update and removal path
18
+ (`plugin check`, `plugin update`, `plugin remove`) with it.
19
+ - **The installer has a package mode**, and picks between the two from where it
20
+ runs: a checkout installs the working tree, an installed copy installs the
21
+ published package. `npx @javierkaiser/opencode-indexing` therefore does the
22
+ right thing with no flags, including the permission rules and the restart that
23
+ `plugin add` alone does not do.
24
+ - `bin` in `package.json`, so `npx` has something to run.
25
+ - The plugin options and `ignoreKiloConfig` are documented in the README. The
26
+ option existed in the code and had tests, but appeared in no documentation.
27
+
28
+ ### Fixed
29
+
30
+ - **"No Kilo index found" for a workspace that has one.** The server plugin is
31
+ instantiated for the directory the service runs in, so every RPC that omitted a
32
+ `directory` described *that* project. `kilo.discover` took no directory at all,
33
+ which is why opening the palette in one project and importing reported nothing
34
+ while the footer, using the same client and passing the directory, looked
35
+ correct. `kilo.discover` and `settings.test` now accept a directory, and the
36
+ palette, the settings page and the index actions all send the caller's. The
37
+ directory is injected once by `bindWorkspace` rather than passed at each call
38
+ site, so a new workspace-scoped RPC is correct by default instead of correct by
39
+ remembering.
40
+ - **The tools read and indexed the service's directory.** All seven were pinned to
41
+ the plugin instance's root, so `indexing_search` in a session answered from
42
+ another codebase — results from the wrong project read as plausible — and
43
+ `indexing_import` refused an index that was sitting right there. They now use
44
+ `context.directory`, with the instance root as the fallback for hosts that do
45
+ not report one.
46
+ - **`@lancedb/lancedb` could never load, so no Kilo index was ever importable
47
+ from LanceDB.** `apache-arrow` is that package's *peerDependency*, and the
48
+ installer runs `npm install --omit=peer`, which drops peers from the tree: the
49
+ native binary loaded and then threw `Cannot find module 'apache-arrow'` on first
50
+ use. It is now declared explicitly. This also broke `vectorStore: lancedb`
51
+ outright.
52
+ - **An unreadable index was reported as a missing one.** Discovery swallowed
53
+ backend failures, so "Kilo is not running here" and "Kilo is not installed here"
54
+ both surfaced as *No Kilo index found*. It now names the path it found and the
55
+ reason it could not be read, in `indexing_status`, `indexing_build`,
56
+ `indexing_config`, `settings test kilo`, the import tool and the empty-state
57
+ toast. `indexing_build` is the one that matters most: with nothing reported, a
58
+ build in a workspace whose Kilo index could not be read re-embedded everything
59
+ without saying why.
60
+ - **`apache-arrow` is declared in `package.json`, not only in the installer.**
61
+ It is a *peer* of `@lancedb/lancedb`, and the two install paths disagreed about
62
+ peers: `install.mjs` runs `npm install --omit=peer`, which drops them, while
63
+ the npm-based install OpenCode performs keeps them. Declaring it makes the
64
+ dependency true for both instead of true for whichever path happened to be
65
+ tested.
66
+ - The installer no longer keeps its own copy of the dependency list; it derives
67
+ the installed `package.json` from the published one. That duplication is how
68
+ `apache-arrow` came to be declared for one install path and missing from the
69
+ other.
70
+ - `--package` deletes a previously copied plugin. Both sources define
71
+ `id: "opencode.indexing"` and the config directory is scanned automatically, so
72
+ leaving both would load the plugin twice.
9
73
 
10
74
  ## [0.2.0] - 2026-10-14
11
75
 
package/README.md CHANGED
@@ -26,44 +26,83 @@ index is still installed.
26
26
 
27
27
  ## Install
28
28
 
29
- ### From npm (recommended)
29
+ ### With the OpenCode CLI (recommended)
30
30
 
31
31
  ```bash
32
- npm install @javierkaiser/opencode-indexing
32
+ opencode plugin add @javierkaiser/opencode-indexing
33
33
  ```
34
34
 
35
- Then reference it from `opencode.json`:
35
+ That is the whole install. OpenCode downloads the package into its own cache and
36
+ records it in the global `plugins` array, and it loads both halves of the plugin:
37
+ the server side (`index.ts`, which registers the tools) and the terminal UI
38
+ (`tui.ts`, which adds the prompt-footer indicator and the `/indexing` page).
39
+
40
+ Update and remove it with the same command family:
41
+
42
+ ```bash
43
+ opencode plugin check # is there a newer version?
44
+ opencode plugin update # install it
45
+ opencode plugin remove @javierkaiser/opencode-indexing
46
+ ```
47
+
48
+ `plugin add` installs the plugin but knows nothing about its tools, so it does
49
+ not write permission rules. Add this one to `opencode.json` to keep the host
50
+ asking before the only tool that writes your settings file:
36
51
 
37
52
  ```jsonc
38
53
  {
39
- "plugins": [
40
- { "package": "@javierkaiser/opencode-indexing" }
54
+ "permissions": [
55
+ { "action": "indexing_config_set", "resource": "*", "effect": "ask" }
41
56
  ]
42
57
  }
43
58
  ```
44
59
 
45
- ### Global installer (alternative)
60
+ The other six tools only read. OpenCode's base policy already allows everything
61
+ (`{ "action": "*", "resource": "*", "effect": "allow" }`), so they need no rule;
62
+ write them out only if you want to tighten the set.
63
+
64
+ ### One command, permissions included
46
65
 
47
- The installer is cross-platform; it only needs Node >= 22.6.
66
+ To skip the manual rule:
48
67
 
49
68
  ```bash
50
- # Linux / macOS
51
- ./install.sh
69
+ npx @javierkaiser/opencode-indexing
70
+ ```
52
71
 
53
- # Windows
54
- powershell -ExecutionPolicy Bypass -File .\install.ps1
72
+ The installer runs `opencode plugin add`, writes the permission rules, and
73
+ restarts the service. It picks its strategy from where it runs: from an installed
74
+ copy — an npx cache, an unpacked tarball — it installs the published package;
75
+ from a checkout it installs your working tree instead, so edits take effect on
76
+ restart with no publish in between. It prints which one it chose.
55
77
 
56
- # Any platform (the wrappers just call this)
57
- node install.mjs
58
- ```
78
+ | Flag | Effect |
79
+ | --- | --- |
80
+ | `--package [spec]` | Install the published package (default spec `@latest`) |
81
+ | `--local` | Copy this directory into the config dir (development) |
82
+ | `--skip-deps` | `--local` only: copy the files, install no dependencies |
83
+ | `--no-restart` | Do not restart the service |
84
+
85
+ `install.sh` and `install.ps1` are thin wrappers over it; PowerShell also accepts
86
+ `-Local`, `-Package`, `-Spec`, `-SkipDeps` and `-NoRestart`.
59
87
 
60
- It copies the plugin to the global config directory (`$XDG_CONFIG_HOME/opencode` or `~/.config/opencode`, same path used on Windows), installs runtime deps (`ignore`, plus optional `@lancedb/lancedb@0.26.2` and the tree-sitter WASM packages), adds one `permissions` rule per tool to `opencode.json` (read tools `allow`, `indexing_config_set` `ask`), and restarts the OpenCode service.
88
+ `--package` deletes any copied plugin it finds: both sources define the same
89
+ plugin id and the config directory is scanned automatically, so leaving both
90
+ would load the plugin twice.
61
91
 
62
- Those are V2 rules — `{ "action": "indexing_search", "resource": "*", "effect": "allow" }` inside the `permissions` array. The older `permission` action map is a V1 shape that V2 ignores, so the installer removes any of its own entries from that map rather than leaving something that looks configured but grants nothing.
92
+ If no package manager is available, a `--local` install still copies the files
93
+ and the plugin runs with reduced features (line chunker instead of tree-sitter,
94
+ no LanceDB).
63
95
 
64
- Options: `--skip-deps` (copy files only), `--no-restart`. PowerShell accepts `-SkipDeps` / `-NoRestart` too.
96
+ ### Manual
65
97
 
66
- If no package manager is available the copy still happens and the plugin runs with reduced features (line chunker instead of tree-sitter, no LanceDB).
98
+ To manage the directory yourself, point `opencode.json` at it. Relative paths
99
+ resolve from the config file containing the entry:
100
+
101
+ ```jsonc
102
+ {
103
+ "plugins": ["./plugins/opencode-indexing"]
104
+ }
105
+ ```
67
106
 
68
107
  ## How it works
69
108
 
@@ -302,16 +341,36 @@ Settings are stored in `~/.config/opencode/indexing.json` (plugin options → `i
302
341
  ```jsonc
303
342
  {
304
343
  "plugins": [
305
- { "package": "./plugins/opencode-indexing", "options": {
344
+ { "package": "@javierkaiser/opencode-indexing", "options": {
306
345
  "vectorStore": "qdrant",
307
346
  "qdrantUrl": "http://localhost:6333",
308
347
  "importFromKilo": true,
309
- "autoRefresh": false
348
+ "autoRefresh": false,
349
+ "ignoreKiloConfig": false
310
350
  }}
311
351
  ]
312
352
  }
313
353
  ```
314
354
 
355
+ | Option | Meaning |
356
+ | --- | --- |
357
+ | `vectorStore` | `qdrant` or `lancedb` for the **own** index. Does not affect where an import reads from. |
358
+ | `qdrantUrl` / `qdrantApiKey` | Qdrant connection |
359
+ | `provider` / `model` / `dimension` | Embedding profile, overriding the resolved one |
360
+ | `importFromKilo` | Import a compatible Kilo index on build instead of re-embedding |
361
+ | `autoRefresh` | Refresh inline on search when few files changed |
362
+ | `enabled` | Master switch: when false the own index is neither written nor refreshed |
363
+ | `ignoreKiloConfig` | Stop reading `~/.config/kilo/kilo.jsonc` entirely |
364
+
365
+ `~/.config/kilo/kilo.jsonc` is read as a fallback for the embedding provider,
366
+ model, dimensions **and API keys**, which is what makes a free import possible:
367
+ the same model has to be used to reuse Kilo's vectors. Set `ignoreKiloConfig` to
368
+ `true` to cut that off, or override any single value with the options above, an
369
+ environment variable, or `~/.config/opencode/indexing.json` — in that order of
370
+ precedence, with plugin options winning.
371
+
372
+ ### Environment variables
373
+
315
374
  | Variable | Meaning |
316
375
  | --- | --- |
317
376
  | `KILO_INDEX_VECTOR_STORE` | `lancedb` (default) or `qdrant` for the own index |
package/index.ts CHANGED
@@ -217,6 +217,19 @@ const plugin = {
217
217
  const directory = (input as { directory?: string } | undefined)?.directory
218
218
  return typeof directory === "string" && directory.trim() !== "" ? directory : root
219
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)
220
233
  const options = (ctx.options ?? {}) as unknown as PluginOptionsInput
221
234
  const kv: KVStore = createPluginStorage(ctx.storage as never)
222
235
  const getSettings = (): Promise<IndexingSettings> => Promise.resolve(resolveSettings(options))
@@ -357,7 +370,7 @@ Behavior:
357
370
  * Extracted so the `settings.test` RPC and the `/indexing-config test`
358
371
  * subcommand answer identically instead of drifting apart.
359
372
  */
360
- async function testTarget(target: string): Promise<{ ok: boolean; message: string }> {
373
+ async function testTarget(target: string, workspace: string): Promise<{ ok: boolean; message: string }> {
361
374
  const settings = await getSettings()
362
375
  try {
363
376
  if (target === "qdrant") {
@@ -379,15 +392,23 @@ Behavior:
379
392
  return { ok: true, message: `${settings.provider}/${settings.modelId} OK (${vector?.length ?? 0} dimensions)` }
380
393
  }
381
394
  if (target === "kilo") {
382
- const sources = await discoverKiloSources(root, {
395
+ const problems: string[] = []
396
+ const sources = await discoverKiloSources(workspace, {
383
397
  qdrant: createSettingsQdrant(settings),
384
398
  homeDir: undefined,
385
399
  targetProfile: profileFor(settings),
400
+ onProblem: (message) => problems.push(message),
386
401
  })
387
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}`
388
407
  return {
389
408
  ok: sources.length > 0,
390
- 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,
391
412
  }
392
413
  }
393
414
  return { ok: false, message: `Unknown test target: ${target}` }
@@ -412,11 +433,24 @@ Behavior:
412
433
  const dimension = settings.dimension || (await store.info()).profile?.dimension || 0
413
434
  if (!dimension) throw new Error("Embedding dimension unknown")
414
435
  const targetProfile = { provider: settings.provider, modelId: settings.modelId, dimension }
415
- 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
+ })
416
442
  const source = wanted
417
443
  ? sources.find((item) => item.name === wanted)
418
444
  : (sources.find((item) => item.compatible) ?? sources[0])
419
- 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
+ }
420
454
  if (!source.compatible) throw new Error(`Source ${source.name} has an incompatible embedding profile`)
421
455
  await store.ensure(dimension, targetProfile)
422
456
  const report = await importFromKilo({
@@ -548,6 +582,7 @@ class ForgetRefusedError extends Error {}
548
582
  }
549
583
  lines.push("")
550
584
  }
585
+ const problems: string[] = []
551
586
  const sources = await discoverKiloSources(root, {
552
587
  qdrant: createSettingsQdrant(settings),
553
588
  homeDir: undefined,
@@ -556,6 +591,7 @@ class ForgetRefusedError extends Error {}
556
591
  // compare against, so the report claimed an import would work when it
557
592
  // would be refused a moment later.
558
593
  targetProfile: profileFor(settings),
594
+ onProblem: (message) => problems.push(message),
559
595
  }).catch(() => [])
560
596
  lines.push(
561
597
  "",
@@ -563,6 +599,9 @@ class ForgetRefusedError extends Error {}
563
599
  ? `**Kilo sources detected:** ${sources.map((source) => `${source.kind} ${source.name} (${source.pointsCount ?? "?"} pts, ${source.compatible ? "compatible" : "INCOMPATIBLE"})`).join("; ")}`
564
600
  : "**Kilo sources detected:** none for this workspace",
565
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(" · ")}`)
566
605
  lines.push(
567
606
  "",
568
607
  "To change settings:",
@@ -622,11 +661,15 @@ class ForgetRefusedError extends Error {}
622
661
  return
623
662
  }
624
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.
625
667
  const targets =
626
668
  parsed.target === "all" ? ["qdrant", "lancedb", "provider", "kilo"] : [parsed.target]
669
+ const workspace = (await sessionDirectory(sessionID)) ?? root
627
670
  const results: string[] = []
628
671
  for (const target of targets) {
629
- const result = await testTarget(target)
672
+ const result = await testTarget(target, workspace)
630
673
  results.push(`${target}: ${result.ok ? "OK" : "FAIL"} — ${result.message}`)
631
674
  }
632
675
  await reply(results.join("\n"))
@@ -636,11 +679,7 @@ class ForgetRefusedError extends Error {}
636
679
  // The session knows which workspace the user is in; the plugin
637
680
  // instance directory is the one the service runs in, which is
638
681
  // usually a different directory entirely.
639
- const sessionDirectory = await ctx.session
640
- .get({ sessionID } as never)
641
- .then((info: { location?: { directory?: string } }) => info?.location?.directory)
642
- .catch(() => undefined)
643
- await reply(await runImport(sessionDirectory, parsed.source))
682
+ await reply(await runImport(await sessionDirectory(sessionID), parsed.source))
644
683
  return
645
684
  }
646
685
  }
@@ -678,17 +717,19 @@ class ForgetRefusedError extends Error {}
678
717
  },
679
718
 
680
719
  "settings.test": async (input: unknown) => {
681
- return testTarget((input as { target: string }).target)
720
+ return testTarget((input as { target: string }).target, rootFor(input))
682
721
  },
683
722
 
684
- "kilo.discover": async () => {
723
+ "kilo.discover": async (input: unknown) => {
685
724
  const settings = await getSettings()
686
725
  const targetProfile =
687
726
  settings.dimension > 0 ? { provider: settings.provider, modelId: settings.modelId, dimension: settings.dimension } : undefined
688
- const sources = await discoverKiloSources(root, {
727
+ const problems: string[] = []
728
+ const sources = await discoverKiloSources(rootFor(input), {
689
729
  qdrant: createSettingsQdrant(settings),
690
730
  homeDir: undefined,
691
731
  targetProfile,
732
+ onProblem: (message) => problems.push(message),
692
733
  })
693
734
  return {
694
735
  sources: sources.map((source) => ({
@@ -701,6 +742,7 @@ class ForgetRefusedError extends Error {}
701
742
  : null,
702
743
  compatible: source.compatible,
703
744
  })),
745
+ problems,
704
746
  }
705
747
  },
706
748