@mrdrmccoy/xerj-opencode-plugin 0.4.4 → 0.4.6
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/package.json +1 -1
- package/src/hooks/session.ts +1 -0
- package/src/index/watcher.ts +32 -2
- package/src/index.ts +13 -4
- package/src/paths.ts +16 -1
- package/src/rpc.ts +1 -1
- package/src/search/rank.ts +20 -0
- package/src/search/service.ts +10 -2
package/package.json
CHANGED
package/src/hooks/session.ts
CHANGED
package/src/index/watcher.ts
CHANGED
|
@@ -47,6 +47,13 @@ const BACKOFF_MS = [1_000, 2_000, 5_000, 15_000, 30_000] as const;
|
|
|
47
47
|
const MAX_RESTARTS = 5;
|
|
48
48
|
const STOP_GRACE_MS = 3_000;
|
|
49
49
|
const STDERR_TAIL_LINES = 20;
|
|
50
|
+
// The engine refuses a second watcher for the same journal and says so.
|
|
51
|
+
const CONTENTION_MARKER = "already owns";
|
|
52
|
+
const CONTENTION_RETRY_MS = 5_000;
|
|
53
|
+
// A watcher that survives this long is watching, even before its first run
|
|
54
|
+
// document lands: an initial pass over a large corpus takes minutes, and the
|
|
55
|
+
// status would otherwise read "starting" the whole time.
|
|
56
|
+
const WATCH_SETTLE_MS = 3_000;
|
|
50
57
|
|
|
51
58
|
export class CorpusService {
|
|
52
59
|
readonly layout: CorpusLayout;
|
|
@@ -62,6 +69,8 @@ export class CorpusService {
|
|
|
62
69
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
63
70
|
private stopped = false;
|
|
64
71
|
private spawning = false;
|
|
72
|
+
private lastStderr = "";
|
|
73
|
+
private spawnedAt = 0;
|
|
65
74
|
|
|
66
75
|
constructor(private readonly options: CorpusServiceOptions) {
|
|
67
76
|
this.layout = corpusLayout(options.projectRoot, options.prefix, options.dataRoot);
|
|
@@ -124,8 +133,13 @@ export class CorpusService {
|
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
snapshot(): CorpusSnapshot {
|
|
136
|
+
const settled =
|
|
137
|
+
this.state === "starting" &&
|
|
138
|
+
this.child !== null &&
|
|
139
|
+
Date.now() - this.spawnedAt >= WATCH_SETTLE_MS;
|
|
140
|
+
|
|
127
141
|
return {
|
|
128
|
-
state: this.state,
|
|
142
|
+
state: settled ? "watching" : this.state,
|
|
129
143
|
lastRun: this.lastRun,
|
|
130
144
|
restarts: this.restarts,
|
|
131
145
|
runs: this.runs,
|
|
@@ -160,6 +174,8 @@ export class CorpusService {
|
|
|
160
174
|
|
|
161
175
|
this.child = child;
|
|
162
176
|
this.spawning = false;
|
|
177
|
+
this.lastStderr = "";
|
|
178
|
+
this.spawnedAt = Date.now();
|
|
163
179
|
if (watch) this.state = "starting";
|
|
164
180
|
|
|
165
181
|
this.attachOutput(child);
|
|
@@ -204,8 +220,9 @@ export class CorpusService {
|
|
|
204
220
|
});
|
|
205
221
|
|
|
206
222
|
child.once("close", () => {
|
|
223
|
+
this.lastStderr = stderr.join("").slice(-2000);
|
|
207
224
|
if (stderr.length > 0) {
|
|
208
|
-
this.options.logger.debug("autoindex stderr",
|
|
225
|
+
this.options.logger.debug("autoindex stderr", this.lastStderr);
|
|
209
226
|
}
|
|
210
227
|
});
|
|
211
228
|
}
|
|
@@ -213,6 +230,19 @@ export class CorpusService {
|
|
|
213
230
|
private handleWatchExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
214
231
|
const meaning = exitMeaning(code);
|
|
215
232
|
|
|
233
|
+
// Another process owns this journal, typically the plugin instance of a
|
|
234
|
+
// server that is being replaced. Ownership frees when that process exits,
|
|
235
|
+
// so wait it out instead of counting a failure or burning the restart
|
|
236
|
+
// budget.
|
|
237
|
+
if (this.lastStderr.includes(CONTENTION_MARKER)) {
|
|
238
|
+
this.state = "starting";
|
|
239
|
+
this.lastError = null;
|
|
240
|
+
this.options.logger.debug("autoindex watcher journal is owned elsewhere; retrying");
|
|
241
|
+
this.options.onChange?.();
|
|
242
|
+
setTimeout(() => this.spawn(true), CONTENTION_RETRY_MS);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
216
246
|
// A clean exit from `--watch` is unexpected; it should stay resident.
|
|
217
247
|
if (meaning === "decision") {
|
|
218
248
|
this.state = "idle";
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
environmentFrom,
|
|
28
28
|
externalPrefix,
|
|
29
29
|
indexPrefix,
|
|
30
|
+
isOverbroadRoot,
|
|
30
31
|
memoryNamespace,
|
|
31
32
|
projectStateDir,
|
|
32
33
|
webPrefix,
|
|
@@ -81,6 +82,14 @@ export default Plugin.define({
|
|
|
81
82
|
// The location may be a subdirectory of the project; index the project root
|
|
82
83
|
// so a session opened in `docs/` does not create a second corpus.
|
|
83
84
|
const projectRoot = ctx.location.project.canonical;
|
|
85
|
+
const overbroad = isOverbroadRoot(projectRoot);
|
|
86
|
+
const indexWarnings = [...warnings];
|
|
87
|
+
if (overbroad) {
|
|
88
|
+
const refusal =
|
|
89
|
+
`refusing to index ${projectRoot}: the home directory and filesystem roots are too broad. Open OpenCode inside a project.`;
|
|
90
|
+
indexWarnings.push(refusal);
|
|
91
|
+
logger.warn(refusal);
|
|
92
|
+
}
|
|
84
93
|
const prefix = indexPrefix(projectRoot);
|
|
85
94
|
const brains = brainSpecs(projectRoot, config.index.brain);
|
|
86
95
|
const hiddenRoots = documentRoots(projectRoot, config.index.hidden);
|
|
@@ -99,7 +108,7 @@ export default Plugin.define({
|
|
|
99
108
|
hiddenRoots,
|
|
100
109
|
anomaly: null,
|
|
101
110
|
error: null,
|
|
102
|
-
warnings:
|
|
111
|
+
warnings: indexWarnings,
|
|
103
112
|
};
|
|
104
113
|
|
|
105
114
|
const ledger = new Ledger({
|
|
@@ -502,7 +511,7 @@ export default Plugin.define({
|
|
|
502
511
|
.catch(() => {});
|
|
503
512
|
},
|
|
504
513
|
});
|
|
505
|
-
await runtime.corpus.start();
|
|
514
|
+
if (!overbroad) await runtime.corpus.start();
|
|
506
515
|
|
|
507
516
|
if (runtime.hiddenRoots.length > 0) {
|
|
508
517
|
runtime.hidden = new HiddenIndex({
|
|
@@ -521,7 +530,7 @@ export default Plugin.define({
|
|
|
521
530
|
},
|
|
522
531
|
});
|
|
523
532
|
|
|
524
|
-
if (config.index.auto) await runtime.hidden.start();
|
|
533
|
+
if (config.index.auto && !overbroad) await runtime.hidden.start();
|
|
525
534
|
}
|
|
526
535
|
|
|
527
536
|
if (runtime.brains.length > 0) {
|
|
@@ -544,7 +553,7 @@ export default Plugin.define({
|
|
|
544
553
|
|
|
545
554
|
// One-shot: graph indexing cannot run in watch mode. `index.auto`
|
|
546
555
|
// gates startup runs only; an explicit reindex still works.
|
|
547
|
-
if (config.index.auto) void runtime.brain.runAll();
|
|
556
|
+
if (config.index.auto && !overbroad) void runtime.brain.runAll();
|
|
548
557
|
}
|
|
549
558
|
|
|
550
559
|
await registration.events.emit("updated", { updatedAt: Date.now() });
|
package/src/paths.ts
CHANGED
|
@@ -12,7 +12,6 @@ export interface Environment {
|
|
|
12
12
|
readonly HOME?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Picks the variables that matter out of a wider process environment. */
|
|
16
15
|
export function environmentFrom(env: Readonly<Record<string, string | undefined>>): Environment {
|
|
17
16
|
return {
|
|
18
17
|
XDG_DATA_HOME: env.XDG_DATA_HOME,
|
|
@@ -21,6 +20,22 @@ export function environmentFrom(env: Readonly<Record<string, string | undefined>
|
|
|
21
20
|
};
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
/**
|
|
24
|
+
* True when a directory is too broad to index: the home directory itself or a
|
|
25
|
+
* filesystem root. Indexing it walks every cache, download and trash folder and
|
|
26
|
+
* costs minutes of I/O for results nobody asked for.
|
|
27
|
+
*/
|
|
28
|
+
export function isOverbroadRoot(
|
|
29
|
+
directory: string,
|
|
30
|
+
home: string = homedir(),
|
|
31
|
+
platform: NodeJS.Platform = process.platform,
|
|
32
|
+
): boolean {
|
|
33
|
+
const resolved = resolve(directory);
|
|
34
|
+
const root = platform === "win32" ? resolve(resolved, sep) : "/";
|
|
35
|
+
return resolved === root || resolved === resolve(home);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
24
39
|
const APP_DIR = "opencode/xerj";
|
|
25
40
|
const PREFIX_ROOT = "xop";
|
|
26
41
|
const MAX_SLUG_LENGTH = 24;
|
package/src/rpc.ts
CHANGED
package/src/search/rank.ts
CHANGED
|
@@ -59,3 +59,23 @@ export function demoteGenerated(
|
|
|
59
59
|
})
|
|
60
60
|
.sort((left, right) => right.score - left.score);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Drops generated files the query does not name. Injection shares one small
|
|
65
|
+
* budget across a whole turn, and a lockfile passage matches on dependency
|
|
66
|
+
* names that have nothing to do with the question, so it can crowd out the
|
|
67
|
+
* source that answers it. Tool searches keep them: the caller asked directly.
|
|
68
|
+
*/
|
|
69
|
+
export function dropGenerated(
|
|
70
|
+
hits: ReadonlyArray<ParsedHit>,
|
|
71
|
+
query: string,
|
|
72
|
+
): ParsedHit[] {
|
|
73
|
+
const named = query.toLowerCase();
|
|
74
|
+
|
|
75
|
+
return hits.filter((hit) => {
|
|
76
|
+
if (hit.path === null) return true;
|
|
77
|
+
if (!isGeneratedPath(hit.path)) return true;
|
|
78
|
+
const basename = hit.path.toLowerCase().split("/").pop() ?? "";
|
|
79
|
+
return named.includes(basename);
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/search/service.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { Logger } from "../log.ts";
|
|
|
14
14
|
import { XerjClient } from "../node/client.ts";
|
|
15
15
|
import type { BrainSpec } from "../paths.ts";
|
|
16
16
|
import type { BrainGraph } from "./brains.ts";
|
|
17
|
-
import { demoteGenerated } from "./rank.ts";
|
|
17
|
+
import { demoteGenerated, dropGenerated } from "./rank.ts";
|
|
18
18
|
|
|
19
19
|
export interface SearchHit {
|
|
20
20
|
readonly index: string;
|
|
@@ -36,7 +36,14 @@ export interface SearchResult {
|
|
|
36
36
|
readonly narrowingBytes: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Where the results go. Injection shares a small budget across a whole turn,
|
|
41
|
+
* so it is stricter than an explicit tool call about generated files.
|
|
42
|
+
*/
|
|
43
|
+
export type SearchSurface = "context" | "tool";
|
|
44
|
+
|
|
39
45
|
export interface SearchOptions {
|
|
46
|
+
readonly surface?: SearchSurface;
|
|
40
47
|
readonly k?: number;
|
|
41
48
|
readonly maxTokens?: number;
|
|
42
49
|
readonly signal?: AbortSignal;
|
|
@@ -132,7 +139,8 @@ export class SearchService {
|
|
|
132
139
|
|
|
133
140
|
const strategy = semantic ? "hybrid" : "lexical";
|
|
134
141
|
const fused = semantic ? fuse([lexical.hits, semantic.hits], k) : lexical.hits.slice(0, k);
|
|
135
|
-
const
|
|
142
|
+
const ranked = demoteGenerated(this.locateBrainPaths(fused), trimmed);
|
|
143
|
+
const located = options.surface === "context" ? dropGenerated(ranked, trimmed) : ranked;
|
|
136
144
|
const resolved = await this.resolveLines(located, trimmed);
|
|
137
145
|
const hits = dropShadowedWholeFiles(
|
|
138
146
|
resolved,
|