@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/index.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
// Server part. Loads config, brings up the binary and node in the background,
|
|
2
|
+
// and registers tools, hooks, and RPC. Nothing here blocks plugin load: a slow
|
|
3
|
+
// download or a missing node must not delay OpenCode startup.
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
7
|
+
import { Plugin } from "@opencode/plugin";
|
|
8
|
+
import { detectRuntime } from "./binary/detect.ts";
|
|
9
|
+
import { ensureBinary, externalBinary, type InstalledBinary } from "./binary/manager.ts";
|
|
10
|
+
import { loadConfig } from "./config.ts";
|
|
11
|
+
import { registerSessionHooks } from "./hooks/session.ts";
|
|
12
|
+
import { BrainIndexer, type BrainRun } from "./index/brain.ts";
|
|
13
|
+
import { HiddenIndex } from "./index/hidden.ts";
|
|
14
|
+
import { CorpusService, type CorpusSnapshot } from "./index/watcher.ts";
|
|
15
|
+
import { createLogger, type KeyValueStorage, type Logger } from "./log.ts";
|
|
16
|
+
import { XerjClient, type IndexSummary } from "./node/client.ts";
|
|
17
|
+
import {
|
|
18
|
+
ensureNode,
|
|
19
|
+
persistPort,
|
|
20
|
+
readPersistedPort,
|
|
21
|
+
type NodeHandle,
|
|
22
|
+
} from "./node/supervisor.ts";
|
|
23
|
+
import {
|
|
24
|
+
brainSpecs,
|
|
25
|
+
dataRoot,
|
|
26
|
+
documentRoots,
|
|
27
|
+
environmentFrom,
|
|
28
|
+
externalPrefix,
|
|
29
|
+
indexPrefix,
|
|
30
|
+
memoryNamespace,
|
|
31
|
+
projectStateDir,
|
|
32
|
+
webPrefix,
|
|
33
|
+
type BrainSpec,
|
|
34
|
+
type DocumentRoot,
|
|
35
|
+
} from "./paths.ts";
|
|
36
|
+
import {
|
|
37
|
+
PLUGIN_VERSION,
|
|
38
|
+
XerjRpc,
|
|
39
|
+
type BrainStatus,
|
|
40
|
+
type DatasetStatus,
|
|
41
|
+
type StatusSnapshot,
|
|
42
|
+
} from "./rpc.ts";
|
|
43
|
+
import { MemoryStore } from "./memory/store.ts";
|
|
44
|
+
import { startCompactionCapture } from "./memory/capture.ts";
|
|
45
|
+
import { RemoteSpool } from "./remote/spool.ts";
|
|
46
|
+
import { shellReadPaths } from "./remote/shell.ts";
|
|
47
|
+
import { BrainGraph } from "./search/brains.ts";
|
|
48
|
+
import { SearchService } from "./search/service.ts";
|
|
49
|
+
import { Ledger } from "./stats/ledger.ts";
|
|
50
|
+
import { Counters } from "./stats/counters.ts";
|
|
51
|
+
import { AnomalyService } from "./telemetry/anomaly.ts";
|
|
52
|
+
import { buildTools } from "./tools/search.ts";
|
|
53
|
+
|
|
54
|
+
interface Runtime {
|
|
55
|
+
phase: "starting" | "ready" | "error" | "disabled";
|
|
56
|
+
binary: InstalledBinary | null;
|
|
57
|
+
node: NodeHandle | null;
|
|
58
|
+
corpus: CorpusService | null;
|
|
59
|
+
search: SearchService | null;
|
|
60
|
+
memory: MemoryStore | null;
|
|
61
|
+
spool: RemoteSpool | null;
|
|
62
|
+
web: CorpusService | null;
|
|
63
|
+
brain: BrainIndexer | null;
|
|
64
|
+
brains: ReadonlyArray<BrainSpec>;
|
|
65
|
+
hidden: HiddenIndex | null;
|
|
66
|
+
hiddenRoots: ReadonlyArray<DocumentRoot>;
|
|
67
|
+
anomaly: AnomalyService | null;
|
|
68
|
+
error: string | null;
|
|
69
|
+
warnings: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default Plugin.define({
|
|
73
|
+
id: "xerj",
|
|
74
|
+
async setup(ctx) {
|
|
75
|
+
const { config, warnings } = loadConfig(ctx.options, process.env);
|
|
76
|
+
const storage = asStorage(ctx.storage);
|
|
77
|
+
const logger = createLogger(storage);
|
|
78
|
+
for (const warning of warnings) logger.warn(warning);
|
|
79
|
+
|
|
80
|
+
const root = dataRoot(environmentFrom(process.env));
|
|
81
|
+
// The location may be a subdirectory of the project; index the project root
|
|
82
|
+
// so a session opened in `docs/` does not create a second corpus.
|
|
83
|
+
const projectRoot = ctx.location.project.canonical;
|
|
84
|
+
const prefix = indexPrefix(projectRoot);
|
|
85
|
+
const brains = brainSpecs(projectRoot, config.index.brain);
|
|
86
|
+
const hiddenRoots = documentRoots(projectRoot, config.index.hidden);
|
|
87
|
+
const runtime: Runtime = {
|
|
88
|
+
phase: "starting",
|
|
89
|
+
binary: null,
|
|
90
|
+
node: null,
|
|
91
|
+
corpus: null,
|
|
92
|
+
search: null,
|
|
93
|
+
memory: null,
|
|
94
|
+
spool: null,
|
|
95
|
+
web: null,
|
|
96
|
+
brain: null,
|
|
97
|
+
brains,
|
|
98
|
+
hidden: null,
|
|
99
|
+
hiddenRoots,
|
|
100
|
+
anomaly: null,
|
|
101
|
+
error: null,
|
|
102
|
+
warnings: [...warnings],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const ledger = new Ledger({
|
|
106
|
+
storage,
|
|
107
|
+
projectRoot,
|
|
108
|
+
bytesPerToken: config.stats.bytesPerToken,
|
|
109
|
+
maxBaselineBytes: config.stats.maxBaselineBytes,
|
|
110
|
+
logger,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const counters = new Counters();
|
|
114
|
+
const observer = { onResponse: (status: number) => {
|
|
115
|
+
if (status === 429) counters.note429();
|
|
116
|
+
} };
|
|
117
|
+
|
|
118
|
+
const host = await detectRuntime({
|
|
119
|
+
libc: config.binary.libc,
|
|
120
|
+
target: config.binary.target,
|
|
121
|
+
env: process.env,
|
|
122
|
+
});
|
|
123
|
+
const info = host.info;
|
|
124
|
+
if (!info && !config.binary.path) {
|
|
125
|
+
runtime.phase = "disabled";
|
|
126
|
+
runtime.error = config.binary.target
|
|
127
|
+
? `binary.target ${config.binary.target} cannot run on ${host.platform}/${host.arch}`
|
|
128
|
+
: `unsupported platform: ${host.platform}/${host.arch}`;
|
|
129
|
+
logger.error(runtime.error);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const memoryNs = memoryNamespace(projectRoot);
|
|
133
|
+
|
|
134
|
+
const snapshot = async (): Promise<StatusSnapshot> => {
|
|
135
|
+
const usage = ledger.snapshot();
|
|
136
|
+
const base: StatusSnapshot = {
|
|
137
|
+
pluginVersion: PLUGIN_VERSION,
|
|
138
|
+
updatedAt: Date.now(),
|
|
139
|
+
node: {
|
|
140
|
+
phase: runtime.phase,
|
|
141
|
+
baseUrl: runtime.node?.baseUrl ?? null,
|
|
142
|
+
serverVersion: runtime.node?.version ?? null,
|
|
143
|
+
spawned: runtime.node?.spawned ?? false,
|
|
144
|
+
error: runtime.error,
|
|
145
|
+
},
|
|
146
|
+
binary: {
|
|
147
|
+
version: runtime.binary?.version ?? null,
|
|
148
|
+
path: runtime.binary?.path ?? null,
|
|
149
|
+
upgraded: runtime.binary?.upgraded ?? false,
|
|
150
|
+
target: info?.target ?? null,
|
|
151
|
+
},
|
|
152
|
+
index: {
|
|
153
|
+
prefix,
|
|
154
|
+
docs: 0,
|
|
155
|
+
bytes: 0,
|
|
156
|
+
indices: 0,
|
|
157
|
+
datasets: [],
|
|
158
|
+
...corpusStatus(runtime.corpus),
|
|
159
|
+
},
|
|
160
|
+
config: {
|
|
161
|
+
indexRoot: projectRoot,
|
|
162
|
+
brain: config.index.brain,
|
|
163
|
+
watch: config.index.watch,
|
|
164
|
+
contextEnabled: config.context.enabled,
|
|
165
|
+
},
|
|
166
|
+
usage: {
|
|
167
|
+
queries: usage.queries,
|
|
168
|
+
hits: usage.hits,
|
|
169
|
+
hitRate: usage.hitRate,
|
|
170
|
+
bytesReturned: usage.bytesReturned,
|
|
171
|
+
bytesRead: usage.bytesRead,
|
|
172
|
+
baselineBytes: usage.baselineBytes,
|
|
173
|
+
savedBytes: usage.savedBytes,
|
|
174
|
+
tokensSaved: usage.tokensSaved,
|
|
175
|
+
topFiles: usage.topFiles,
|
|
176
|
+
},
|
|
177
|
+
memory: { namespace: memoryNs, count: 0 },
|
|
178
|
+
brains: brainStatus(runtime.brain, runtime.brains),
|
|
179
|
+
counters: counters.snapshot(),
|
|
180
|
+
recent: await recentMessages(logger),
|
|
181
|
+
warnings: runtime.warnings,
|
|
182
|
+
errors: runtime.error ? [runtime.error] : [],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (!runtime.node) return base;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const client = new XerjClient(runtime.node.baseUrl, undefined, observer);
|
|
189
|
+
const [indices, summary] = await Promise.all([
|
|
190
|
+
client.indices(),
|
|
191
|
+
client.memorySummary(memoryNs).catch(() => null),
|
|
192
|
+
]);
|
|
193
|
+
const datasets = datasetRows(
|
|
194
|
+
indices,
|
|
195
|
+
prefix,
|
|
196
|
+
webPrefix(projectRoot),
|
|
197
|
+
externalPrefix(projectRoot),
|
|
198
|
+
memoryNs,
|
|
199
|
+
runtime.brains,
|
|
200
|
+
);
|
|
201
|
+
const corpus = datasets.filter((dataset) => dataset.name !== "memory");
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
index: {
|
|
206
|
+
prefix,
|
|
207
|
+
docs: corpus.reduce((total, dataset) => total + dataset.docs, 0),
|
|
208
|
+
bytes: corpus.reduce((total, dataset) => total + dataset.bytes, 0),
|
|
209
|
+
indices: corpus.filter((dataset) => dataset.docs > 0).length,
|
|
210
|
+
datasets,
|
|
211
|
+
...corpusStatus(runtime.corpus),
|
|
212
|
+
},
|
|
213
|
+
memory: {
|
|
214
|
+
namespace: memoryNs,
|
|
215
|
+
count:
|
|
216
|
+
summary?.count ??
|
|
217
|
+
datasets.find((dataset) => dataset.name === "memory")?.docs ??
|
|
218
|
+
0,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return { ...base, errors: [...base.errors, String(error)] };
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const registration = await ctx.rpc.register(XerjRpc, {
|
|
227
|
+
status: async () => {
|
|
228
|
+
counters.noteRpc();
|
|
229
|
+
return snapshot();
|
|
230
|
+
},
|
|
231
|
+
reindex: async () => {
|
|
232
|
+
counters.noteRpc();
|
|
233
|
+
await Promise.all([
|
|
234
|
+
runtime.corpus?.reindex(),
|
|
235
|
+
runtime.web?.reindex(),
|
|
236
|
+
runtime.hidden?.reindex(),
|
|
237
|
+
]);
|
|
238
|
+
void runtime.brain?.runAll();
|
|
239
|
+
return {
|
|
240
|
+
queued:
|
|
241
|
+
runtime.corpus !== null ||
|
|
242
|
+
runtime.web !== null ||
|
|
243
|
+
runtime.hidden !== null ||
|
|
244
|
+
runtime.brain !== null,
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
remember: async (input) => {
|
|
248
|
+
counters.noteRpc();
|
|
249
|
+
if (!runtime.memory) throw new Error("XERJ memory is not available yet");
|
|
250
|
+
const args = input as { text: string };
|
|
251
|
+
return runtime.memory.remember({ text: args.text });
|
|
252
|
+
},
|
|
253
|
+
search: async (input) => {
|
|
254
|
+
counters.noteRpc();
|
|
255
|
+
if (!runtime.search) throw new Error("XERJ search is not available yet");
|
|
256
|
+
const args = input as { query: string };
|
|
257
|
+
const result = await runtime.search.search(args.query);
|
|
258
|
+
return { text: result.text, hits: result.hits.length, bytes: result.bytes };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const summarize = async (): Promise<string> => {
|
|
263
|
+
const status = await snapshot();
|
|
264
|
+
if (status.index.docs === 0) {
|
|
265
|
+
return `The local XERJ index for this project is not populated yet (${status.index.state}).`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return [
|
|
269
|
+
`Local XERJ index for this project: ${status.index.docs} records (${status.index.state}).`,
|
|
270
|
+
"Use xerj_search for code and documentation questions, and xerj_def for a symbol.",
|
|
271
|
+
"Prefer a cited passage over reading whole files.",
|
|
272
|
+
].join(" ");
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const tools = buildTools({
|
|
276
|
+
config,
|
|
277
|
+
ledger,
|
|
278
|
+
getSearch: () => runtime.search,
|
|
279
|
+
getMemory: () => runtime.memory,
|
|
280
|
+
getSpool: () => runtime.spool,
|
|
281
|
+
getAnomaly: () => runtime.anomaly,
|
|
282
|
+
snapshot,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await ctx.tool.transform((editor) => {
|
|
286
|
+
editor.namespace({
|
|
287
|
+
name: "xerj",
|
|
288
|
+
description: "Local XERJ search over this project. Prefer it to reading whole files.",
|
|
289
|
+
});
|
|
290
|
+
for (const tool of tools) editor.add(tool as never);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
294
|
+
if (event.status !== "completed") return;
|
|
295
|
+
|
|
296
|
+
// Any tool that fetched a URL (webfetch, MCP extract, search) can spool
|
|
297
|
+
// its text. A short result is an error or a list, not a document. The
|
|
298
|
+
// spool is fire-and-forget, like the other ingests: the write is local,
|
|
299
|
+
// and a failure must not delay or fail the tool result.
|
|
300
|
+
const urlInput = event.input as { url?: unknown } | null;
|
|
301
|
+
const url = typeof urlInput?.url === "string" ? urlInput.url : null;
|
|
302
|
+
const content = typeof event.result.content === "string" ? event.result.content : null;
|
|
303
|
+
if (url !== null && content !== null && content.length >= 200 && runtime.spool) {
|
|
304
|
+
void runtime.spool
|
|
305
|
+
.ingestUrl(url, content)
|
|
306
|
+
.then((stored) => {
|
|
307
|
+
if (stored) runtime.search?.invalidate();
|
|
308
|
+
})
|
|
309
|
+
.catch((error: unknown) => {
|
|
310
|
+
logger.debug("could not spool a fetched document", String(error));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Bash reads are invisible per file, so parse the command for literal
|
|
315
|
+
// file references and index only existing files outside the project.
|
|
316
|
+
if (event.tool === "bash" || event.tool === "shell") {
|
|
317
|
+
const bashInput = event.input as {
|
|
318
|
+
command?: unknown;
|
|
319
|
+
cwd?: unknown;
|
|
320
|
+
workdir?: unknown;
|
|
321
|
+
directory?: unknown;
|
|
322
|
+
} | null;
|
|
323
|
+
const command = typeof bashInput?.command === "string" ? bashInput.command : null;
|
|
324
|
+
const spool = runtime.spool;
|
|
325
|
+
if (command === null || !spool) return;
|
|
326
|
+
|
|
327
|
+
const read = await shellReadPaths(command, toolCwd(bashInput, projectRoot));
|
|
328
|
+
const paths = read.filter((path) => isOutside(projectRoot, path));
|
|
329
|
+
if (paths.length === 0) return;
|
|
330
|
+
|
|
331
|
+
void Promise.all(
|
|
332
|
+
paths.map((path) =>
|
|
333
|
+
spool.ingestExternalFile(path, { skipUnchanged: true }).catch(() => null),
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
.then((stored) => {
|
|
337
|
+
if (stored.some((entry) => entry !== null)) runtime.search?.invalidate();
|
|
338
|
+
})
|
|
339
|
+
.catch((error: unknown) => {
|
|
340
|
+
logger.debug("could not index a shell-read file", String(error));
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (event.tool !== "read") return;
|
|
346
|
+
|
|
347
|
+
// OpenCode's built-in read uses `filePath`; other tool packs use `path`.
|
|
348
|
+
const input = event.input as { filePath?: unknown; path?: unknown } | null;
|
|
349
|
+
const filePath =
|
|
350
|
+
typeof input?.filePath === "string"
|
|
351
|
+
? input.filePath
|
|
352
|
+
: typeof input?.path === "string"
|
|
353
|
+
? input.path
|
|
354
|
+
: null;
|
|
355
|
+
if (filePath === null) return;
|
|
356
|
+
await ledger.recordRead(event.sessionID, filePath, contentBytes(event.result.content));
|
|
357
|
+
|
|
358
|
+
// A read outside the project is the only signal that the file matters.
|
|
359
|
+
// Index it in the background so the read itself is not delayed.
|
|
360
|
+
if (runtime.spool && isOutside(projectRoot, filePath)) {
|
|
361
|
+
void runtime.spool
|
|
362
|
+
.ingestExternalFile(filePath, { skipUnchanged: true })
|
|
363
|
+
.then((stored) => {
|
|
364
|
+
if (stored) runtime.search?.invalidate();
|
|
365
|
+
})
|
|
366
|
+
.catch((error: unknown) => {
|
|
367
|
+
logger.debug("could not index an external file", String(error));
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await registerSessionHooks(ctx, {
|
|
373
|
+
config,
|
|
374
|
+
logger,
|
|
375
|
+
getSearch: () => runtime.search,
|
|
376
|
+
getMemory: () => runtime.memory,
|
|
377
|
+
summarize,
|
|
378
|
+
onInject: () => counters.noteInjection(),
|
|
379
|
+
recordQuery: (sessionID, paths, bytes, hits) =>
|
|
380
|
+
ledger.recordQuery(sessionID, paths, bytes, hits),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const shutdown = new AbortController();
|
|
384
|
+
|
|
385
|
+
const bootstrap = async (): Promise<void> => {
|
|
386
|
+
try {
|
|
387
|
+
if (!info && !config.binary.path) return;
|
|
388
|
+
|
|
389
|
+
// Project state is created before the first tool call so a read-only
|
|
390
|
+
// workspace surfaces here, not mid-session.
|
|
391
|
+
await ensureProjectState(projectRoot);
|
|
392
|
+
|
|
393
|
+
// An explicit path wins over download and detection: the user already
|
|
394
|
+
// built or installed a binary, possibly for a platform we do not ship.
|
|
395
|
+
const binary = config.binary.path
|
|
396
|
+
? await externalBinary(config.binary.path, logger)
|
|
397
|
+
: info
|
|
398
|
+
? await ensureBinary({
|
|
399
|
+
root,
|
|
400
|
+
requested: config.binary.version,
|
|
401
|
+
platform: process.platform,
|
|
402
|
+
info,
|
|
403
|
+
logger,
|
|
404
|
+
})
|
|
405
|
+
: null;
|
|
406
|
+
if (!binary) return;
|
|
407
|
+
runtime.binary = binary;
|
|
408
|
+
|
|
409
|
+
runtime.node = await ensureNode({
|
|
410
|
+
binaryPath: binary.path,
|
|
411
|
+
binaryVersion: binary.version,
|
|
412
|
+
root,
|
|
413
|
+
dataDir: config.node.dataDir,
|
|
414
|
+
preferredPort: config.node.port,
|
|
415
|
+
persistedPort: await readPersistedPort(root),
|
|
416
|
+
explicitUrl: config.node.url,
|
|
417
|
+
onPort: (port) => persistPort(root, port),
|
|
418
|
+
logger,
|
|
419
|
+
});
|
|
420
|
+
if (runtime.node.spawned) counters.noteSpawn();
|
|
421
|
+
|
|
422
|
+
const client = new XerjClient(runtime.node.baseUrl, undefined, observer);
|
|
423
|
+
runtime.search = new SearchService(
|
|
424
|
+
client,
|
|
425
|
+
[
|
|
426
|
+
prefix,
|
|
427
|
+
webPrefix(projectRoot),
|
|
428
|
+
externalPrefix(projectRoot),
|
|
429
|
+
...runtime.brains.map((brain) => brain.prefix),
|
|
430
|
+
],
|
|
431
|
+
logger,
|
|
432
|
+
projectRoot,
|
|
433
|
+
new BrainGraph({ client, brains: runtime.brains, logger }),
|
|
434
|
+
runtime.brains,
|
|
435
|
+
runtime.hiddenRoots.length > 0 ? `${prefix}-hidden-docs` : null,
|
|
436
|
+
);
|
|
437
|
+
runtime.memory = new MemoryStore(client, memoryNamespace(projectRoot), logger);
|
|
438
|
+
runtime.anomaly = new AnomalyService(client, logger);
|
|
439
|
+
|
|
440
|
+
if (config.memory.enabled) {
|
|
441
|
+
startCompactionCapture({
|
|
442
|
+
events: ctx.event,
|
|
443
|
+
memory: runtime.memory,
|
|
444
|
+
signal: shutdown.signal,
|
|
445
|
+
logger,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (config.remote.enabled) {
|
|
450
|
+
const spool = new RemoteSpool({
|
|
451
|
+
root,
|
|
452
|
+
projectRoot,
|
|
453
|
+
prefix: webPrefix(projectRoot),
|
|
454
|
+
externalPrefix: externalPrefix(projectRoot),
|
|
455
|
+
maxBytes: config.remote.maxBytes,
|
|
456
|
+
client,
|
|
457
|
+
logger,
|
|
458
|
+
});
|
|
459
|
+
await spool.init();
|
|
460
|
+
runtime.spool = spool;
|
|
461
|
+
|
|
462
|
+
runtime.web = new CorpusService({
|
|
463
|
+
binaryPath: runtime.binary.path,
|
|
464
|
+
nodeUrl: runtime.node.baseUrl,
|
|
465
|
+
projectRoot: spool.directory,
|
|
466
|
+
prefix: webPrefix(projectRoot),
|
|
467
|
+
dataRoot: root,
|
|
468
|
+
config,
|
|
469
|
+
storage,
|
|
470
|
+
logger,
|
|
471
|
+
onRun: () => counters.noteIndexRun(),
|
|
472
|
+
onChange: () => {
|
|
473
|
+
runtime.search?.invalidate();
|
|
474
|
+
void registration.events
|
|
475
|
+
.emit("updated", { updatedAt: Date.now() })
|
|
476
|
+
.catch(() => {});
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
await runtime.web.start();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
runtime.phase = "ready";
|
|
483
|
+
logger.info("xerj is ready", {
|
|
484
|
+
url: runtime.node.baseUrl,
|
|
485
|
+
version: runtime.node.version,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
runtime.corpus = new CorpusService({
|
|
489
|
+
binaryPath: runtime.binary.path,
|
|
490
|
+
nodeUrl: runtime.node.baseUrl,
|
|
491
|
+
projectRoot,
|
|
492
|
+
prefix,
|
|
493
|
+
dataRoot: root,
|
|
494
|
+
config,
|
|
495
|
+
storage,
|
|
496
|
+
logger,
|
|
497
|
+
onRun: () => counters.noteIndexRun(),
|
|
498
|
+
onChange: () => {
|
|
499
|
+
runtime.search?.invalidate();
|
|
500
|
+
void registration.events
|
|
501
|
+
.emit("updated", { updatedAt: Date.now() })
|
|
502
|
+
.catch(() => {});
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
await runtime.corpus.start();
|
|
506
|
+
|
|
507
|
+
if (runtime.hiddenRoots.length > 0) {
|
|
508
|
+
runtime.hidden = new HiddenIndex({
|
|
509
|
+
client,
|
|
510
|
+
projectRoot,
|
|
511
|
+
prefix,
|
|
512
|
+
roots: runtime.hiddenRoots,
|
|
513
|
+
maxBytes: config.index.hiddenMaxBytes,
|
|
514
|
+
logger,
|
|
515
|
+
onRun: () => counters.noteIndexRun(),
|
|
516
|
+
onChange: () => {
|
|
517
|
+
runtime.search?.invalidate();
|
|
518
|
+
void registration.events
|
|
519
|
+
.emit("updated", { updatedAt: Date.now() })
|
|
520
|
+
.catch(() => {});
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
if (config.index.auto) await runtime.hidden.start();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (runtime.brains.length > 0) {
|
|
528
|
+
runtime.brain = new BrainIndexer({
|
|
529
|
+
binaryPath: binary.path,
|
|
530
|
+
nodeUrl: runtime.node.baseUrl,
|
|
531
|
+
dataRoot: root,
|
|
532
|
+
config,
|
|
533
|
+
specs: runtime.brains,
|
|
534
|
+
logger,
|
|
535
|
+
observer,
|
|
536
|
+
onRun: () => counters.noteIndexRun(),
|
|
537
|
+
onChange: () => {
|
|
538
|
+
runtime.search?.invalidate();
|
|
539
|
+
void registration.events
|
|
540
|
+
.emit("updated", { updatedAt: Date.now() })
|
|
541
|
+
.catch(() => {});
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// One-shot: graph indexing cannot run in watch mode. `index.auto`
|
|
546
|
+
// gates startup runs only; an explicit reindex still works.
|
|
547
|
+
if (config.index.auto) void runtime.brain.runAll();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
await registration.events.emit("updated", { updatedAt: Date.now() });
|
|
551
|
+
} catch (error) {
|
|
552
|
+
runtime.phase = "error";
|
|
553
|
+
runtime.error = error instanceof Error ? error.message : String(error);
|
|
554
|
+
logger.error("xerj failed to start", runtime.error);
|
|
555
|
+
} finally {
|
|
556
|
+
if (!shutdown.signal.aborted) {
|
|
557
|
+
await registration.events.emit("updated", { updatedAt: Date.now() }).catch(() => {});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
void bootstrap();
|
|
564
|
+
|
|
565
|
+
return () => {
|
|
566
|
+
shutdown.abort();
|
|
567
|
+
const corpus = runtime.corpus;
|
|
568
|
+
const web = runtime.web;
|
|
569
|
+
const hidden = runtime.hidden;
|
|
570
|
+
runtime.corpus = null;
|
|
571
|
+
runtime.web = null;
|
|
572
|
+
runtime.hidden = null;
|
|
573
|
+
|
|
574
|
+
void Promise.all([corpus?.stop(), web?.stop(), hidden?.stop()])
|
|
575
|
+
.then(() => ledger.flush())
|
|
576
|
+
.then(() => logger.flush())
|
|
577
|
+
.then(() => registration.dispose())
|
|
578
|
+
.catch(() => {});
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
/** `.xerj/` holds the marker and caches; it must never be committed. */
|
|
584
|
+
async function ensureProjectState(projectRoot: string): Promise<void> {
|
|
585
|
+
const directory = projectStateDir(projectRoot);
|
|
586
|
+
await mkdir(directory, { recursive: true });
|
|
587
|
+
await writeFile(join(directory, ".gitignore"), "*\n", { flag: "wx" }).catch(() => {});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function corpusStatus(corpus: CorpusService | null): Pick<
|
|
591
|
+
StatusSnapshot["index"],
|
|
592
|
+
"state" | "lastRunAt" | "lastGeneration" | "lastRecords" | "restarts" | "lastError"
|
|
593
|
+
> {
|
|
594
|
+
const snapshot: CorpusSnapshot | null = corpus?.snapshot() ?? null;
|
|
595
|
+
return {
|
|
596
|
+
state: snapshot?.state ?? "disabled",
|
|
597
|
+
lastRunAt: snapshot?.lastRun?.at ?? null,
|
|
598
|
+
lastGeneration: snapshot?.lastRun?.generation ?? null,
|
|
599
|
+
lastRecords: snapshot?.lastRun?.records ?? null,
|
|
600
|
+
restarts: snapshot?.restarts ?? 0,
|
|
601
|
+
lastError: snapshot?.lastError ?? null,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Groups the node's indices into the corpora this plugin maintains. */
|
|
606
|
+
function datasetRows(
|
|
607
|
+
indices: ReadonlyArray<IndexSummary>,
|
|
608
|
+
project: string,
|
|
609
|
+
web: string,
|
|
610
|
+
external: string,
|
|
611
|
+
memoryNs: string,
|
|
612
|
+
brains: ReadonlyArray<BrainSpec>,
|
|
613
|
+
): DatasetStatus[] {
|
|
614
|
+
const groups = [
|
|
615
|
+
{
|
|
616
|
+
name: "project",
|
|
617
|
+
matches: (index: string) =>
|
|
618
|
+
index.startsWith(`${project}-`) &&
|
|
619
|
+
!index.startsWith(`${project}-hidden-`),
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "hidden",
|
|
623
|
+
matches: (index: string) => index.startsWith(`${project}-hidden-`),
|
|
624
|
+
},
|
|
625
|
+
{ name: "web", matches: (index: string) => index.startsWith(`${web}-`) },
|
|
626
|
+
{
|
|
627
|
+
name: "external",
|
|
628
|
+
matches: (index: string) => index.startsWith(`${external}-`),
|
|
629
|
+
},
|
|
630
|
+
...brains.map((brain) => ({
|
|
631
|
+
name: `brain:${brain.name}`,
|
|
632
|
+
matches: (index: string) => index.startsWith(`${brain.prefix}-`),
|
|
633
|
+
})),
|
|
634
|
+
{
|
|
635
|
+
name: "memory",
|
|
636
|
+
matches: (index: string) => index === `.xerj-memory-${memoryNs}`,
|
|
637
|
+
},
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
return groups
|
|
641
|
+
.map((group) => {
|
|
642
|
+
const matched = indices.filter((entry) => group.matches(entry.index));
|
|
643
|
+
return {
|
|
644
|
+
name: group.name,
|
|
645
|
+
docs: matched.reduce((total, entry) => total + entry.docs, 0),
|
|
646
|
+
bytes: matched.reduce((total, entry) => total + entry.bytes, 0),
|
|
647
|
+
};
|
|
648
|
+
})
|
|
649
|
+
.filter((dataset) => dataset.docs > 0 || dataset.bytes > 0);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function toolCwd(input: unknown, fallback: string): string {
|
|
653
|
+
if (typeof input !== "object" || input === null) return fallback;
|
|
654
|
+
|
|
655
|
+
const record = input as Record<string, unknown>;
|
|
656
|
+
for (const key of ["cwd", "workdir", "directory"]) {
|
|
657
|
+
const value = record[key];
|
|
658
|
+
if (typeof value === "string" && value.length > 0) {
|
|
659
|
+
return isAbsolute(value) ? resolve(value) : resolve(fallback, value);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return fallback;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isOutside(root: string, filePath: string): boolean {
|
|
666
|
+
const base = resolve(root);
|
|
667
|
+
const absolute = resolve(filePath);
|
|
668
|
+
return absolute !== base && !absolute.startsWith(base + sep);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function brainStatus(
|
|
672
|
+
indexer: BrainIndexer | null,
|
|
673
|
+
specs: ReadonlyArray<BrainSpec>,
|
|
674
|
+
): BrainStatus[] {
|
|
675
|
+
const runs: ReadonlyArray<BrainRun> =
|
|
676
|
+
indexer?.snapshot() ??
|
|
677
|
+
specs.map((spec) => ({
|
|
678
|
+
name: spec.name,
|
|
679
|
+
prefix: spec.prefix,
|
|
680
|
+
at: 0,
|
|
681
|
+
docs: 0,
|
|
682
|
+
edges: 0,
|
|
683
|
+
error: null,
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
return runs.map((run) => ({
|
|
687
|
+
name: run.name,
|
|
688
|
+
prefix: run.prefix,
|
|
689
|
+
docs: run.docs,
|
|
690
|
+
edges: run.edges,
|
|
691
|
+
lastRunAt: run.at === 0 ? null : run.at,
|
|
692
|
+
error: run.error,
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function recentMessages(logger: Logger): Promise<string[]> {
|
|
697
|
+
const entries = await logger.entries().catch(() => []);
|
|
698
|
+
return entries
|
|
699
|
+
.filter((entry) => entry.level === "warn" || entry.level === "error")
|
|
700
|
+
.slice(-4)
|
|
701
|
+
.map((entry) => `${entry.level}: ${entry.message}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function contentBytes(content: unknown): number {
|
|
705
|
+
if (typeof content === "string") return Buffer.byteLength(content, "utf8");
|
|
706
|
+
if (!Array.isArray(content)) return 0;
|
|
707
|
+
|
|
708
|
+
return content.reduce((total, entry) => {
|
|
709
|
+
if (typeof entry === "object" && entry !== null && "text" in entry) {
|
|
710
|
+
const text = (entry as { text?: unknown }).text;
|
|
711
|
+
if (typeof text === "string") return total + Buffer.byteLength(text, "utf8");
|
|
712
|
+
}
|
|
713
|
+
return total;
|
|
714
|
+
}, 0);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function asStorage(storage: Plugin.Context["storage"]): KeyValueStorage {
|
|
718
|
+
return {
|
|
719
|
+
get: (key) => storage.get(key),
|
|
720
|
+
// The logger and ledger only ever write JSON values.
|
|
721
|
+
set: (key, value) => storage.set(key, value as never),
|
|
722
|
+
};
|
|
723
|
+
}
|