@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/CHANGELOG.md CHANGED
@@ -5,7 +5,84 @@ 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.3] - 2026-10-16
9
+
10
+ ### Added
11
+ - `install.mjs` accepts `--with-codegraph`. It is **off by default** and only
12
+ has effect in `--package` mode, as consent comes from passing the flag. When
13
+ active it detects an existing `codegraph` CLI, installs it globally (`npm install
14
+ -g codegraph`) if it is missing, and registers its MCP with OpenCode via
15
+ `codegraph install --target opencode`. A failure in either step is reported and
16
+ does not cancel this plugin's own install.
17
+ - The README now names `npx` (not `opencode plugin add`) as the recommended
18
+ install method, and shows the companion install in the recommended command:
19
+ `npx @javierkaiser/opencode-indexing --with-codegraph`.
20
+
21
+ ## [0.2.1] - 2026-10-15
22
+
23
+ ### Added
24
+
25
+ - **`opencode plugin add @javierkaiser/opencode-indexing` is now the documented
26
+ install.** It was already possible — the package was published and exported
27
+ `./tui` — but the README told people to run `npm install` and edit
28
+ `opencode.json` by hand, which puts the package in whatever directory they were
29
+ standing in and never tells OpenCode about it. The CLI command installs into
30
+ OpenCode's own cache, records the entry, and brings the update and removal path
31
+ (`plugin check`, `plugin update`, `plugin remove`) with it.
32
+ - **The installer has a package mode**, and picks between the two from where it
33
+ runs: a checkout installs the working tree, an installed copy installs the
34
+ published package. `npx @javierkaiser/opencode-indexing` therefore does the
35
+ right thing with no flags, including the permission rules and the restart that
36
+ `plugin add` alone does not do.
37
+ - `bin` in `package.json`, so `npx` has something to run.
38
+ - The plugin options and `ignoreKiloConfig` are documented in the README. The
39
+ option existed in the code and had tests, but appeared in no documentation.
40
+
41
+ ### Fixed
42
+
43
+ - **"No Kilo index found" for a workspace that has one.** The server plugin is
44
+ instantiated for the directory the service runs in, so every RPC that omitted a
45
+ `directory` described *that* project. `kilo.discover` took no directory at all,
46
+ which is why opening the palette in one project and importing reported nothing
47
+ while the footer, using the same client and passing the directory, looked
48
+ correct. `kilo.discover` and `settings.test` now accept a directory, and the
49
+ palette, the settings page and the index actions all send the caller's. The
50
+ directory is injected once by `bindWorkspace` rather than passed at each call
51
+ site, so a new workspace-scoped RPC is correct by default instead of correct by
52
+ remembering.
53
+ - **The tools read and indexed the service's directory.** All seven were pinned to
54
+ the plugin instance's root, so `indexing_search` in a session answered from
55
+ another codebase — results from the wrong project read as plausible — and
56
+ `indexing_import` refused an index that was sitting right there. They now use
57
+ `context.directory`, with the instance root as the fallback for hosts that do
58
+ not report one.
59
+ - **`@lancedb/lancedb` could never load, so no Kilo index was ever importable
60
+ from LanceDB.** `apache-arrow` is that package's *peerDependency*, and the
61
+ installer runs `npm install --omit=peer`, which drops peers from the tree: the
62
+ native binary loaded and then threw `Cannot find module 'apache-arrow'` on first
63
+ use. It is now declared explicitly. This also broke `vectorStore: lancedb`
64
+ outright.
65
+ - **An unreadable index was reported as a missing one.** Discovery swallowed
66
+ backend failures, so "Kilo is not running here" and "Kilo is not installed here"
67
+ both surfaced as *No Kilo index found*. It now names the path it found and the
68
+ reason it could not be read, in `indexing_status`, `indexing_build`,
69
+ `indexing_config`, `settings test kilo`, the import tool and the empty-state
70
+ toast. `indexing_build` is the one that matters most: with nothing reported, a
71
+ build in a workspace whose Kilo index could not be read re-embedded everything
72
+ without saying why.
73
+ - **`apache-arrow` is declared in `package.json`, not only in the installer.**
74
+ It is a *peer* of `@lancedb/lancedb`, and the two install paths disagreed about
75
+ peers: `install.mjs` runs `npm install --omit=peer`, which drops them, while
76
+ the npm-based install OpenCode performs keeps them. Declaring it makes the
77
+ dependency true for both instead of true for whichever path happened to be
78
+ tested.
79
+ - The installer no longer keeps its own copy of the dependency list; it derives
80
+ the installed `package.json` from the published one. That duplication is how
81
+ `apache-arrow` came to be declared for one install path and missing from the
82
+ other.
83
+ - `--package` deletes a previously copied plugin. Both sources define
84
+ `id: "opencode.indexing"` and the config directory is scanned automatically, so
85
+ leaving both would load the plugin twice.
9
86
 
