@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/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
|
}
|
|
@@ -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
|
|
@@ -116,7 +132,7 @@ export const UNKNOWN_SETTINGS: SettingsView = {
|
|
|
116
132
|
export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<SettingsPageData> {
|
|
117
133
|
const [settingsResponse, sourcesResponse, status, workspaces] = await Promise.all([
|
|
118
134
|
rpc["settings.get"]({}).catch(() => ({ settings: UNKNOWN_SETTINGS })),
|
|
119
|
-
rpc["kilo.discover"]().catch(() => ({ sources: [] as KiloSourceView[] })),
|
|
135
|
+
rpc["kilo.discover"]({}).catch(() => ({ sources: [] as KiloSourceView[], problems: [] as string[] })),
|
|
120
136
|
rpc["status.get"]({ checkFreshness: false }).catch(() => ({ summary: "(status unavailable)", recommendation: "" })),
|
|
121
137
|
// loadWorkspaces already degrades to an empty view, so it cannot reject.
|
|
122
138
|
loadWorkspaces(rpc),
|
|
@@ -124,6 +140,7 @@ export async function loadSettingsPageData(rpc: SettingsPageRpc): Promise<Settin
|
|
|
124
140
|
return {
|
|
125
141
|
settings: settingsResponse.settings,
|
|
126
142
|
sources: sourcesResponse.sources,
|
|
143
|
+
problems: sourcesResponse.problems ?? [],
|
|
127
144
|
status,
|
|
128
145
|
workspaces,
|
|
129
146
|
}
|
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
|
+
}
|
package/tui.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { ThemeLike } from "./tui/page-style.ts"
|
|
|
24
24
|
import { PAGE_ACTIONS, PAGE_ROWS, type SettingsPageRpc } from "./tui/settings-page-data.ts"
|
|
25
25
|
import { footerAction, footerState, type IndexingState } from "./tui/footer.ts"
|
|
26
26
|
import { activeWorkspace, type WorkspaceSources } from "./tui/footer.ts"
|
|
27
|
+
import { bindWorkspace } from "./tui/workspace-rpc.ts"
|
|
27
28
|
import { describeRow, forgetWorkspaceFlow, type WorkspacesRpc } from "./tui/workspaces.ts"
|
|
28
29
|
import { pagePalette } from "./tui/page-style.ts"
|
|
29
30
|
import type { FooterHandle, FooterState } from "./tui/footer-view.tsx"
|
|
@@ -48,8 +49,15 @@ const CUSTOM_MODEL = "\u0000custom-model"
|
|
|
48
49
|
type RpcClient = {
|
|
49
50
|
"settings.get": (input: Record<string, never>) => Promise<{ settings: SettingsView }>
|
|
50
51
|
"settings.set": (input: { patch: Record<string, unknown> }) => Promise<{ settings: SettingsView }>
|
|
51
|
-
"settings.test": (input: {
|
|
52
|
-
|
|
52
|
+
"settings.test": (input: {
|
|
53
|
+
target: "qdrant" | "lancedb" | "provider" | "kilo"
|
|
54
|
+
directory?: string
|
|
55
|
+
}) => Promise<{ ok: boolean; message: string }>
|
|
56
|
+
"kilo.discover": (input?: { directory?: string }) => Promise<{
|
|
57
|
+
sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }>
|
|
58
|
+
/** Sources that exist but could not be read. */
|
|
59
|
+
problems?: string[]
|
|
60
|
+
}>
|
|
53
61
|
"status.get": (input: { checkFreshness?: boolean; directory?: string }) => Promise<{ summary: string; recommendation: string; ownKind: string; ownStore: string; ownPoints?: number | null; ownComplete?: boolean | null; kiloCollection?: string | null; directory?: string; ownStoreReachable?: boolean }>
|
|
54
62
|
"index.build": (input: { rebuild?: boolean; skipImport?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
|
|
55
63
|
"index.refresh": (input: { maxFiles?: number; directory?: string }) => Promise<{ summary: string; error?: string }>
|
|
@@ -77,10 +85,46 @@ interface SettingsView {
|
|
|
77
85
|
const plugin = {
|
|
78
86
|
id: ID,
|
|
79
87
|
async setup(context: Plugin.Context): Promise<void> {
|
|
88
|
+
/**
|
|
89
|
+
* The workspace the caller is working in.
|
|
90
|
+
*
|
|
91
|
+
* The server plugin serves its own instance directory, so every RPC that
|
|
92
|
+
* reads or writes an index must be told which workspace it is about. Without
|
|
93
|
+
* this the footer would report — and the palette command would index — the
|
|
94
|
+
* service's directory instead of the user's: the ACTIVE tab is what follows
|
|
95
|
+
* the user, and the instance directory is the fallback for a host with tabs
|
|
96
|
+
* switched off.
|
|
97
|
+
*
|
|
98
|
+
* Every accessor is optional: a host may expose neither `ui.tabs` nor
|
|
99
|
+
* `data.session`, and resolving the workspace must degrade rather than
|
|
100
|
+
* throw. Read from a reactive context (the footer slot render) the lookups
|
|
101
|
+
* re-run when the host's tab or session state changes.
|
|
102
|
+
*
|
|
103
|
+
* Declared before `rpc` because `rpc` binds the directory into every
|
|
104
|
+
* workspace-scoped call: a call made before this binding exists would be
|
|
105
|
+
* answered against the service's own directory.
|
|
106
|
+
*/
|
|
107
|
+
const workspaceSources: WorkspaceSources = {
|
|
108
|
+
tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
|
|
109
|
+
tabs: () => context.ui.tabs?.list?.() ?? [],
|
|
110
|
+
sessions: () => context.data?.session?.list?.() ?? [],
|
|
111
|
+
instanceDirectory: () => context.location?.directory,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const workspace = (): string | undefined => activeWorkspace(workspaceSources)
|
|
115
|
+
|
|
80
116
|
// Resolve the RPC subclient lazily: the TUI may start before the server
|
|
81
117
|
// plugin is ready, and calls are retried on first use.
|
|
118
|
+
//
|
|
119
|
+
// Every call goes through `bindWorkspace`, so a workspace-scoped method
|
|
120
|
+
// always names the caller's directory. Passing it per call site is what
|
|
121
|
+
// failed before: the footer did it and the palette did not, and the palette
|
|
122
|
+
// silently read and wrote the service's index instead of the user's.
|
|
82
123
|
const rpc = (): RpcClient =>
|
|
83
|
-
(
|
|
124
|
+
bindWorkspace(
|
|
125
|
+
(context.client as unknown as { rpc: (def: unknown, options?: unknown) => RpcClient }).rpc(IndexingRpc),
|
|
126
|
+
workspace,
|
|
127
|
+
)
|
|
84
128
|
|
|
85
129
|
const toast = (message: string, variant: "success" | "error" | "info" = "info"): void => {
|
|
86
130
|
context.ui.toast.show({ title: "Indexing", message, variant, duration: 5000 })
|
|
@@ -446,14 +490,24 @@ const plugin = {
|
|
|
446
490
|
|
|
447
491
|
async function kiloMenu(): Promise<void> {
|
|
448
492
|
let sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }> = []
|
|
493
|
+
let problems: string[] = []
|
|
449
494
|
try {
|
|
450
|
-
|
|
495
|
+
const found = await rpc()["kilo.discover"]({})
|
|
496
|
+
sources = found.sources
|
|
497
|
+
problems = found.problems ?? []
|
|
451
498
|
} catch (error) {
|
|
452
499
|
fail(error)
|
|
453
500
|
return
|
|
454
501
|
}
|
|
455
502
|
if (sources.length === 0) {
|
|
456
|
-
|
|
503
|
+
// "Found but unreadable" and "never indexed" look identical from here,
|
|
504
|
+
// and only one of them is fixed by indexing. Say which one it is.
|
|
505
|
+
toast(
|
|
506
|
+
problems.length > 0
|
|
507
|
+
? `No importable Kilo index: ${problems.join(" · ")}`
|
|
508
|
+
: "No Kilo index found for this workspace",
|
|
509
|
+
problems.length > 0 ? "error" : "info",
|
|
510
|
+
)
|
|
457
511
|
return
|
|
458
512
|
}
|
|
459
513
|
const choice = await context.ui.dialog.select({
|
|
@@ -502,7 +556,7 @@ const plugin = {
|
|
|
502
556
|
toast("Running…", "info")
|
|
503
557
|
try {
|
|
504
558
|
if (action === "refresh") {
|
|
505
|
-
reportRun(await rpc()["index.refresh"]({
|
|
559
|
+
reportRun(await rpc()["index.refresh"]({}))
|
|
506
560
|
} else if (action === "build") {
|
|
507
561
|
reportRun(await rpc()["index.build"]({}))
|
|
508
562
|
} else if (action === "build-scratch") {
|
|
@@ -582,30 +636,6 @@ const plugin = {
|
|
|
582
636
|
return route?.type === "plugin" && route.name === SETTINGS_PAGE_NAME
|
|
583
637
|
}
|
|
584
638
|
|
|
585
|
-
/**
|
|
586
|
-
* The workspace the caller is working in.
|
|
587
|
-
*
|
|
588
|
-
* The server plugin serves its own instance directory, so every RPC that
|
|
589
|
-
* reads or writes an index must be told which workspace it is about. Without
|
|
590
|
-
* this the footer would report — and the palette command would index — the
|
|
591
|
-
* service's directory instead of the user's: the ACTIVE tab is what follows
|
|
592
|
-
* the user, and the instance directory is the fallback for a host with tabs
|
|
593
|
-
* switched off.
|
|
594
|
-
*
|
|
595
|
-
* Every accessor is optional: a host may expose neither `ui.tabs` nor
|
|
596
|
-
* `data.session`, and resolving the workspace must degrade rather than
|
|
597
|
-
* throw. Read from a reactive context (the footer slot render) the lookups
|
|
598
|
-
* re-run when the host's tab or session state changes.
|
|
599
|
-
*/
|
|
600
|
-
const workspaceSources: WorkspaceSources = {
|
|
601
|
-
tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
|
|
602
|
-
tabs: () => context.ui.tabs?.list?.() ?? [],
|
|
603
|
-
sessions: () => context.data?.session?.list?.() ?? [],
|
|
604
|
-
instanceDirectory: () => context.location?.directory,
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const workspace = (): string | undefined => activeWorkspace(workspaceSources)
|
|
608
|
-
|
|
609
639
|
/** Opens the dialog editor, holding the page keys off for its duration. */
|
|
610
640
|
async function openEditor(): Promise<void> {
|
|
611
641
|
dialogOpen = true
|
|
@@ -820,8 +850,8 @@ const plugin = {
|
|
|
820
850
|
toast("Indexing started…", "info")
|
|
821
851
|
const summary =
|
|
822
852
|
from.state === "stale"
|
|
823
|
-
? await rpc()["index.refresh"]({
|
|
824
|
-
: await rpc()["index.build"]({ skipImport: false
|
|
853
|
+
? await rpc()["index.refresh"]({})
|
|
854
|
+
: await rpc()["index.build"]({ skipImport: false })
|
|
825
855
|
// A failed run comes back as data, not as a rejection: the reason is far
|
|
826
856
|
// more useful than "something went wrong", and it is already readable.
|
|
827
857
|
reportRun(summary)
|