@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.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/LICENSE +201 -0
- package/README.md +284 -2
- package/package.json +63 -4
- package/src/binary/detect.ts +55 -0
- package/src/binary/manager.ts +348 -0
- package/src/binary/release.ts +64 -0
- package/src/config.ts +381 -0
- package/src/hooks/session.ts +165 -0
- package/src/index/brain.ts +225 -0
- package/src/index/corpus.ts +164 -0
- package/src/index/hidden.ts +318 -0
- package/src/index/watcher.ts +333 -0
- package/src/index.ts +723 -0
- package/src/log.ts +88 -0
- package/src/memory/capture.ts +72 -0
- package/src/memory/store.ts +116 -0
- package/src/node/client.ts +361 -0
- package/src/node/supervisor.ts +267 -0
- package/src/paths.ts +202 -0
- package/src/platform.ts +88 -0
- package/src/remote/shell.ts +338 -0
- package/src/remote/spool.ts +217 -0
- package/src/rpc.ts +250 -0
- package/src/search/brains.ts +306 -0
- package/src/search/rank.ts +61 -0
- package/src/search/service.ts +589 -0
- package/src/semver.ts +117 -0
- package/src/stats/counters.ts +55 -0
- package/src/stats/ledger.ts +344 -0
- package/src/telemetry/anomaly.ts +211 -0
- package/src/tools/search.ts +384 -0
- package/src/tui.tsx +362 -0
- package/src/ui/format.ts +234 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Typed contract shared by the server part, the TUI part, and any client.
|
|
2
|
+
// Everything crosses a JSON boundary, so the schemas are JSON Schema objects
|
|
3
|
+
// rather than TypeScript-only shapes. The casts are unavoidable: JSON Schema
|
|
4
|
+
// values are structurally `unknown` in the plugin's type surface.
|
|
5
|
+
|
|
6
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
7
|
+
|
|
8
|
+
export interface NodeStatus {
|
|
9
|
+
readonly phase: "starting" | "ready" | "error" | "disabled";
|
|
10
|
+
readonly baseUrl: string | null;
|
|
11
|
+
readonly serverVersion: string | null;
|
|
12
|
+
readonly spawned: boolean;
|
|
13
|
+
readonly error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BinaryStatus {
|
|
17
|
+
readonly version: string | null;
|
|
18
|
+
readonly path: string | null;
|
|
19
|
+
readonly upgraded: boolean;
|
|
20
|
+
readonly target: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DatasetStatus {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly docs: number;
|
|
26
|
+
readonly bytes: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BrainStatus {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly prefix: string;
|
|
32
|
+
readonly docs: number;
|
|
33
|
+
readonly edges: number;
|
|
34
|
+
readonly lastRunAt: number | null;
|
|
35
|
+
readonly error: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MemoryStatus {
|
|
39
|
+
readonly namespace: string;
|
|
40
|
+
readonly count: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CitedFileStatus {
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly citations: number;
|
|
46
|
+
readonly bytes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IndexStatus {
|
|
50
|
+
readonly prefix: string;
|
|
51
|
+
readonly docs: number;
|
|
52
|
+
readonly bytes: number;
|
|
53
|
+
readonly indices: number;
|
|
54
|
+
readonly state: "disabled" | "starting" | "watching" | "idle" | "error";
|
|
55
|
+
readonly lastRunAt: number | null;
|
|
56
|
+
readonly lastGeneration: number | null;
|
|
57
|
+
readonly lastRecords: number | null;
|
|
58
|
+
readonly restarts: number;
|
|
59
|
+
readonly lastError: string | null;
|
|
60
|
+
readonly datasets: ReadonlyArray<DatasetStatus>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UsageStatus {
|
|
64
|
+
readonly queries: number;
|
|
65
|
+
readonly hits: number;
|
|
66
|
+
readonly hitRate: number;
|
|
67
|
+
readonly bytesReturned: number;
|
|
68
|
+
readonly bytesRead: number;
|
|
69
|
+
readonly baselineBytes: number;
|
|
70
|
+
readonly savedBytes: number;
|
|
71
|
+
readonly tokensSaved: number;
|
|
72
|
+
readonly topFiles: ReadonlyArray<CitedFileStatus>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CounterStatus {
|
|
76
|
+
readonly rpcCalls: number;
|
|
77
|
+
readonly injectedTurns: number;
|
|
78
|
+
readonly nodeSpawns: number;
|
|
79
|
+
readonly http429: number;
|
|
80
|
+
readonly indexRuns: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ConfigStatus {
|
|
84
|
+
readonly indexRoot: string;
|
|
85
|
+
readonly brain: ReadonlyArray<string>;
|
|
86
|
+
readonly watch: boolean;
|
|
87
|
+
readonly contextEnabled: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StatusSnapshot {
|
|
91
|
+
readonly pluginVersion: string;
|
|
92
|
+
readonly updatedAt: number;
|
|
93
|
+
readonly node: NodeStatus;
|
|
94
|
+
readonly binary: BinaryStatus;
|
|
95
|
+
readonly index: IndexStatus;
|
|
96
|
+
readonly config: ConfigStatus;
|
|
97
|
+
readonly usage: UsageStatus;
|
|
98
|
+
readonly memory: MemoryStatus;
|
|
99
|
+
readonly brains: ReadonlyArray<BrainStatus>;
|
|
100
|
+
readonly counters: CounterStatus;
|
|
101
|
+
readonly recent: ReadonlyArray<string>;
|
|
102
|
+
readonly warnings: ReadonlyArray<string>;
|
|
103
|
+
readonly errors: ReadonlyArray<string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type ValueSchema = Rpc.PortableValueSchema;
|
|
107
|
+
type EventSchema = Rpc.PortableEventDefinition["schema"];
|
|
108
|
+
|
|
109
|
+
function object(properties: Record<string, unknown>): Record<string, unknown> {
|
|
110
|
+
return {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties,
|
|
113
|
+
required: Object.keys(properties),
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function valueSchema(properties: Record<string, unknown>): ValueSchema {
|
|
119
|
+
return object(properties) as unknown as ValueSchema;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function eventSchema(properties: Record<string, unknown>): EventSchema {
|
|
123
|
+
return object(properties) as unknown as EventSchema;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const definition = {
|
|
127
|
+
id: "xerj",
|
|
128
|
+
methods: {
|
|
129
|
+
status: {
|
|
130
|
+
input: valueSchema({}),
|
|
131
|
+
output: valueSchema({
|
|
132
|
+
pluginVersion: { type: "string" },
|
|
133
|
+
updatedAt: { type: "number" },
|
|
134
|
+
node: object({
|
|
135
|
+
phase: { type: "string", enum: ["starting", "ready", "error", "disabled"] },
|
|
136
|
+
baseUrl: { type: ["string", "null"] },
|
|
137
|
+
serverVersion: { type: ["string", "null"] },
|
|
138
|
+
spawned: { type: "boolean" },
|
|
139
|
+
error: { type: ["string", "null"] },
|
|
140
|
+
}),
|
|
141
|
+
binary: object({
|
|
142
|
+
version: { type: ["string", "null"] },
|
|
143
|
+
path: { type: ["string", "null"] },
|
|
144
|
+
upgraded: { type: "boolean" },
|
|
145
|
+
target: { type: ["string", "null"] },
|
|
146
|
+
}),
|
|
147
|
+
index: object({
|
|
148
|
+
prefix: { type: "string" },
|
|
149
|
+
docs: { type: "number" },
|
|
150
|
+
bytes: { type: "number" },
|
|
151
|
+
indices: { type: "number" },
|
|
152
|
+
state: {
|
|
153
|
+
type: "string",
|
|
154
|
+
enum: ["disabled", "starting", "watching", "idle", "error"],
|
|
155
|
+
},
|
|
156
|
+
lastRunAt: { type: ["number", "null"] },
|
|
157
|
+
lastGeneration: { type: ["number", "null"] },
|
|
158
|
+
lastRecords: { type: ["number", "null"] },
|
|
159
|
+
restarts: { type: "number" },
|
|
160
|
+
lastError: { type: ["string", "null"] },
|
|
161
|
+
datasets: {
|
|
162
|
+
type: "array",
|
|
163
|
+
items: object({
|
|
164
|
+
name: { type: "string" },
|
|
165
|
+
docs: { type: "number" },
|
|
166
|
+
bytes: { type: "number" },
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
config: object({
|
|
171
|
+
indexRoot: { type: "string" },
|
|
172
|
+
brain: { type: "array", items: { type: "string" } },
|
|
173
|
+
watch: { type: "boolean" },
|
|
174
|
+
contextEnabled: { type: "boolean" },
|
|
175
|
+
}),
|
|
176
|
+
usage: object({
|
|
177
|
+
queries: { type: "number" },
|
|
178
|
+
hits: { type: "number" },
|
|
179
|
+
hitRate: { type: "number" },
|
|
180
|
+
bytesReturned: { type: "number" },
|
|
181
|
+
bytesRead: { type: "number" },
|
|
182
|
+
baselineBytes: { type: "number" },
|
|
183
|
+
savedBytes: { type: "number" },
|
|
184
|
+
tokensSaved: { type: "number" },
|
|
185
|
+
topFiles: {
|
|
186
|
+
type: "array",
|
|
187
|
+
items: object({
|
|
188
|
+
path: { type: "string" },
|
|
189
|
+
citations: { type: "number" },
|
|
190
|
+
bytes: { type: "number" },
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
memory: object({
|
|
195
|
+
namespace: { type: "string" },
|
|
196
|
+
count: { type: "number" },
|
|
197
|
+
}),
|
|
198
|
+
brains: {
|
|
199
|
+
type: "array",
|
|
200
|
+
items: object({
|
|
201
|
+
name: { type: "string" },
|
|
202
|
+
prefix: { type: "string" },
|
|
203
|
+
docs: { type: "number" },
|
|
204
|
+
edges: { type: "number" },
|
|
205
|
+
lastRunAt: { type: ["number", "null"] },
|
|
206
|
+
error: { type: ["string", "null"] },
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
counters: object({
|
|
210
|
+
rpcCalls: { type: "number" },
|
|
211
|
+
injectedTurns: { type: "number" },
|
|
212
|
+
nodeSpawns: { type: "number" },
|
|
213
|
+
http429: { type: "number" },
|
|
214
|
+
indexRuns: { type: "number" },
|
|
215
|
+
}),
|
|
216
|
+
recent: { type: "array", items: { type: "string" } },
|
|
217
|
+
warnings: { type: "array", items: { type: "string" } },
|
|
218
|
+
errors: { type: "array", items: { type: "string" } },
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
221
|
+
reindex: {
|
|
222
|
+
input: valueSchema({}),
|
|
223
|
+
output: valueSchema({ queued: { type: "boolean" } }),
|
|
224
|
+
},
|
|
225
|
+
remember: {
|
|
226
|
+
input: valueSchema({ text: { type: "string" } }),
|
|
227
|
+
output: valueSchema({
|
|
228
|
+
id: { type: "string" },
|
|
229
|
+
created: { type: "boolean" },
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
search: {
|
|
233
|
+
input: valueSchema({ query: { type: "string" } }),
|
|
234
|
+
output: valueSchema({
|
|
235
|
+
text: { type: "string" },
|
|
236
|
+
hits: { type: "number" },
|
|
237
|
+
bytes: { type: "number" },
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
events: {
|
|
242
|
+
updated: {
|
|
243
|
+
schema: eventSchema({ updatedAt: { type: "number" } }),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
} satisfies Rpc.PortableDefinition;
|
|
247
|
+
|
|
248
|
+
export const XerjRpc = Rpc.define(definition);
|
|
249
|
+
|
|
250
|
+
export const PLUGIN_VERSION = "0.4.1";
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// One hop of second-brain neighbors for retrieved passages. The graph stores
|
|
2
|
+
// edges, not text, so neighbor ids are resolved through the brain's own index.
|
|
3
|
+
//
|
|
4
|
+
// Only `pathcite` and `sequence` edges are used. `same_dir` says two files share
|
|
5
|
+
// a folder, which is not evidence of a useful relation.
|
|
6
|
+
|
|
7
|
+
import type { Logger } from "../log.ts";
|
|
8
|
+
import type { XerjClient } from "../node/client.ts";
|
|
9
|
+
import type { BrainSpec } from "../paths.ts";
|
|
10
|
+
import type { SearchHit } from "./service.ts";
|
|
11
|
+
|
|
12
|
+
export interface BrainGraphOptions {
|
|
13
|
+
readonly client: XerjClient;
|
|
14
|
+
readonly brains: ReadonlyArray<BrainSpec>;
|
|
15
|
+
readonly logger: Logger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NeighborRef {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly type: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ALLOWED_TYPES = new Set(["pathcite", "sequence"]);
|
|
24
|
+
const TYPE_PRIORITY: Record<string, number> = { pathcite: 0, sequence: 1 };
|
|
25
|
+
const MAX_NEIGHBORS = 2;
|
|
26
|
+
const MAX_TEXT_CHARS = 600;
|
|
27
|
+
const SOURCE_FIELDS = ["path", "ax_path", "title", "start_line", "kind", "body"];
|
|
28
|
+
|
|
29
|
+
export class BrainGraph {
|
|
30
|
+
constructor(private readonly options: BrainGraphOptions) {}
|
|
31
|
+
|
|
32
|
+
/** Neighbor passages for brain hits, capped at two per query. */
|
|
33
|
+
async related(
|
|
34
|
+
hits: ReadonlyArray<SearchHit>,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Promise<SearchHit[]> {
|
|
37
|
+
const groups = new Map<string, { spec: BrainSpec; seeds: string[] }>();
|
|
38
|
+
|
|
39
|
+
for (const hit of hits) {
|
|
40
|
+
const spec = this.specFor(hit.index);
|
|
41
|
+
if (!spec || hit.id.length === 0) continue;
|
|
42
|
+
|
|
43
|
+
const group = groups.get(spec.name) ?? { spec, seeds: [] };
|
|
44
|
+
if (!group.seeds.includes(hit.id)) group.seeds.push(hit.id);
|
|
45
|
+
groups.set(spec.name, group);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const related: SearchHit[] = [];
|
|
49
|
+
for (const group of groups.values()) {
|
|
50
|
+
if (related.length >= MAX_NEIGHBORS) break;
|
|
51
|
+
const found = await this.expand(
|
|
52
|
+
group.spec,
|
|
53
|
+
group.seeds.slice(0, 2),
|
|
54
|
+
MAX_NEIGHBORS - related.length,
|
|
55
|
+
signal,
|
|
56
|
+
);
|
|
57
|
+
related.push(...found);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return related;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private specFor(index: string): BrainSpec | undefined {
|
|
64
|
+
return this.options.brains.find((spec) => index.startsWith(`${spec.prefix}-`));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async expand(
|
|
68
|
+
spec: BrainSpec,
|
|
69
|
+
seeds: ReadonlyArray<string>,
|
|
70
|
+
limit: number,
|
|
71
|
+
signal?: AbortSignal,
|
|
72
|
+
): Promise<SearchHit[]> {
|
|
73
|
+
// The ego endpoint accepts one node per request; a comma-joined list
|
|
74
|
+
// answers with no edges. Query each seed and merge the edges.
|
|
75
|
+
const edges: unknown[] = [];
|
|
76
|
+
for (const seed of seeds) {
|
|
77
|
+
const path =
|
|
78
|
+
`/_graph/${encodeURIComponent(spec.name)}` +
|
|
79
|
+
`/ego?node=${encodeURIComponent(seed)}&hops=1&direction=both`;
|
|
80
|
+
const ego = await this.options.client
|
|
81
|
+
.get(path, "graph query failed", signal)
|
|
82
|
+
.catch((error: unknown) => {
|
|
83
|
+
this.options.logger.debug("brain expansion skipped", String(error));
|
|
84
|
+
return null;
|
|
85
|
+
});
|
|
86
|
+
if (!isRecord(ego) || !Array.isArray(ego.edges)) continue;
|
|
87
|
+
edges.push(...ego.edges);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const neighbors = pickNeighbors({ edges }, seeds, limit);
|
|
91
|
+
if (neighbors.length === 0) return [];
|
|
92
|
+
|
|
93
|
+
const response = await this.options.client
|
|
94
|
+
.search(
|
|
95
|
+
`${spec.prefix}-docs`,
|
|
96
|
+
{
|
|
97
|
+
size: neighbors.length,
|
|
98
|
+
_source: SOURCE_FIELDS,
|
|
99
|
+
query: { ids: { values: neighbors.map((neighbor) => neighbor.id) } },
|
|
100
|
+
},
|
|
101
|
+
signal,
|
|
102
|
+
)
|
|
103
|
+
.catch((error: unknown) => {
|
|
104
|
+
this.options.logger.debug("brain neighbor fetch skipped", String(error));
|
|
105
|
+
return null;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const direct = parseNeighbors(response, neighbors);
|
|
109
|
+
|
|
110
|
+
// A `pathcite` points at the target file's whole-file record, which has no
|
|
111
|
+
// `body`; only its section records carry text. Fetch the first section so
|
|
112
|
+
// the neighbor is usable.
|
|
113
|
+
const missing = bodylessPaths(response, neighbors, direct);
|
|
114
|
+
const sections = await this.fetchSections(spec, missing, signal);
|
|
115
|
+
return [...direct, ...sections].slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async fetchSections(
|
|
119
|
+
spec: BrainSpec,
|
|
120
|
+
missing: ReadonlyArray<{ path: string; type: string }>,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
): Promise<SearchHit[]> {
|
|
123
|
+
const found: SearchHit[] = [];
|
|
124
|
+
for (const entry of missing) {
|
|
125
|
+
if (found.length >= MAX_NEIGHBORS) break;
|
|
126
|
+
const response = await this.options.client
|
|
127
|
+
.search(
|
|
128
|
+
`${spec.prefix}-docs`,
|
|
129
|
+
{
|
|
130
|
+
size: 1,
|
|
131
|
+
sort: [{ start_line: "asc" }],
|
|
132
|
+
_source: SOURCE_FIELDS,
|
|
133
|
+
query: {
|
|
134
|
+
bool: {
|
|
135
|
+
must: [
|
|
136
|
+
{ term: { ax_path: entry.path } },
|
|
137
|
+
{ exists: { field: "body" } },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
signal,
|
|
143
|
+
)
|
|
144
|
+
.catch((error: unknown) => {
|
|
145
|
+
this.options.logger.debug("brain section fetch skipped", String(error));
|
|
146
|
+
return null;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const hit = firstHit(response, entry.type);
|
|
150
|
+
if (hit) found.push(hit);
|
|
151
|
+
}
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Chooses allowed edge types, preferring explicit citations. */
|
|
157
|
+
export function pickNeighbors(
|
|
158
|
+
ego: unknown,
|
|
159
|
+
seeds: ReadonlyArray<string>,
|
|
160
|
+
limit: number,
|
|
161
|
+
): NeighborRef[] {
|
|
162
|
+
if (!isRecord(ego) || !Array.isArray(ego.edges)) return [];
|
|
163
|
+
|
|
164
|
+
const seedSet = new Set(seeds);
|
|
165
|
+
const candidates: Array<NeighborRef & { weight: number }> = [];
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
|
|
168
|
+
for (const edge of ego.edges) {
|
|
169
|
+
if (!isRecord(edge)) continue;
|
|
170
|
+
if (edge.invalid_at !== null && edge.invalid_at !== undefined) continue;
|
|
171
|
+
|
|
172
|
+
const type = typeof edge.type === "string" ? edge.type : "";
|
|
173
|
+
if (!ALLOWED_TYPES.has(type)) continue;
|
|
174
|
+
|
|
175
|
+
const src = typeof edge.src === "string" ? edge.src : "";
|
|
176
|
+
const dst = typeof edge.dst === "string" ? edge.dst : "";
|
|
177
|
+
const other = seedSet.has(src) ? dst : seedSet.has(dst) ? src : "";
|
|
178
|
+
if (other.length === 0 || seedSet.has(other) || seen.has(other)) continue;
|
|
179
|
+
|
|
180
|
+
seen.add(other);
|
|
181
|
+
candidates.push({
|
|
182
|
+
id: other,
|
|
183
|
+
type,
|
|
184
|
+
weight: typeof edge.weight === "number" ? edge.weight : 0,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return candidates
|
|
189
|
+
.sort(
|
|
190
|
+
(left, right) =>
|
|
191
|
+
(TYPE_PRIORITY[left.type] ?? 9) - (TYPE_PRIORITY[right.type] ?? 9) ||
|
|
192
|
+
right.weight - left.weight,
|
|
193
|
+
)
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
.map(({ id, type }) => ({ id, type }));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Neighbor docs that were fetched but carry no text, with their file path. */
|
|
199
|
+
function bodylessPaths(
|
|
200
|
+
response: unknown,
|
|
201
|
+
neighbors: ReadonlyArray<NeighborRef>,
|
|
202
|
+
parsed: ReadonlyArray<SearchHit>,
|
|
203
|
+
): Array<{ path: string; type: string }> {
|
|
204
|
+
const found = new Map<string, string>();
|
|
205
|
+
for (const hit of hitsOf(response)) {
|
|
206
|
+
if (!isRecord(hit)) continue;
|
|
207
|
+
const id = stringValue(hit._id) ?? "";
|
|
208
|
+
if (id.length === 0) continue;
|
|
209
|
+
const source = isRecord(hit._source) ? hit._source : {};
|
|
210
|
+
const path = stringValue(source.ax_path) ?? stringValue(source.path);
|
|
211
|
+
if (path !== null && path.length > 0) found.set(id, path);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const done = new Set(parsed.map((hit) => hit.id));
|
|
215
|
+
const result: Array<{ path: string; type: string }> = [];
|
|
216
|
+
for (const neighbor of neighbors) {
|
|
217
|
+
if (done.has(neighbor.id)) continue;
|
|
218
|
+
const path = found.get(neighbor.id);
|
|
219
|
+
if (path) result.push({ path, type: neighbor.type });
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function firstHit(response: unknown, type: string): SearchHit | null {
|
|
225
|
+
for (const raw of hitsOf(response)) {
|
|
226
|
+
if (!isRecord(raw)) continue;
|
|
227
|
+
const source = isRecord(raw._source) ? raw._source : {};
|
|
228
|
+
const text = stringValue(source.body) ?? stringValue(source.code) ?? "";
|
|
229
|
+
if (text.trim().length === 0) continue;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
index: stringValue(raw._index) ?? "",
|
|
233
|
+
id: stringValue(raw._id) ?? "",
|
|
234
|
+
passage: 0,
|
|
235
|
+
path: stringValue(source.ax_path) ?? stringValue(source.path) ?? null,
|
|
236
|
+
line: numberValue(source.start_line),
|
|
237
|
+
title: stringValue(source.title),
|
|
238
|
+
kind: `via ${type}`,
|
|
239
|
+
score: 0,
|
|
240
|
+
text: clip(text.trim()),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseNeighbors(
|
|
247
|
+
response: unknown,
|
|
248
|
+
neighbors: ReadonlyArray<NeighborRef>,
|
|
249
|
+
): SearchHit[] {
|
|
250
|
+
const byId = new Map(neighbors.map((neighbor) => [neighbor.id, neighbor.type]));
|
|
251
|
+
const parsed: SearchHit[] = [];
|
|
252
|
+
|
|
253
|
+
for (const raw of hitsOf(response)) {
|
|
254
|
+
if (!isRecord(raw)) continue;
|
|
255
|
+
const id = stringValue(raw._id) ?? "";
|
|
256
|
+
const via = byId.get(id);
|
|
257
|
+
if (!via) continue;
|
|
258
|
+
|
|
259
|
+
const source = isRecord(raw._source) ? raw._source : {};
|
|
260
|
+
const text = stringValue(source.body) ?? stringValue(source.code) ?? "";
|
|
261
|
+
if (text.trim().length === 0) continue;
|
|
262
|
+
|
|
263
|
+
parsed.push({
|
|
264
|
+
index: stringValue(raw._index) ?? "",
|
|
265
|
+
id,
|
|
266
|
+
passage: 0,
|
|
267
|
+
path: stringValue(source.ax_path) ?? stringValue(source.path) ?? null,
|
|
268
|
+
line: numberValue(source.start_line),
|
|
269
|
+
title: stringValue(source.title),
|
|
270
|
+
kind: `via ${via}`,
|
|
271
|
+
score: 0,
|
|
272
|
+
text: clip(text.trim()),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return parsed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function hitsOf(response: unknown): unknown[] {
|
|
280
|
+
if (!isRecord(response)) return [];
|
|
281
|
+
const hits = response.hits;
|
|
282
|
+
if (!isRecord(hits) || !Array.isArray(hits.hits)) return [];
|
|
283
|
+
return hits.hits;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function clip(text: string): string {
|
|
287
|
+
if (text.length <= MAX_TEXT_CHARS) return text;
|
|
288
|
+
|
|
289
|
+
const cut = text.slice(0, MAX_TEXT_CHARS);
|
|
290
|
+
const lastNewline = cut.lastIndexOf("\n");
|
|
291
|
+
return lastNewline > MAX_TEXT_CHARS / 2
|
|
292
|
+
? `${cut.slice(0, lastNewline)}\n…`
|
|
293
|
+
: `${cut}\n…`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function stringValue(value: unknown): string | null {
|
|
297
|
+
return typeof value === "string" ? value : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function numberValue(value: unknown): number | null {
|
|
301
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Ranking adjustments applied after fusion. Generated files match on common
|
|
2
|
+
// words and crowd out the source they came from, so they are demoted rather
|
|
3
|
+
// than dropped: a query that names the file still has to find it.
|
|
4
|
+
|
|
5
|
+
import { sep } from "node:path";
|
|
6
|
+
import type { ParsedHit } from "./service.ts";
|
|
7
|
+
|
|
8
|
+
const GENERATED_WEIGHT = 0.35;
|
|
9
|
+
|
|
10
|
+
const GENERATED_NAMES = new Set([
|
|
11
|
+
"package-lock.json",
|
|
12
|
+
"npm-shrinkwrap.json",
|
|
13
|
+
"yarn.lock",
|
|
14
|
+
"pnpm-lock.yaml",
|
|
15
|
+
"bun.lock",
|
|
16
|
+
"bun.lockb",
|
|
17
|
+
"composer.lock",
|
|
18
|
+
"cargo.lock",
|
|
19
|
+
"gemfile.lock",
|
|
20
|
+
"poetry.lock",
|
|
21
|
+
"pipfile.lock",
|
|
22
|
+
"go.sum",
|
|
23
|
+
"flake.lock",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const GENERATED_DIRS = ["dist/", "build/", "vendor/", "coverage/"];
|
|
27
|
+
const MINIFIED_SUFFIXES = [".min.js", ".min.css", ".map"];
|
|
28
|
+
|
|
29
|
+
/** True when the path looks generated rather than authored. */
|
|
30
|
+
export function isGeneratedPath(path: string): boolean {
|
|
31
|
+
const normalized = path.split(sep).join("/").toLowerCase();
|
|
32
|
+
const name = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
33
|
+
|
|
34
|
+
if (GENERATED_NAMES.has(name)) return true;
|
|
35
|
+
if (MINIFIED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
36
|
+
return GENERATED_DIRS.some(
|
|
37
|
+
(dir) => normalized.startsWith(dir) || normalized.includes(`/${dir}`),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Re-ranks fused hits so generated files sit below authored ones. A query that
|
|
43
|
+
* names the file wins, because asking for `package-lock.json` must not demote
|
|
44
|
+
* it.
|
|
45
|
+
*/
|
|
46
|
+
export function demoteGenerated(
|
|
47
|
+
hits: ReadonlyArray<ParsedHit>,
|
|
48
|
+
query: string,
|
|
49
|
+
): ParsedHit[] {
|
|
50
|
+
const named = query.toLowerCase();
|
|
51
|
+
|
|
52
|
+
return hits
|
|
53
|
+
.map((hit) => {
|
|
54
|
+
if (hit.path === null) return hit;
|
|
55
|
+
if (!isGeneratedPath(hit.path)) return hit;
|
|
56
|
+
const basename = hit.path.toLowerCase().split("/").pop() ?? "";
|
|
57
|
+
if (named.includes(basename)) return hit;
|
|
58
|
+
return { ...hit, score: hit.score * GENERATED_WEIGHT };
|
|
59
|
+
})
|
|
60
|
+
.sort((left, right) => right.score - left.score);
|
|
61
|
+
}
|