10
87
  ## [0.2.0] - 2026-10-14
11
88
 
package/README.md CHANGED
@@ -26,44 +26,82 @@ index is still installed.
26
26
 
27
27
  ## Install
28
28
 
29
- ### From npm (recommended)
29
+ ### With npx (recommended)
30
30
 
31
31
  ```bash
32
- npm install @javierkaiser/opencode-indexing
32
+ npx @javierkaiser/opencode-indexing
33
33
  ```
34
34
 
35
- Then reference it from `opencode.json`:
35
+ This is the whole install: the installer runs `opencode plugin add`, writes the
36
+ permission rules, restarts the service, and, when you ask for it, also installs
37
+ and registers the companion `codegraph`
38
+ MCP. It picks its strategy from where it runs — from an npx cache or an
39
+ unpacked tarball it installs the published package, from a checkout it
40
+ installs your working tree instead — so edits take effect on restart with no
41
+ publish in between. It prints which one it chose.
36
42
 
37
- ```jsonc
38
- {
39
- "plugins": [
40
- { "package": "@javierkaiser/opencode-indexing" }
41
- ]
42
- }
43
+ Add `--with-codegraph` to install and register codegraph as part of the same run
44
+ (the flag is opt-in and only has effect in the published-package mode):
45
+
46
+ ```bash
47
+ npx @javierkaiser/opencode-indexing --with-codegraph
43
48
  ```
44
49
 
45
- ### Global installer (alternative)
50
+ | Flag | Effect |
51
+ | --- | --- |
52
+ | `--package [spec]` | Install the published package (default spec `@latest`) |
53
+ | `--local` | Copy this directory into the config dir (development) |
54
+ | `--skip-deps` | `--local` only: copy the files, install no dependencies |
55
+ | `--no-restart` | Do not restart the service |
56
+ | `--with-codegraph` | Install and register codegraph as a companion MCP (opt-in, `--package` only) |
46
57
 
47
- The installer is cross-platform; it only needs Node >= 22.6.
58
+ ### With the OpenCode CLI
48
59
 
49
60
  ```bash
