@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 +65 -1
- package/README.md +79 -20
- package/index.ts +56 -14
- package/install.mjs +229 -128
- package/install.ps1 +7 -1
- package/install.sh +1 -1
- package/package.json +6 -2
- package/src/import.ts +30 -9
- package/src/indexer.ts +11 -0
- package/src/rpc.ts +26 -1
- package/src/status.ts +14 -2
- package/src/tools.ts +65 -20
- package/tui/settings-page-data.ts +24 -7
- package/tui/settings-page.tsx +7 -1
- package/tui/workspace-rpc.ts +98 -0
- package/tui.ts +62 -32
package/install.mjs
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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]
|
|
8
|
+
* npx @javierkaiser/opencode-indexing
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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"
|
|
@@ -25,110 +40,44 @@ const skipDeps = has("--skip-deps")
|
|
|
25
40
|
const noRestart = has("--no-restart")
|
|
26
41
|
|
|
27
42
|
const source = dirname(fileURLToPath(import.meta.url))
|
|
43
|
+
const sourcePkg = JSON.parse(readFileSync(join(source, "package.json"), "utf8"))
|
|
28
44
|
const configRoot = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config")
|
|
29
45
|
const configDir = join(configRoot, "opencode")
|
|
30
46
|
const dest = join(configDir, "plugins", "opencode-indexing")
|
|
31
47
|
const configFile = join(configDir, "opencode.json")
|
|
32
48
|
|
|
33
49
|
const isWindows = process.platform === "win32"
|
|
50
|
+
const opencodeBin = isWindows ? "opencode.cmd" : "opencode"
|
|
34
51
|
const log = (message) => console.log(message)
|
|
35
52
|
const warn = (message) => console.warn(message)
|
|
36
53
|
|
|
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
54
|
/**
|
|
45
|
-
*
|
|
55
|
+
* Whether this looks like the repository rather than an installed copy.
|
|
46
56
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* installers, which are not needed inside an installed plugin.
|
|
57
|
+
* `.git` and `test/` are both absent from the published tarball, so their
|
|
58
|
+
* presence means someone is running the installer out of a working tree and
|
|
59
|
+
* almost certainly wants the source they are editing to be what gets loaded.
|
|
51
60
|
*/
|
|
52
|
-
|
|
53
|
-
"
|
|
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 })
|
|
61
|
+
function looksLikeCheckout() {
|
|
62
|
+
return existsSync(join(source, ".git")) || existsSync(join(source, "test"))
|
|
68
63
|
}
|
|
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
64
|
|
|
79
|
-
|
|
80
|
-
|
|
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")
|
|
65
|
+
const mode = has("--local") ? "local" : has("--package") ? "package" : looksLikeCheckout() ? "local" : "package"
|
|
66
|
+
const reason = has("--local") || has("--package") ? "requested" : looksLikeCheckout() ? "a checkout" : "an installed copy"
|
|
116
67
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
}
|
|
68
|
+
// A positional argument is the package spec for --package. `@latest` rather than
|
|
69
|
+
// the version this file was published as, because an exact version is skipped by
|
|
70
|
+
// `opencode plugin check` and `plugin update`: pinning would silently opt the
|
|
71
|
+
// install out of the update path OpenCode provides.
|
|
72
|
+
const positional = argv.filter((arg) => !arg.startsWith("-"))
|
|
73
|
+
const spec = positional[0] ?? `${sourcePkg.name}@latest`
|
|
74
|
+
|
|
75
|
+
log(`Installing ${sourcePkg.name} ${sourcePkg.version}`)
|
|
76
|
+
log(` from: ${source}`)
|
|
77
|
+
log(` mode: ${mode} (${reason})`)
|
|
129
78
|
|
|
130
79
|
// ---------------------------------------------------------------------------
|
|
131
|
-
//
|
|
80
|
+
// Tool permissions (both modes)
|
|
132
81
|
// ---------------------------------------------------------------------------
|
|
133
82
|
function stripJsonc(input) {
|
|
134
83
|
// Strip a UTF-8 BOM: editors on Windows may add one and JSON.parse rejects it.
|
|
@@ -189,51 +138,203 @@ const TOOLS = ["indexing_search", "indexing_status", "indexing_config", "indexin
|
|
|
189
138
|
// Everything else only reads. This one writes the settings file, so the host
|
|
190
139
|
// asks the user before it runs instead of taking it on trust.
|
|
191
140
|
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
141
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Add one permission rule per tool to the global config.
|
|
144
|
+
*
|
|
145
|
+
* `opencode plugin add` installs the plugin but knows nothing about its tools,
|
|
146
|
+
* so a package-mode install has to write these itself or the `ask` gate on
|
|
147
|
+
* `indexing_config_set` is lost. The read tools would be allowed by the base
|
|
148
|
+
* `{ "*": "allow" }` policy anyway; they are written out so the set is visible
|
|
149
|
+
* and can be tightened in one place.
|
|
150
|
+
*/
|
|
151
|
+
function writePermissions() {
|
|
152
|
+
try {
|
|
153
|
+
let cfg = {}
|
|
154
|
+
if (existsSync(configFile)) {
|
|
155
|
+
cfg = JSON.parse(stripJsonc(readFileSync(configFile, "utf8")))
|
|
156
|
+
if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) cfg = {}
|
|
157
|
+
}
|
|
205
158
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
rule.resource = "*"
|
|
212
|
-
rule.effect = effect
|
|
213
|
-
} else {
|
|
214
|
-
cfg.permissions.push({ action: tool, resource: "*", effect })
|
|
159
|
+
// Drop the V1 entries this installer used to write. Anything else the user
|
|
160
|
+
// put there is left untouched.
|
|
161
|
+
if (cfg.permission && typeof cfg.permission === "object" && !Array.isArray(cfg.permission)) {
|
|
162
|
+
for (const tool of TOOLS) delete cfg.permission[tool]
|
|
163
|
+
if (Object.keys(cfg.permission).length === 0) delete cfg.permission
|
|
215
164
|
}
|
|
216
|
-
}
|
|
217
165
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
166
|
+
if (!Array.isArray(cfg.permissions)) cfg.permissions = []
|
|
167
|
+
for (const tool of TOOLS) {
|
|
168
|
+
const effect = ASK_BEFORE_RUN.has(tool) ? "ask" : "allow"
|
|
169
|
+
const rule = cfg.permissions.find((entry) => entry && entry.action === tool)
|
|
170
|
+
if (rule) {
|
|
171
|
+
rule.resource = "*"
|
|
172
|
+
rule.effect = effect
|
|
173
|
+
} else {
|
|
174
|
+
cfg.permissions.push({ action: tool, resource: "*", effect })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
mkdirSync(configDir, { recursive: true })
|
|
179
|
+
writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n", "utf8")
|
|
180
|
+
log(` permissions added to ${configFile}`)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
warn(` could not update ${configFile}: ${error instanceof Error ? error.message : String(error)}`)
|
|
183
|
+
}
|
|
223
184
|
}
|
|
224
185
|
|
|
225
186
|
// ---------------------------------------------------------------------------
|
|
226
|
-
//
|
|
187
|
+
// Service restart (both modes)
|
|
227
188
|
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
189
|
+
function restartService() {
|
|
190
|
+
if (noRestart) return
|
|
229
191
|
log(" restarting OpenCode service...")
|
|
230
|
-
const
|
|
231
|
-
const result = spawnSync(opencode, ["service", "restart"], { stdio: "inherit", shell: isWindows })
|
|
192
|
+
const result = spawnSync(opencodeBin, ["service", "restart"], { stdio: "inherit", shell: isWindows })
|
|
232
193
|
if (result.error || result.status !== 0) {
|
|
233
194
|
warn(" could not restart automatically. Run: opencode service restart")
|
|
234
195
|
}
|
|
235
196
|
}
|
|
236
197
|
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Local mode: copy the working tree into the config directory
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Entries copied into the plugin directory.
|
|
203
|
+
*
|
|
204
|
+
* An allowlist rather than a blocklist: a blocklist copied whatever local debris
|
|
205
|
+
* happened to be in the working tree — a stray `test-run.log` was shipped into
|
|
206
|
+
* every install. This mirrors the `files` field in package.json, minus the
|
|
207
|
+
* installers, which are not needed inside an installed plugin.
|
|
208
|
+
*/
|
|
209
|
+
const COPY_ENTRIES = [
|
|
210
|
+
"index.ts",
|
|
211
|
+
"tui.ts",
|
|
212
|
+
"tui",
|
|
213
|
+
"src",
|
|
214
|
+
"package.json",
|
|
215
|
+
"tsconfig.json",
|
|
216
|
+
"README.md",
|
|
217
|
+
"LICENSE",
|
|
218
|
+
"THIRD_PARTY_NOTICES.md",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
function installLocal() {
|
|
222
|
+
log(` to: ${dest}`)
|
|
223
|
+
mkdirSync(dest, { recursive: true })
|
|
224
|
+
for (const entry of readdirSync(dest)) {
|
|
225
|
+
if (entry === "node_modules") continue
|
|
226
|
+
rmSync(join(dest, entry), { recursive: true, force: true })
|
|
227
|
+
}
|
|
228
|
+
for (const entry of COPY_ENTRIES) {
|
|
229
|
+
const from = join(source, entry)
|
|
230
|
+
if (!existsSync(from)) {
|
|
231
|
+
warn(` skipping missing entry: ${entry}`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
cpSync(from, join(dest, entry), { recursive: true })
|
|
235
|
+
}
|
|
236
|
+
log(" files copied")
|
|
237
|
+
|
|
238
|
+
// The installed package.json is derived, never hand-written: the list this
|
|
239
|
+
// used to keep in parallel is how `apache-arrow` came to be declared for the
|
|
240
|
+
// installer and missing from the published package, which broke every Kilo
|
|
241
|
+
// LanceDB import for one of the two install paths and not the other.
|
|
242
|
+
const installedPkg = {
|
|
243
|
+
name: sourcePkg.name,
|
|
244
|
+
version: sourcePkg.version,
|
|
245
|
+
type: "module",
|
|
246
|
+
exports: sourcePkg.exports,
|
|
247
|
+
dependencies: sourcePkg.dependencies,
|
|
248
|
+
optionalDependencies: sourcePkg.optionalDependencies,
|
|
249
|
+
peerDependencies: sourcePkg.peerDependencies,
|
|
250
|
+
peerDependenciesMeta: sourcePkg.peerDependenciesMeta,
|
|
251
|
+
}
|
|
252
|
+
writeFileSync(join(dest, "package.json"), JSON.stringify(installedPkg, null, 2) + "\n", "utf8")
|
|
253
|
+
|
|
254
|
+
if (skipDeps) {
|
|
255
|
+
log(" skipping dependency install (--skip-deps)")
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
log(" installing runtime deps (ignore + optional lancedb/tree-sitter)...")
|
|
260
|
+
const npm = isWindows ? "npm.cmd" : "npm"
|
|
261
|
+
// --omit=peer is why apache-arrow has to be a real dependency above: the
|
|
262
|
+
// native LanceDB binary loads without it and throws on first use.
|
|
263
|
+
const result = spawnSync(
|
|
264
|
+
npm,
|
|
265
|
+
["install", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", "--loglevel=error"],
|
|
266
|
+
{ cwd: dest, stdio: "inherit", shell: isWindows },
|
|
267
|
+
)
|
|
268
|
+
if (result.error || result.status !== 0) {
|
|
269
|
+
warn(` dependency install failed (${result.error?.message ?? `exit ${result.status}`}); the plugin still runs with reduced features`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Package mode: let OpenCode own the install
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
/** How many entries `opencode plugin list` reports for this plugin. */
|
|
277
|
+
function registeredCount() {
|
|
278
|
+
const result = spawnSync(opencodeBin, ["plugin", "list"], { encoding: "utf8", shell: isWindows })
|
|
279
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") return undefined
|
|
280
|
+
// `plugin list` prints a table: `ID VERSION SOURCE`. Compare the first
|
|
281
|
+
// column exactly rather than by prefix, so a longer id that happens to start
|
|
282
|
+
// the same way is not counted as this plugin.
|
|
283
|
+
return result.stdout
|
|
284
|
+
.split("\n")
|
|
285
|
+
.map((line) => line.trim().split(/\s+/)[0])
|
|
286
|
+
.filter((id) => id === "opencode.indexing").length
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function installPackage() {
|
|
290
|
+
log(` spec: ${spec}`)
|
|
291
|
+
|
|
292
|
+
const added = spawnSync(opencodeBin, ["plugin", "add", spec], { stdio: "inherit", shell: isWindows })
|
|
293
|
+
if (added.error || added.status !== 0) {
|
|
294
|
+
// Nothing has been removed at this point, so a failure here leaves the
|
|
295
|
+
// previous install exactly as it was.
|
|
296
|
+
warn(` \`opencode plugin add\` failed (${added.error?.message ?? `exit ${added.status}`}); the previous install was left untouched`)
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Remove the copied plugin *after* the package is in place. Both sources
|
|
301
|
+
// define `id: "opencode.indexing"`, and the config directory is scanned
|
|
302
|
+
// automatically, so leaving the copy behind would load the plugin twice.
|
|
303
|
+
if (existsSync(dest)) {
|
|
304
|
+
rmSync(dest, { recursive: true, force: true })
|
|
305
|
+
log(` removed the copied plugin at ${dest} (it would load twice)`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Run
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
if (mode === "local") {
|
|
315
|
+
installLocal()
|
|
316
|
+
} else if (!installPackage()) {
|
|
317
|
+
warn(" not writing permissions or restarting: the install did not complete")
|
|
318
|
+
process.exit(1)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
writePermissions()
|
|
322
|
+
// Before the count below, not after: `plugin list` asks the running service,
|
|
323
|
+
// which still holds the configuration from before this install.
|
|
324
|
+
restartService()
|
|
325
|
+
|
|
326
|
+
if (mode === "package") {
|
|
327
|
+
const count = registeredCount()
|
|
328
|
+
if (count !== undefined && count > 1) {
|
|
329
|
+
warn(` ${count} entries report id opencode.indexing; only one should. Check \`opencode plugin list\`.`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
237
333
|
log("Done.")
|
|
238
|
-
|
|
239
|
-
log("
|
|
334
|
+
if (mode === "package") {
|
|
335
|
+
log("Update with: opencode plugin update")
|
|
336
|
+
log("Remove with: opencode plugin remove " + spec)
|
|
337
|
+
} else {
|
|
338
|
+
log("Verify with: opencode api get /api/info")
|
|
339
|
+
log("Then run in a session: indexing_status (or /indexing-config in any client)")
|
|
340
|
+
}
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@javierkaiser/opencode-indexing",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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: {
|
|
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
|
-
//
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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)}`)
|
package/src/rpc.ts
CHANGED
|
@@ -19,6 +19,20 @@ const empty = { type: "object", properties: {}, additionalProperties: false } as
|
|
|
19
19
|
*/
|
|
20
20
|
const directoryShape = { directory: { type: "string" } } as const
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Input schema for a method whose only argument is the caller's workspace.
|
|
24
|
+
*
|
|
25
|
+
* Exists so `kilo.discover` and `settings.test` cannot be written as a bare
|
|
26
|
+
* `directoryShape`: that is a fragment of `properties`, not a schema, and a
|
|
27
|
+
* validator reading it as one sees no `directory` at all — silently answering
|
|
28
|
+
* about the service's own workspace again.
|
|
29
|
+
*/
|
|
30
|
+
const workspaceInputShape = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { ...directoryShape },
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
22
36
|
const settingsShape = {
|
|
23
37
|
type: "object",
|
|
24
38
|
properties: {
|
|
@@ -62,6 +76,9 @@ export const IndexingRpc = {
|
|
|
62
76
|
type: "object",
|
|
63
77
|
properties: {
|
|
64
78
|
target: { type: "string", enum: ["qdrant", "lancedb", "provider", "kilo"] },
|
|
79
|
+
// Only `kilo` is workspace-scoped; the other targets describe global
|
|
80
|
+
// backends. Sending it costs nothing and keeps one shape for the client.
|
|
81
|
+
...directoryShape,
|
|
65
82
|
},
|
|
66
83
|
required: ["target"],
|
|
67
84
|
additionalProperties: false,
|
|
@@ -74,7 +91,7 @@ export const IndexingRpc = {
|
|
|
74
91
|
},
|
|
75
92
|
},
|
|
76
93
|
"kilo.discover": {
|
|
77
|
-
input:
|
|
94
|
+
input: workspaceInputShape,
|
|
78
95
|
output: {
|
|
79
96
|
type: "object",
|
|
80
97
|
properties: {
|
|
@@ -94,6 +111,14 @@ export const IndexingRpc = {
|
|
|
94
111
|
additionalProperties: true,
|
|
95
112
|
},
|
|
96
113
|
},
|
|
114
|
+
/**
|
|
115
|
+
* Sources that exist but could not be read, in plain language.
|
|
116
|
+
*
|
|
117
|
+
* Without this the only thing a caller can say when the list is empty
|
|
118
|
+
* is "not found", which is a different statement from "found, and the
|
|
119
|
+
* read failed" — and the second one is the one that needs acting on.
|
|
120
|
+
*/
|
|
121
|
+
problems: { type: "array", items: { type: "string" } },
|
|
97
122
|
},
|
|
98
123
|
required: ["sources"],
|
|
99
124
|
additionalProperties: false,
|
package/src/status.ts
CHANGED
|
@@ -93,11 +93,19 @@ export async function getStatus(deps: StatusDeps, options?: { checkFreshness?: b
|
|
|
93
93
|
settings.dimension > 0
|
|
94
94
|
? { provider: settings.provider, modelId: settings.modelId, dimension: settings.dimension }
|
|
95
95
|
: undefined
|
|
96
|
+
// Discovery problems are collected here rather than thrown away, so `indexing_status`
|
|
97
|
+
// can say "found but unreadable" instead of the much less useful "nothing there".
|
|
98
|
+
const kiloProblems: string[] = []
|
|
96
99
|
const [kiloInfo, ownInfo, manifest, kiloSources] = await Promise.all([
|
|
97
100
|
kiloCollection ? getKiloStoreInfo(qdrant, kiloCollection).catch(() => null) : null,
|
|
98
101
|
ownStore.info().catch(() => null),
|
|
99
102
|
loadManifest(kv, root),
|
|
100
|
-
discoverKiloSources(root, {
|
|
103
|
+
discoverKiloSources(root, {
|
|
104
|
+
qdrant,
|
|
105
|
+
homeDir: undefined,
|
|
106
|
+
targetProfile,
|
|
107
|
+
onProblem: (message) => kiloProblems.push(message),
|
|
108
|
+
}).catch(() => [] as KiloSourceInfo[]),
|
|
101
109
|
])
|
|
102
110
|
|
|
103
111
|
const freshness = { checked: false, total: 0, added: 0, maybeStale: 0, deleted: 0 }
|
|
@@ -204,6 +212,10 @@ export async function getStatus(deps: StatusDeps, options?: { checkFreshness?: b
|
|
|
204
212
|
},
|
|
205
213
|
freshness,
|
|
206
214
|
recommendation,
|
|
207
|
-
warnings
|
|
215
|
+
// Discovery problems ride along as warnings because that is the list
|
|
216
|
+
// `indexing_status` already renders, so they surface without a field of
|
|
217
|
+
// their own. It is the only consumer: the footer reads point counts and
|
|
218
|
+
// `ownStoreReachable`, and never looks at warnings.
|
|
219
|
+
warnings: [...warnings, ...kiloProblems],
|
|
208
220
|
}
|
|
209
221
|
}
|