@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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Shell reads are not tool calls the plugin can hook per file, so a bash
|
|
2
|
+
// command is parsed conservatively: every simple command's literal arguments
|
|
3
|
+
// are treated as file references, and only arguments that resolve to an
|
|
4
|
+
// existing regular file are returned. Variables, globs, and redirection
|
|
5
|
+
// targets are skipped rather than guessed at.
|
|
6
|
+
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
const WRAPPERS = new Set([
|
|
11
|
+
"sudo",
|
|
12
|
+
"env",
|
|
13
|
+
"command",
|
|
14
|
+
"time",
|
|
15
|
+
"nice",
|
|
16
|
+
"nohup",
|
|
17
|
+
"timeout",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const MAX_CANDIDATES = 24;
|
|
21
|
+
const MAX_FILES = 8;
|
|
22
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
23
|
+
/** A single-letter short option; its value, if any, is the next token. */
|
|
24
|
+
const SHORT_FLAG = /^-[A-Za-z]$/;
|
|
25
|
+
|
|
26
|
+
/** Splits a shell command into simple commands, one token list each. */
|
|
27
|
+
export function shellCommands(command: string): string[][] {
|
|
28
|
+
return tokenize(substituteCommands(command));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Inlines `$(...)` and backtick substitutions as commands that run before the
|
|
33
|
+
* outer one. A substitution is a real command, so a file it reads is a
|
|
34
|
+
* reference; leaving it inside a token would hide it. Single-quoted text is
|
|
35
|
+
* literal and is copied verbatim.
|
|
36
|
+
*/
|
|
37
|
+
export function substituteCommands(command: string): string {
|
|
38
|
+
const bodies: string[] = [];
|
|
39
|
+
let outer = "";
|
|
40
|
+
let index = 0;
|
|
41
|
+
|
|
42
|
+
while (index < command.length) {
|
|
43
|
+
const char = command[index] ?? "";
|
|
44
|
+
|
|
45
|
+
if (char === "'") {
|
|
46
|
+
const end = command.indexOf("'", index + 1);
|
|
47
|
+
const stop = end === -1 ? command.length : end + 1;
|
|
48
|
+
outer += command.slice(index, stop);
|
|
49
|
+
index = stop;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (char === "$" && command[index + 1] === "(") {
|
|
54
|
+
const end = matchParen(command, index + 1);
|
|
55
|
+
bodies.push(command.slice(index + 2, end));
|
|
56
|
+
outer += " ";
|
|
57
|
+
index = end + 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (char === "`") {
|
|
62
|
+
const end = matchBacktick(command, index);
|
|
63
|
+
bodies.push(command.slice(index + 1, end));
|
|
64
|
+
outer += " ";
|
|
65
|
+
index = end + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
outer += char;
|
|
70
|
+
index += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Substitutions expand before the command they belong to runs.
|
|
74
|
+
return bodies.length === 0 ? outer : `${bodies.join(" ; ")} ; ${outer}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Index of the `)` that closes the `(` at `open`, or end of input. */
|
|
78
|
+
function matchParen(command: string, open: number): number {
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let quote: "'" | '"' | null = null;
|
|
81
|
+
|
|
82
|
+
for (let index = open; index < command.length; index += 1) {
|
|
83
|
+
const char = command[index] ?? "";
|
|
84
|
+
if (quote !== null) {
|
|
85
|
+
if (char === quote) quote = null;
|
|
86
|
+
} else if (char === "'" || char === '"') {
|
|
87
|
+
quote = char;
|
|
88
|
+
} else if (char === "(") {
|
|
89
|
+
depth += 1;
|
|
90
|
+
} else if (char === ")") {
|
|
91
|
+
depth -= 1;
|
|
92
|
+
if (depth === 0) return index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return command.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Index of the backtick that closes the one at `open`, or end of input. */
|
|
100
|
+
function matchBacktick(command: string, open: number): number {
|
|
101
|
+
for (let index = open + 1; index < command.length; index += 1) {
|
|
102
|
+
const char = command[index] ?? "";
|
|
103
|
+
if (char === "\\") {
|
|
104
|
+
index += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (char === "`") return index;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return command.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tokenize(command: string): string[][] {
|
|
114
|
+
const commands: string[][] = [];
|
|
115
|
+
let current: string[] = [];
|
|
116
|
+
let token = "";
|
|
117
|
+
let hasToken = false;
|
|
118
|
+
let quote: "'" | '"' | null = null;
|
|
119
|
+
let escaped = false;
|
|
120
|
+
let skipNext = false;
|
|
121
|
+
|
|
122
|
+
const flushToken = (): void => {
|
|
123
|
+
if (!hasToken) return;
|
|
124
|
+
if (!skipNext) current.push(token);
|
|
125
|
+
skipNext = false;
|
|
126
|
+
token = "";
|
|
127
|
+
hasToken = false;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const flushCommand = (): void => {
|
|
131
|
+
flushToken();
|
|
132
|
+
if (current.length > 0) commands.push(current);
|
|
133
|
+
current = [];
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
137
|
+
const char = command[index] ?? "";
|
|
138
|
+
|
|
139
|
+
if (escaped) {
|
|
140
|
+
token += char;
|
|
141
|
+
hasToken = true;
|
|
142
|
+
escaped = false;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (char === "\\" && quote !== "'") {
|
|
147
|
+
escaped = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (quote !== null) {
|
|
152
|
+
if (char === quote) quote = null;
|
|
153
|
+
else {
|
|
154
|
+
token += char;
|
|
155
|
+
hasToken = true;
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (char === "'" || char === '"') {
|
|
161
|
+
quote = char;
|
|
162
|
+
hasToken = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (char === " " || char === "\t" || char === "\r") {
|
|
167
|
+
flushToken();
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (char === "\n" || char === ";" || char === "|" || char === "&") {
|
|
172
|
+
flushCommand();
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A subshell starts a new command scope; treat the parens as separators so
|
|
177
|
+
// `(cd dir && cat a)` resolves against `dir`. A closing paren ends the
|
|
178
|
+
// current token, which may sit right against it (`cat a)`).
|
|
179
|
+
if (char === ")") {
|
|
180
|
+
flushCommand();
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (char === "(" && !hasToken) {
|
|
185
|
+
flushCommand();
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (char === "<" || char === ">") {
|
|
190
|
+
// The target of a redirection is not read, so drop the next token. A
|
|
191
|
+
// leading file descriptor (`2>`) is part of the redirection, not an
|
|
192
|
+
// argument.
|
|
193
|
+
if (/^\d$/.test(token)) {
|
|
194
|
+
token = "";
|
|
195
|
+
hasToken = false;
|
|
196
|
+
}
|
|
197
|
+
flushToken();
|
|
198
|
+
skipNext = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (char === "#" && !hasToken) {
|
|
203
|
+
while (index + 1 < command.length && command[index + 1] !== "\n") {
|
|
204
|
+
index += 1;
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
token += char;
|
|
210
|
+
hasToken = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
flushCommand();
|
|
214
|
+
return commands;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Existing files a command chain references, resolved against its cwd. Every
|
|
219
|
+
* literal argument is a candidate, so `grep pattern file.md` and
|
|
220
|
+
* `./scripts/gen.sh` count the same as `cat file.md`; the stat call, not a
|
|
221
|
+
* command whitelist, decides what is real.
|
|
222
|
+
*/
|
|
223
|
+
export async function shellReadPaths(
|
|
224
|
+
command: string,
|
|
225
|
+
cwd: string,
|
|
226
|
+
): Promise<string[]> {
|
|
227
|
+
const base = resolve(cwd);
|
|
228
|
+
const found = candidatesFrom(command, base);
|
|
229
|
+
|
|
230
|
+
const files: string[] = [];
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
|
|
233
|
+
for (const candidate of found) {
|
|
234
|
+
const info = await stat(candidate).catch(() => null);
|
|
235
|
+
if (!info?.isFile()) continue;
|
|
236
|
+
if (seen.has(candidate)) continue;
|
|
237
|
+
|
|
238
|
+
seen.add(candidate);
|
|
239
|
+
files.push(candidate);
|
|
240
|
+
if (files.length >= MAX_FILES) break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return files;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Literal path-shaped arguments, in command order, deduped and capped. */
|
|
247
|
+
function candidatesFrom(command: string, base: string): string[] {
|
|
248
|
+
const found: string[] = [];
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
let currentCwd = base;
|
|
251
|
+
|
|
252
|
+
const add = (value: string): void => {
|
|
253
|
+
if (!isLiteral(value)) return;
|
|
254
|
+
|
|
255
|
+
const absolute = isAbsolute(value)
|
|
256
|
+
? resolve(value)
|
|
257
|
+
: resolve(currentCwd, value);
|
|
258
|
+
if (seen.has(absolute)) return;
|
|
259
|
+
|
|
260
|
+
seen.add(absolute);
|
|
261
|
+
found.push(absolute);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
for (const tokens of shellCommands(command)) {
|
|
265
|
+
let index = 0;
|
|
266
|
+
while (index < tokens.length) {
|
|
267
|
+
const token = tokens[index] ?? "";
|
|
268
|
+
if (
|
|
269
|
+
WRAPPERS.has(basename(token)) ||
|
|
270
|
+
token.startsWith("-") ||
|
|
271
|
+
ASSIGNMENT.test(token)
|
|
272
|
+
) {
|
|
273
|
+
index += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const word = tokens[index] ?? "";
|
|
280
|
+
index += 1;
|
|
281
|
+
|
|
282
|
+
// An executable referenced by path (`./scripts/gen.sh`) is a file too.
|
|
283
|
+
if (word.includes("/")) add(word);
|
|
284
|
+
|
|
285
|
+
if (word === "cd") {
|
|
286
|
+
const target = tokens[index];
|
|
287
|
+
if (target !== undefined && isLiteral(target)) {
|
|
288
|
+
currentCwd = isAbsolute(target)
|
|
289
|
+
? resolve(target)
|
|
290
|
+
: resolve(currentCwd, target);
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// `xargs -a list` reads arguments (paths) from a file, so that file is a
|
|
296
|
+
// reference. Arguments xargs receives on stdin cannot be known here.
|
|
297
|
+
if (basename(word) === "xargs") {
|
|
298
|
+
for (let cursor = index; cursor < tokens.length; cursor += 1) {
|
|
299
|
+
const argument = tokens[cursor] ?? "";
|
|
300
|
+
if (argument === "-a" || argument === "--arg-file") {
|
|
301
|
+
const value = tokens[cursor + 1];
|
|
302
|
+
if (value !== undefined) {
|
|
303
|
+
add(value);
|
|
304
|
+
cursor += 1;
|
|
305
|
+
}
|
|
306
|
+
} else if (argument.startsWith("--arg-file=")) {
|
|
307
|
+
add(argument.slice("--arg-file=".length));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let afterShortFlag = false;
|
|
314
|
+
for (const argument of tokens.slice(index)) {
|
|
315
|
+
if (afterShortFlag) {
|
|
316
|
+
afterShortFlag = false;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (argument.startsWith("-")) {
|
|
321
|
+
afterShortFlag = SHORT_FLAG.test(argument);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (argument.startsWith("+")) continue;
|
|
325
|
+
|
|
326
|
+
add(argument);
|
|
327
|
+
if (found.length >= MAX_CANDIDATES) return found;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Variables, globs, and tilde expansion need a shell to resolve. */
|
|
335
|
+
function isLiteral(value: string): boolean {
|
|
336
|
+
if (value === "-" || value.startsWith("~")) return false;
|
|
337
|
+
return !/[$*?[\]{}]/.test(value);
|
|
338
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Remote documents: fetched pages spool to disk for the web corpus watcher,
|
|
2
|
+
// and explicit external files are ingested through `_bulk` because autoindex
|
|
3
|
+
// refuses a single-file path.
|
|
4
|
+
//
|
|
5
|
+
// The spool lives under the data root, never in the project: its files are
|
|
6
|
+
// events for the watcher, and the watched tree must not contain the journal.
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import type { Logger } from "../log.ts";
|
|
12
|
+
import { NodeRequestError, type XerjClient } from "../node/client.ts";
|
|
13
|
+
|
|
14
|
+
export interface SpoolOptions {
|
|
15
|
+
readonly root: string;
|
|
16
|
+
readonly projectRoot: string;
|
|
17
|
+
readonly prefix: string;
|
|
18
|
+
readonly externalPrefix: string;
|
|
19
|
+
readonly maxBytes: number;
|
|
20
|
+
readonly client: XerjClient;
|
|
21
|
+
readonly logger: Logger;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StoredDocument {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly bytes: number;
|
|
27
|
+
readonly title: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Text formats the plugin will index from disk; binaries are skipped. */
|
|
31
|
+
const TEXT_EXTENSIONS = new Set([
|
|
32
|
+
".md",
|
|
33
|
+
".markdown",
|
|
34
|
+
".txt",
|
|
35
|
+
".rst",
|
|
36
|
+
".json",
|
|
37
|
+
".yaml",
|
|
38
|
+
".yml",
|
|
39
|
+
".toml",
|
|
40
|
+
".csv",
|
|
41
|
+
".tsv",
|
|
42
|
+
".html",
|
|
43
|
+
".htm",
|
|
44
|
+
".xml",
|
|
45
|
+
".log",
|
|
46
|
+
".ts",
|
|
47
|
+
".tsx",
|
|
48
|
+
".js",
|
|
49
|
+
".py",
|
|
50
|
+
".rs",
|
|
51
|
+
".go",
|
|
52
|
+
".java",
|
|
53
|
+
".rb",
|
|
54
|
+
".sh",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
export class RemoteSpool {
|
|
58
|
+
readonly directory: string;
|
|
59
|
+
|
|
60
|
+
private mapped = false;
|
|
61
|
+
/** `<absolute path> -> mtime:size`, so a repeated read does not re-bulk. */
|
|
62
|
+
private readonly externalStamps = new Map<string, string>();
|
|
63
|
+
|
|
64
|
+
constructor(private readonly options: SpoolOptions) {
|
|
65
|
+
this.directory = join(options.root, "spool", options.prefix);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async init(): Promise<void> {
|
|
69
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stores a fetched page as markdown and drops any earlier version of it. */
|
|
73
|
+
async ingestUrl(url: string, content: string, title?: string): Promise<StoredDocument | null> {
|
|
74
|
+
const body = redact(truncate(content, this.options.maxBytes)).trim();
|
|
75
|
+
if (body.length === 0) return null;
|
|
76
|
+
|
|
77
|
+
const id = digest(url).slice(0, 20);
|
|
78
|
+
const document = [
|
|
79
|
+
`# ${title?.trim() || url}`,
|
|
80
|
+
"",
|
|
81
|
+
`Source: ${url}`,
|
|
82
|
+
`Fetched: ${new Date().toISOString()}`,
|
|
83
|
+
"",
|
|
84
|
+
body,
|
|
85
|
+
"",
|
|
86
|
+
].join("\n");
|
|
87
|
+
|
|
88
|
+
await this.init();
|
|
89
|
+
await this.removeVersions(id);
|
|
90
|
+
|
|
91
|
+
const path = join(this.directory, `${id}.md`);
|
|
92
|
+
await writeFile(path, document, { mode: 0o600 });
|
|
93
|
+
|
|
94
|
+
return { path, bytes: Buffer.byteLength(document, "utf8"), title: title ?? url };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Ingests one file outside the project index through `_bulk`. */
|
|
98
|
+
async ingestExternalFile(
|
|
99
|
+
filePath: string,
|
|
100
|
+
options: { skipUnchanged?: boolean } = {},
|
|
101
|
+
): Promise<StoredDocument | null> {
|
|
102
|
+
const absolute = resolve(filePath);
|
|
103
|
+
if (!isTextPath(absolute) || isSecretPath(absolute)) return null;
|
|
104
|
+
|
|
105
|
+
const info = await stat(absolute).catch(() => null);
|
|
106
|
+
if (!info?.isFile() || info.size === 0) return null;
|
|
107
|
+
|
|
108
|
+
const stamp = `${info.mtimeMs}:${info.size}`;
|
|
109
|
+
if (options.skipUnchanged && this.externalStamps.get(absolute) === stamp) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = await readFile(absolute, "utf8").catch(() => null);
|
|
114
|
+
if (raw === null || raw.includes("\u0000")) return null;
|
|
115
|
+
|
|
116
|
+
const body = redact(truncate(raw, this.options.maxBytes));
|
|
117
|
+
if (body.trim().length === 0) return null;
|
|
118
|
+
|
|
119
|
+
const root = resolve(this.options.projectRoot);
|
|
120
|
+
const inside = absolute === root || absolute.startsWith(root + sep);
|
|
121
|
+
const relativePath = inside ? relative(root, absolute) : absolute;
|
|
122
|
+
|
|
123
|
+
await this.ensureMapping();
|
|
124
|
+
|
|
125
|
+
const source = {
|
|
126
|
+
body,
|
|
127
|
+
title: basename(absolute),
|
|
128
|
+
path: relativePath,
|
|
129
|
+
abs_path: absolute,
|
|
130
|
+
mtime: new Date(info.mtimeMs).toISOString(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// A stable id means a changed file replaces its old document.
|
|
134
|
+
await this.options.client.bulk(`${this.options.externalPrefix}-docs`, [
|
|
135
|
+
{ id: digest(absolute).slice(0, 24), source },
|
|
136
|
+
]);
|
|
137
|
+
this.externalStamps.set(absolute, stamp);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
path: relativePath,
|
|
141
|
+
bytes: Buffer.byteLength(body, "utf8"),
|
|
142
|
+
title: basename(absolute),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async ensureMapping(): Promise<void> {
|
|
147
|
+
if (this.mapped) return;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await this.options.client.put(
|
|
151
|
+
`/${this.options.externalPrefix}-docs`,
|
|
152
|
+
{
|
|
153
|
+
mappings: {
|
|
154
|
+
properties: {
|
|
155
|
+
body: { type: "semantic_text" },
|
|
156
|
+
title: { type: "keyword" },
|
|
157
|
+
path: { type: "keyword" },
|
|
158
|
+
abs_path: { type: "keyword" },
|
|
159
|
+
mtime: { type: "date" },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
"could not create the external index",
|
|
164
|
+
);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// The index survives restarts; 400/409 mean it already exists.
|
|
167
|
+
if (
|
|
168
|
+
!(error instanceof NodeRequestError) ||
|
|
169
|
+
(error.status !== 400 && error.status !== 409)
|
|
170
|
+
) {
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.mapped = true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async removeVersions(id: string): Promise<void> {
|
|
179
|
+
await rm(join(this.directory, `${id}.md`), { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function isTextPath(filePath: string): boolean {
|
|
184
|
+
return TEXT_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const SECRET_FILES = new Set([".env", ".netrc", ".npmrc", ".pypirc"]);
|
|
188
|
+
const SECRET_EXTENSIONS = /\.(pem|key|p12|pfx|crt)$/i;
|
|
189
|
+
|
|
190
|
+
/** Secrets are not indexed even when a tool reads them. */
|
|
191
|
+
export function isSecretPath(filePath: string): boolean {
|
|
192
|
+
const name = basename(filePath).toLowerCase();
|
|
193
|
+
if (SECRET_FILES.has(name) || name.startsWith(".env.")) return true;
|
|
194
|
+
return SECRET_EXTENSIONS.test(name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function digest(value: string): string {
|
|
198
|
+
return createHash("sha256").update(value).digest("hex");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function truncate(text: string, maxBytes: number): string {
|
|
202
|
+
if (maxBytes <= 0 || Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
203
|
+
|
|
204
|
+
// Cut on a character boundary: encoding the slice first keeps multi-byte
|
|
205
|
+
// characters intact when the byte cap lands inside one.
|
|
206
|
+
const buffer = Buffer.from(text, "utf8").subarray(0, maxBytes);
|
|
207
|
+
return buffer.toString("utf8").replace(/\uFFFD$/, "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Best effort: fetched pages can carry keys in prose or URLs. */
|
|
211
|
+
export function redact(text: string): string {
|
|
212
|
+
return text
|
|
213
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._-]{8,}/g, "$1[redacted]")
|
|
214
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "sk-[redacted]")
|
|
215
|
+
.replace(/\bghp_[A-Za-z0-9]{20,}/g, "ghp_[redacted]")
|
|
216
|
+
.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox[redacted]");
|
|
217
|
+
}
|