@javierkaiser/opencode-indexing 0.1.1 → 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 +157 -2
- package/README.md +192 -21
- package/index.ts +133 -17
- package/install.mjs +229 -128
- package/install.ps1 +7 -1
- package/install.sh +1 -1
- package/package.json +6 -2
- package/src/config.ts +1 -1
- package/src/embedder.ts +339 -99
- package/src/import.ts +30 -9
- package/src/indexer.ts +23 -1
- package/src/registry.ts +105 -19
- package/src/rpc.ts +27 -3
- package/src/search.ts +41 -2
- package/src/settings-commands.ts +6 -4
- package/src/settings.ts +28 -13
- package/src/status.ts +52 -5
- package/src/tools.ts +65 -20
- package/src/types.ts +32 -0
- package/tui/settings-page-data.ts +31 -15
- package/tui/settings-page.tsx +7 -1
- package/tui/workspace-rpc.ts +98 -0
- package/tui.ts +112 -41
package/src/tools.ts
CHANGED
|
@@ -10,6 +10,13 @@ import type { Qdrant } from "./qdrant.ts"
|
|
|
10
10
|
|
|
11
11
|
export interface ToolDeps {
|
|
12
12
|
kv: KVStore
|
|
13
|
+
/**
|
|
14
|
+
* Fallback workspace, used when the host does not report one per call.
|
|
15
|
+
*
|
|
16
|
+
* This is the plugin instance's own directory — the one the service runs in,
|
|
17
|
+
* which is usually not the directory the user is working in. Prefer
|
|
18
|
+
* `context.directory`; this is the last resort, not the normal path.
|
|
19
|
+
*/
|
|
13
20
|
root: string
|
|
14
21
|
/**
|
|
15
22
|
* Live settings getter. Tools resolve settings on every execution so config
|
|
@@ -18,6 +25,31 @@ export interface ToolDeps {
|
|
|
18
25
|
getSettings: () => IndexingSettings | Promise<IndexingSettings>
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Host context as far as the tools read it.
|
|
30
|
+
*
|
|
31
|
+
* `directory` is the session's workspace. Older hosts omit it, which is why it
|
|
32
|
+
* is optional: `toolRoot` falls back rather than indexing nothing.
|
|
33
|
+
*/
|
|
34
|
+
export interface ToolContext {
|
|
35
|
+
signal?: AbortSignal
|
|
36
|
+
progress?: (m: unknown) => Promise<void>
|
|
37
|
+
directory?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The workspace one tool call is about.
|
|
42
|
+
*
|
|
43
|
+
* The host knows which directory the user is in; the plugin instance does not.
|
|
44
|
+
* Indexing the service's directory because that is what `deps.root` happened to
|
|
45
|
+
* hold writes a perfectly good index of the wrong codebase and reports it as the
|
|
46
|
+
* user's, so the session directory wins whenever the host reports one.
|
|
47
|
+
*/
|
|
48
|
+
function toolRoot(deps: ToolDeps, context: ToolContext): string {
|
|
49
|
+
const directory = context.directory
|
|
50
|
+
return typeof directory === "string" && directory.trim() !== "" ? directory : deps.root
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
export interface ToolDefinition {
|
|
22
54
|
name: string
|
|
23
55
|
description: string
|
|
@@ -30,10 +62,7 @@ export interface ToolDefinition {
|
|
|
30
62
|
* is why configuration read and write are separate tools.
|
|
31
63
|
*/
|
|
32
64
|
permission?: string
|
|
33
|
-
execute: (
|
|
34
|
-
input: Record<string, unknown>,
|
|
35
|
-
context: { signal?: AbortSignal; progress?: (m: unknown) => Promise<void> },
|
|
36
|
-
) => Promise<{ output: string; metadata?: Record<string, unknown> }>
|
|
65
|
+
execute: (input: Record<string, unknown>, context: ToolContext) => Promise<{ output: string; metadata?: Record<string, unknown> }>
|
|
37
66
|
}
|
|
38
67
|
|
|
39
68
|
const SEARCH_DESCRIPTION = `Find code snippets by semantic meaning in the project's code index.
|
|
@@ -119,9 +148,10 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
119
148
|
},
|
|
120
149
|
["query"],
|
|
121
150
|
),
|
|
122
|
-
async execute(input) {
|
|
151
|
+
async execute(input, context) {
|
|
123
152
|
const settings = await deps.getSettings()
|
|
124
153
|
const qdrant = createSettingsQdrant(settings)
|
|
154
|
+
const root = toolRoot(deps, context)
|
|
125
155
|
const query = typeof input.query === "string" ? input.query.trim() : ""
|
|
126
156
|
if (!query) throw new Error("query is required")
|
|
127
157
|
const pathPrefix = typeof input.path === "string" && input.path.trim() !== "" ? input.path.trim() : undefined
|
|
@@ -130,13 +160,13 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
130
160
|
// Skipped while indexing is paused, otherwise a search would keep writing.
|
|
131
161
|
if (settings.autoRefresh && settings.enabled !== false) {
|
|
132
162
|
try {
|
|
133
|
-
await maybeAutoRefresh(deps, settings, qdrant)
|
|
163
|
+
await maybeAutoRefresh(root, deps, settings, qdrant)
|
|
134
164
|
} catch {
|
|
135
165
|
// Auto-refresh failures never block the search.
|
|
136
166
|
}
|
|
137
167
|
}
|
|
138
168
|
|
|
139
|
-
const outcome = await searchCode({ qdrant, kv: deps.kv, root
|
|
169
|
+
const outcome = await searchCode({ qdrant, kv: deps.kv, root, settings }, query, pathPrefix)
|
|
140
170
|
const { hits, meta, errors } = outcome
|
|
141
171
|
if (hits.length === 0) {
|
|
142
172
|
const lines: string[] = [`No results for "${query}" in ${meta.root}.`]
|
|
@@ -184,10 +214,11 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
184
214
|
input: jsonSchema({
|
|
185
215
|
checkFreshness: { type: "boolean", description: "Scan the workspace to compute file freshness (slower)." },
|
|
186
216
|
}),
|
|
187
|
-
async execute(input) {
|
|
217
|
+
async execute(input, context) {
|
|
188
218
|
const settings = await deps.getSettings()
|
|
189
219
|
const qdrant = createSettingsQdrant(settings)
|
|
190
|
-
const
|
|
220
|
+
const root = toolRoot(deps, context)
|
|
221
|
+
const status = await getStatus({ qdrant, kv: deps.kv, root, settings }, { checkFreshness: input.checkFreshness !== false })
|
|
191
222
|
const lines: string[] = [
|
|
192
223
|
`Workspace: ${status.root}`,
|
|
193
224
|
`Qdrant: ${status.qdrant.url} (${status.qdrant.reachable ? "reachable" : "UNREACHABLE"})`,
|
|
@@ -224,11 +255,12 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
224
255
|
const settings = await deps.getSettings()
|
|
225
256
|
assertIndexingEnabled(settings)
|
|
226
257
|
const qdrant = createSettingsQdrant(settings)
|
|
258
|
+
const root = toolRoot(deps, context)
|
|
227
259
|
const maxFiles = typeof input.maxFiles === "number" && input.maxFiles > 0 ? Math.floor(input.maxFiles) : undefined
|
|
228
260
|
const report = await runIndex(
|
|
229
261
|
{
|
|
230
262
|
kv: deps.kv,
|
|
231
|
-
root
|
|
263
|
+
root,
|
|
232
264
|
settings,
|
|
233
265
|
qdrant,
|
|
234
266
|
onProgress: makeProgress(context),
|
|
@@ -236,7 +268,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
236
268
|
{ mode: "refresh", maxFiles },
|
|
237
269
|
)
|
|
238
270
|
const summary = formatReport("refresh", report)
|
|
239
|
-
const warning = scopeWarning(
|
|
271
|
+
const warning = scopeWarning(root)
|
|
240
272
|
return {
|
|
241
273
|
output: warning ? `${warning}\n${summary}` : summary,
|
|
242
274
|
metadata: report as unknown as Record<string, unknown>,
|
|
@@ -254,10 +286,11 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
254
286
|
const settings = await deps.getSettings()
|
|
255
287
|
assertIndexingEnabled(settings)
|
|
256
288
|
const qdrant = createSettingsQdrant(settings)
|
|
289
|
+
const root = toolRoot(deps, context)
|
|
257
290
|
const report = await runIndex(
|
|
258
291
|
{
|
|
259
292
|
kv: deps.kv,
|
|
260
|
-
root
|
|
293
|
+
root,
|
|
261
294
|
settings,
|
|
262
295
|
qdrant,
|
|
263
296
|
onProgress: makeProgress(context),
|
|
@@ -265,7 +298,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
265
298
|
{ mode: "build", rebuild: input.rebuild === true, skipImport: input.skipImport === true },
|
|
266
299
|
)
|
|
267
300
|
const summary = formatReport("build", report)
|
|
268
|
-
const warning = scopeWarning(
|
|
301
|
+
const warning = scopeWarning(root)
|
|
269
302
|
return {
|
|
270
303
|
output: warning ? `${warning}\n${summary}` : summary,
|
|
271
304
|
metadata: report as unknown as Record<string, unknown>,
|
|
@@ -283,7 +316,7 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
283
316
|
const settings = await deps.getSettings()
|
|
284
317
|
assertIndexingEnabled(settings)
|
|
285
318
|
const qdrant = createSettingsQdrant(settings)
|
|
286
|
-
const root = deps
|
|
319
|
+
const root = toolRoot(deps, context)
|
|
287
320
|
const store = createOwnStore(settings, root)
|
|
288
321
|
const profile = { provider: settings.provider, modelId: settings.modelId, dimension: settings.dimension }
|
|
289
322
|
|
|
@@ -304,8 +337,15 @@ export function createTools(deps: ToolDeps): ToolDefinition[] {
|
|
|
304
337
|
}
|
|
305
338
|
await store.ensure(dimension, targetProfile)
|
|
306
339
|
|
|
307
|
-
const
|
|
308
|
-
|
|
340
|
+
const problems: string[] = []
|
|
341
|
+
const sources = await discoverKiloSources(root, { qdrant, targetProfile, onProblem: (m) => problems.push(m) })
|
|
342
|
+
if (sources.length === 0) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
problems.length > 0
|
|
345
|
+
? `No Kilo Code index found for ${root}. ${problems.join(" · ")}`
|
|
346
|
+
: "No Kilo Code index found for this workspace (neither Qdrant collection nor local LanceDB)",
|
|
347
|
+
)
|
|
348
|
+
}
|
|
309
349
|
const wanted = typeof input.source === "string" && input.source.trim() !== "" ? input.source.trim() : undefined
|
|
310
350
|
const source = wanted ? sources.find((item) => item.name === wanted) : (sources.find((item) => item.compatible) ?? sources[0])
|
|
311
351
|
if (!source) throw new Error(`Source not found: ${wanted}. Available: ${sources.map((s) => s.name).join(", ")}`)
|
|
@@ -362,12 +402,17 @@ function assertIndexingEnabled(settings: { enabled?: boolean }): void {
|
|
|
362
402
|
/** Max changed files that trigger an inline refresh on search. */
|
|
363
403
|
const AUTO_REFRESH_MAX_FILES = 200
|
|
364
404
|
|
|
365
|
-
async function maybeAutoRefresh(
|
|
405
|
+
async function maybeAutoRefresh(
|
|
406
|
+
root: string,
|
|
407
|
+
deps: ToolDeps,
|
|
408
|
+
settings: IndexingSettings,
|
|
409
|
+
qdrant: Qdrant,
|
|
410
|
+
): Promise<void> {
|
|
366
411
|
// Cheap pre-check: diff the manifest against a bounded workspace scan.
|
|
367
412
|
const { loadManifest, diffFiles } = await import("./manifest.ts")
|
|
368
413
|
const { scanWorkspace } = await import("./scanner.ts")
|
|
369
|
-
const manifest = await loadManifest(deps.kv,
|
|
370
|
-
const scan = await scanWorkspace(
|
|
414
|
+
const manifest = await loadManifest(deps.kv, root)
|
|
415
|
+
const scan = await scanWorkspace(root, {
|
|
371
416
|
extensions: settings.fileExtensions,
|
|
372
417
|
maxFileSizeBytes: settings.maxFileSizeBytes,
|
|
373
418
|
maxFiles: AUTO_REFRESH_MAX_FILES + 1,
|
|
@@ -376,7 +421,7 @@ async function maybeAutoRefresh(deps: ToolDeps, settings: IndexingSettings, qdra
|
|
|
376
421
|
const delta = diff.added.length + diff.maybeStale.length + diff.deleted.length
|
|
377
422
|
if (delta === 0 || delta > AUTO_REFRESH_MAX_FILES) return
|
|
378
423
|
await runIndex(
|
|
379
|
-
{ kv: deps.kv, root
|
|
424
|
+
{ kv: deps.kv, root, settings, qdrant },
|
|
380
425
|
{ mode: "refresh", maxFiles: AUTO_REFRESH_MAX_FILES },
|
|
381
426
|
)
|
|
382
427
|
}
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,38 @@ export interface ModelProfile {
|
|
|
22
22
|
dimension: number
|
|
23
23
|
scoreThreshold?: number
|
|
24
24
|
queryPrefix?: string
|
|
25
|
+
/**
|
|
26
|
+
* Largest input the model accepts, in tokens.
|
|
27
|
+
*
|
|
28
|
+
* Not the same for every model, and the spread is wide: `mxbai-embed-large`
|
|
29
|
+
* takes 512 while `voyage-code-4` takes 32,000. Sizing every request by one
|
|
30
|
+
* global constant silently truncates every chunk past the smallest model's
|
|
31
|
+
* limit, and that truncated vector is stored as if it covered the whole
|
|
32
|
+
* function.
|
|
33
|
+
*/
|
|
34
|
+
maxInputTokens?: number
|
|
35
|
+
/** Documented ceiling on items in one request, where the provider publishes one. */
|
|
36
|
+
maxBatchItems?: number
|
|
37
|
+
/** Documented ceiling on tokens in one request, where the provider publishes one. */
|
|
38
|
+
maxBatchTokens?: number
|
|
39
|
+
/**
|
|
40
|
+
* Whether the provider documents its output as already L2-normalised.
|
|
41
|
+
*
|
|
42
|
+
* The provider's claim, not a measurement we rely on. Mistral documents its
|
|
43
|
+
* embeddings as "norm 1" and a live call measured 0.9913, which is why every
|
|
44
|
+
* comparison here uses cosine regardless: it is correct either way, and this
|
|
45
|
+
* field exists only so a future fast path knows whether a bare dot product
|
|
46
|
+
* would be equivalent.
|
|
47
|
+
*/
|
|
48
|
+
normalized?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Marks a model we know about that Kilo's registry does not.
|
|
51
|
+
*
|
|
52
|
+
* The catalog is a mirror, so that query embeddings stay interchangeable with
|
|
53
|
+
* a Kilo index. An entry with no Kilo counterpart is still useful, but it can
|
|
54
|
+
* never be import-compatible, and the status report has to be able to say so.
|
|
55
|
+
*/
|
|
56
|
+
kiloMirror?: boolean
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
export interface ProviderCredentials {
|
|
@@ -24,8 +24,8 @@ export interface SettingsView {
|
|
|
24
24
|
/** Master switch for writing the own index. */
|
|
25
25
|
enabled: boolean
|
|
26
26
|
searchMaxResults: number
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** Providers that have a usable credential or base URL configured. */
|
|
28
|
+
configuredProviders: string[]
|
|
29
29
|
settingsFile: string
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -43,15 +43,23 @@ export interface StatusView {
|
|
|
43
43
|
recommendation: string
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The RPC slice this page needs.
|
|
48
|
+
*
|
|
49
|
+
* Every workspace-scoped method takes an optional `directory`. Callers normally
|
|
50
|
+
* never pass one: `bindWorkspace` injects it into the client the TUI hands this
|
|
51
|
+
* page, so the call sites below cannot forget it. The field stays on the types
|
|
52
|
+
* because the bound client is what actually sends it.
|
|
53
|
+
*/
|
|
46
54
|
export interface SettingsPageRpc {
|
|
47
55
|
"settings.get": (input: Record<string, never>) => Promise<{ settings: SettingsView }>
|
|
48
56
|
"settings.set": (input: { patch: Record<string, unknown> }) => Promise<{ settings: SettingsView }>
|
|
49
|
-
"settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo" }) => Promise<{ ok: boolean; message: string }>
|
|
50
|
-
"kilo.discover": () => Promise<{ sources: KiloSourceView[] }>
|
|
51
|
-
"status.get": (input: { checkFreshness?: boolean }) => Promise<StatusView>
|
|
52
|
-
"index.build": (input: { rebuild?: boolean; skipImport?: boolean }) => Promise<{ summary: string; error?: string }>
|
|
53
|
-
"index.refresh": (input: { maxFiles?: number }) => Promise<{ summary: string; error?: string }>
|
|
54
|
-
"index.import": (input: { source?: string; rebuild?: boolean }) => Promise<{ summary: string; error?: string }>
|
|
57
|
+
"settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo"; directory?: string }) => Promise<{ ok: boolean; message: string }>
|
|
58
|
+
"kilo.discover": (input?: { directory?: string }) => Promise<{ sources: KiloSourceView[]; problems?: string[] }>
|
|
59
|
+
"status.get": (input: { checkFreshness?: boolean; directory?: string }) => Promise<StatusView>
|
|
60
|
+
"index.build": (input: { rebuild?: boolean; skipImport?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
|
|
61
|
+
"index.refresh": (input: { maxFiles?: number; directory?: string }) => Promise<{ summary: string; error?: string }>
|
|
62
|
+
"index.import": (input: { source?: string; rebuild?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
|
|
55
63
|
"workspaces.list": (input: Record<string, never>) => Promise<{ workspaces: WorkspaceRow[] }>
|
|
56
64
|
"workspace.forget": (input: { store: string }) => Promise<{ ok: boolean; reason?: "refused" | "failed"; summary: string; pointsDeleted: number | null }>
|
|
57
65
|
}
|
|
@@ -78,6 +86,14 @@ export interface SettingsPageProps {
|
|
|
78
86
|
export interface SettingsPageData {
|
|
79
87
|
settings: SettingsView
|
|
80
88
|
sources: KiloSourceView[]
|
|
89
|
+
/**
|
|
90
|
+
* Sources that exist but could not be read.
|
|
91
|
+
*
|
|
92
|
+
* Kept apart from `sources` on purpose: an empty list with problems is a
|
|
93
|
+
* different answer from an empty list without them, and the page has to be
|
|
94
|
+
* able to tell the user which one they are looking at.
|
|
95
|
+
*/
|
|
96
|
+
problems: string[]
|
|
81
97
|
status: StatusView
|
|
82
98
|
/** Indexed workspaces, so the page can show what exists on disk. */
|
|
83
99
|
workspaces: WorkspacesView
|
|
@@ -101,8 +117,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
|
|
|
101
117
|
enabled: false,
|
|
102
118
|
autoRefresh: false,
|
|
103
119
|
searchMaxResults: 0,
|
|
104
|
-
|
|
105
|
-
hasOpenAiKey: false,
|
|
120
|
+
configuredProviders: [],
|
|
106
121
|
settingsFile: "(unavailable)",
|
|
107
122
|
}
|
|
108
123
|
|
|
@@ -117,7 +132,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
|
|
|
117
132
|
export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<SettingsPageData> {
|
|
118
133
|
const [settingsResponse, sourcesResponse, status, workspaces] = await Promise.all([
|
|
119
134
|
rpc["settings.get"]({}).catch(() => ({ settings: UNKNOWN_SETTINGS })),
|
|
120
|
-
rpc["kilo.discover"]().catch(() => ({ sources: [] as KiloSourceView[] })),
|
|
135
|
+
rpc["kilo.discover"]({}).catch(() => ({ sources: [] as KiloSourceView[], problems: [] as string[] })),
|
|
121
136
|
rpc["status.get"]({ checkFreshness: false }).catch(() => ({ summary: "(status unavailable)", recommendation: "" })),
|
|
122
137
|
// loadWorkspaces already degrades to an empty view, so it cannot reject.
|
|
123
138
|
loadWorkspaces(rpc),
|
|
@@ -125,6 +140,7 @@ export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<Settin
|
|
|
125
140
|
return {
|
|
126
141
|
settings: settingsResponse.settings,
|
|
127
142
|
sources: sourcesResponse.sources,
|
|
143
|
+
problems: sourcesResponse.problems ?? [],
|
|
128
144
|
status,
|
|
129
145
|
workspaces,
|
|
130
146
|
}
|
|
@@ -149,10 +165,10 @@ export function configRows(settings: SettingsView): ConfigRow[] {
|
|
|
149
165
|
{ label: "Max results", value: String(settings.searchMaxResults) },
|
|
150
166
|
{ label: "Settings file", value: settings.settingsFile },
|
|
151
167
|
]
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
rows.push({ label: "
|
|
168
|
+
// Derived from the server's list rather than a pair of booleans, so a
|
|
169
|
+
// provider added anywhere else shows up here without a change to this file.
|
|
170
|
+
const keys = settings.configuredProviders
|
|
171
|
+
rows.push({ label: "Credentials", value: keys.length > 0 ? `${keys.join(", ")} configured` : "none configured" })
|
|
156
172
|
return rows
|
|
157
173
|
}
|
|
158
174
|
|
package/tui/settings-page.tsx
CHANGED
|
@@ -75,7 +75,13 @@ export function SettingsPage(props: SettingsPageProps): JSX.Element {
|
|
|
75
75
|
const action = PAGE_ACTIONS.find((entry) => entry.id === id)
|
|
76
76
|
if (!action) return
|
|
77
77
|
if (action.kind === "source" && (data()?.sources ?? []).length === 0) {
|
|
78
|
-
|
|
78
|
+
// Report the reason when there is one: an index that exists but cannot be
|
|
79
|
+
// read is fixed by installing something, not by building one.
|
|
80
|
+
const problems = data()?.problems ?? []
|
|
81
|
+
props.toast(
|
|
82
|
+
problems.length > 0 ? `No importable Kilo index: ${problems.join(" · ")}` : "No Kilo index found for this workspace",
|
|
83
|
+
problems.length > 0 ? "error" : "info",
|
|
84
|
+
)
|
|
79
85
|
return
|
|
80
86
|
}
|
|
81
87
|
// No source passed: the server picks the first compatible one. Use the
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds the caller's workspace directory to every workspace-scoped RPC.
|
|
3
|
+
*
|
|
4
|
+
* The server plugin is instantiated for the directory the service runs in, not
|
|
5
|
+
* the one the user has open, so a call that omits `directory` silently acts on
|
|
6
|
+
* the wrong workspace. Every method below takes one for that reason — and every
|
|
7
|
+
* one that takes one has to be passed it, which is a per-call-site obligation
|
|
8
|
+
* that was already forgotten once (the palette and the settings page read and
|
|
9
|
+
* wrote the service's index while the footer, using the same client, correctly
|
|
10
|
+
* reported the user's).
|
|
11
|
+
*
|
|
12
|
+
* Injecting the directory here rather than at each call site makes that class
|
|
13
|
+
* of bug impossible to reintroduce: there is one place that knows which methods
|
|
14
|
+
* are workspace-scoped, and adding a method to that list is the only edit a new
|
|
15
|
+
* workspace-scoped RPC needs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** RPC methods that act on one workspace and therefore need a directory. */
|
|
19
|
+
export const WORKSPACE_SCOPED_METHODS = [
|
|
20
|
+
"settings.test",
|
|
21
|
+
"kilo.discover",
|
|
22
|
+
"status.get",
|
|
23
|
+
"index.build",
|
|
24
|
+
"index.refresh",
|
|
25
|
+
"index.import",
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The workspace-agnostic methods are deliberately absent: they span every
|
|
30
|
+
* workspace, so naming one would misreport what they act on. In this client
|
|
31
|
+
* that is `settings.get` and `settings.set`; the wider client adds
|
|
32
|
+
* `workspaces.list` and `workspace.forget`, which are global for the same
|
|
33
|
+
* reason.
|
|
34
|
+
*/
|
|
35
|
+
function isWorkspaceScoped(method: string): boolean {
|
|
36
|
+
return (WORKSPACE_SCOPED_METHODS as readonly string[]).includes(method)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wrap `client` so the listed methods carry `directory` unless the caller set
|
|
41
|
+
* one explicitly.
|
|
42
|
+
*
|
|
43
|
+
* An explicit `directory` always wins: the footer reads reactively and a test
|
|
44
|
+
* (or a future caller) may legitimately want to ask about a different workspace
|
|
45
|
+
* than the one the user is sitting in.
|
|
46
|
+
*
|
|
47
|
+
* The real client methods take a second `options` argument (cancellation
|
|
48
|
+
* signals and headers), so the wrapper forwards everything it is given rather
|
|
49
|
+
* than modelling one signature and silently dropping the rest.
|
|
50
|
+
*
|
|
51
|
+
* Reads `Object.keys` on the client, which is what the host produces: its RPC
|
|
52
|
+
* client is built with `Object.fromEntries`, so every method is an own
|
|
53
|
+
* enumerable key. A client whose methods were reachable only through a proxy
|
|
54
|
+
* would yield an empty wrapper, and every call would fail loudly rather than
|
|
55
|
+
* quietly acting on the wrong workspace.
|
|
56
|
+
*/
|
|
57
|
+
export function bindWorkspace<T extends Record<string, unknown>>(client: T, workspace: () => string | undefined): T {
|
|
58
|
+
const bound: Record<string, unknown> = {}
|
|
59
|
+
|
|
60
|
+
for (const key of Object.keys(client)) {
|
|
61
|
+
const method = client[key]
|
|
62
|
+
if (typeof method !== "function" || !isWorkspaceScoped(key)) {
|
|
63
|
+
bound[key] = method
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
bound[key] = (...args: unknown[]) => {
|
|
68
|
+
const input = args[0]
|
|
69
|
+
// An explicit directory wins, so it is checked before the workspace is
|
|
70
|
+
// resolved: a caller that already knows which workspace it means must not
|
|
71
|
+
// depend on the accessors answering, or on them answering at all.
|
|
72
|
+
const explicit =
|
|
73
|
+
input && typeof input === "object" && !Array.isArray(input)
|
|
74
|
+
? (input as { directory?: unknown }).directory
|
|
75
|
+
: undefined
|
|
76
|
+
if (typeof explicit === "string" && explicit.trim() !== "") {
|
|
77
|
+
return (method as (...args: unknown[]) => unknown)(...args)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const directory = workspace()
|
|
81
|
+
if (typeof directory !== "string" || directory.trim() === "") {
|
|
82
|
+
// No workspace to name: the server falls back to its own directory,
|
|
83
|
+
// which is the only thing it can do.
|
|
84
|
+
return (method as (...args: unknown[]) => unknown)(...args)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (method as (...args: unknown[]) => unknown)(
|
|
88
|
+
{
|
|
89
|
+
...(input && typeof input === "object" && !Array.isArray(input) ? input : {}),
|
|
90
|
+
directory,
|
|
91
|
+
},
|
|
92
|
+
...args.slice(1),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return bound as T
|
|
98
|
+
}
|