50
- # Linux / macOS
51
- ./install.sh
61
+ opencode plugin add @javierkaiser/opencode-indexing
62
+ ```
52
63
 
53
- # Windows
54
- powershell -ExecutionPolicy Bypass -File .\install.ps1
64
+ OpenCode downloads the package into its own cache and records it in the global
65
+ `plugins` array, and it loads both halves of the plugin: the server side
66
+ (`index.ts`, which registers the tools) and the terminal UI (`tui.ts`, which
67
+ adds the prompt-footer indicator and the `/indexing` page). It brings `plugin
68
+ check`, `plugin update` and `plugin remove` with it, but it cannot write
69
+ permission rules — add this one to `opencode.json` to keep the host asking
70
+ before the only tool that writes your settings file:
55
71
 
56
- # Any platform (the wrappers just call this)
57
- node install.mjs
72
+ ```jsonc
73
+ {
74
+ "permissions": [
75
+ { "action": "indexing_config_set", "resource": "*", "effect": "ask" }
76
+ ]
77
+ }
58
78
  ```
59
79
 
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.
80
+ The other six tools only read. OpenCode's base policy already allows everything
81
+ (`{ "action": "*", "resource": "*", "effect": "allow" }`), so they need no rule;
82
+ write them out only if you want to tighten the set.
83
+
84
+ `install.sh` and `install.ps1` are thin wrappers over it; PowerShell also accepts
85
+ `-Local`, `-Package`, `-Spec`, `-SkipDeps` and `-NoRestart`.
61
86
 
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.
87
+ `--package` deletes any copied plugin it finds: both sources define the same
88
+ plugin id and the config directory is scanned automatically, so leaving both
89
+ would load the plugin twice.
63
90
 
64
- Options: `--skip-deps` (copy files only), `--no-restart`. PowerShell accepts `-SkipDeps` / `-NoRestart` too.
91
+ If no package manager is available, a `--local` install still copies the files
92
+ and the plugin runs with reduced features (line chunker instead of tree-sitter,
93
+ no LanceDB).
65
94
 
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).
95
+ ### Manual
96
+
97
+ To manage the directory yourself, point `opencode.json` at it. Relative paths
98
+ resolve from the config file containing the entry:
99
+
100
+ ```jsonc
101
+ {
102
+ "plugins": ["./plugins/opencode-indexing"]
103
+ }
104
+ ```
67
105
 
68
106
  ## How it works
69
107
 
@@ -302,16 +340,36 @@ Settings are stored in `~/.config/opencode/indexing.json` (plugin options → `i
302
340
  ```jsonc
303
341
  {
304
342
  "plugins": [
305
- { "package": "./plugins/opencode-indexing", "options": {
343
+ { "package": "@javierkaiser/opencode-indexing", "options": {
306
344
  "vectorStore": "qdrant",
307
345
  "qdrantUrl": "http://localhost:6333",
308
346
  "importFromKilo": true,
309
- "autoRefresh": false
347
+ "autoRefresh": false,
348
+ "ignoreKiloConfig": false
310
349
  }}
311
350
  ]
312
351
  }
313
352
  ```
314
353
 
354
+ | Option | Meaning |
355
+ | --- | --- |
356
+ | `vectorStore` | `qdrant` or `lancedb` for the **own** index. Does not affect where an import reads from. |
357
+ | `qdrantUrl` / `qdrantApiKey` | Qdrant connection |
358
+ | `provider` / `model` / `dimension` | Embedding profile, overriding the resolved one |
359
+ | `importFromKilo` | Import a compatible Kilo index on build instead of re-embedding |
360
+ | `autoRefresh` | Refresh inline on search when few files changed |
361
+ | `enabled` | Master switch: when false the own index is neither written nor refreshed |
362
+ | `ignoreKiloConfig` | Stop reading `~/.config/kilo/kilo.jsonc` entirely |
363
+
364
+ `~/.config/kilo/kilo.jsonc` is read as a fallback for the embedding provider,
365
+ model, dimensions **and API keys**, which is what makes a free import possible:
366
+ the same model has to be used to reuse Kilo's vectors. Set `ignoreKiloConfig` to
367
+ `true` to cut that off, or override any single value with the options above, an
368
+ environment variable, or `~/.config/opencode/indexing.json` — in that order of
369
+ precedence, with plugin options winning.
370
+
371
+ ### Environment variables
372
+
315
373
  | Variable | Meaning |
316
374
  | --- | --- |
317
375
  | `KILO_INDEX_VECTOR_STORE` | `lancedb` (default) or `qdrant` for the own index |
@@ -423,7 +481,7 @@ This plugin deliberately stays on the semantic side and coexists with grep/read
423
481
  (see the tool descriptions and the bundled skill). A code-graph mode (tree-sitter
424
482
  symbol/edge extraction into SQLite, exposed as `indexing_graph_*` tools that
425
483
  chain after `indexing_search`) is a candidate for a future major version; the
426
- tree-sitter infrastructure is already in place.
484
+ tree-sitter infrastructure is already in place. Independently, the installer can itself install and register codegraph as a companion MCP (`--with-codegraph`, opt-in, published-package mode only).
427
485
 
428
486
  ## Development
429
487
 
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