@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.0

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.
@@ -0,0 +1,384 @@
1
+ // Tool surface. Two retrieval tools plus status; the model gets a citation and
2
+ // a passage, not a file listing.
3
+
4
+ import type { Plugin } from "@opencode/plugin";
5
+ import type { Config } from "../config.ts";
6
+ import type { MemoryStore } from "../memory/store.ts";
7
+ import { formatMemories } from "../memory/store.ts";
8
+ import type { RemoteSpool } from "../remote/spool.ts";
9
+ import { formatAnomalies, type AnomalyService } from "../telemetry/anomaly.ts";
10
+ import type { Ledger } from "../stats/ledger.ts";
11
+ import type { SearchService } from "../search/service.ts";
12
+ import type { StatusSnapshot } from "../rpc.ts";
13
+
14
+ export interface ToolOptions {
15
+ readonly config: Config;
16
+ readonly ledger: Ledger;
17
+ readonly getSearch: () => SearchService | null;
18
+ readonly getMemory: () => MemoryStore | null;
19
+ readonly getSpool: () => RemoteSpool | null;
20
+ readonly getAnomaly: () => AnomalyService | null;
21
+ readonly snapshot: () => Promise<StatusSnapshot>;
22
+ }
23
+
24
+ const SEARCH_INPUT = {
25
+ type: "object",
26
+ properties: {
27
+ query: { type: "string", description: "What to look for, in plain words or exact terms." },
28
+ k: { type: "number", description: "How many passages to return (1-50, default 8)." },
29
+ max_tokens: { type: "number", description: "Approximate output budget in tokens." },
30
+ },
31
+ required: ["query"],
32
+ additionalProperties: false,
33
+ } as const;
34
+
35
+ const DEF_INPUT = {
36
+ type: "object",
37
+ properties: {
38
+ symbol: { type: "string", description: "Function, type, constant, or class name." },
39
+ k: { type: "number", description: "How many declarations to return (1-50, default 5)." },
40
+ },
41
+ required: ["symbol"],
42
+ additionalProperties: false,
43
+ } as const;
44
+
45
+ const STATUS_INPUT = {
46
+ type: "object",
47
+ properties: {},
48
+ additionalProperties: false,
49
+ } as const;
50
+
51
+ const REMEMBER_INPUT = {
52
+ type: "object",
53
+ properties: {
54
+ text: { type: "string", description: "The durable fact, decision, or preference to keep." },
55
+ kind: {
56
+ type: "string",
57
+ enum: ["fact", "decision", "preference", "fix", "note"],
58
+ description: "What kind of memory this is (default fact).",
59
+ },
60
+ topic: { type: "string", description: "Optional short topic label." },
61
+ },
62
+ required: ["text"],
63
+ additionalProperties: false,
64
+ } as const;
65
+
66
+ const RECALL_INPUT = {
67
+ type: "object",
68
+ properties: {
69
+ query: { type: "string", description: "What to remember, in plain words." },
70
+ k: { type: "number", description: "How many memories to return (1-20, default 5)." },
71
+ },
72
+ required: ["query"],
73
+ additionalProperties: false,
74
+ } as const;
75
+
76
+ const ANOMALY_INPUT = {
77
+ type: "object",
78
+ properties: {
79
+ index: {
80
+ type: "string",
81
+ description: "Index or pattern with a date field and a numeric metric.",
82
+ },
83
+ time_field: {
84
+ type: "string",
85
+ description: "Date field to bucket by (default @timestamp).",
86
+ },
87
+ function: {
88
+ type: "string",
89
+ enum: ["count", "mean", "min", "max", "sum"],
90
+ description: "Aggregation per bucket (default count).",
91
+ },
92
+ field: {
93
+ type: "string",
94
+ description: "Numeric field; required unless function is count.",
95
+ },
96
+ bucket_span: {
97
+ type: "string",
98
+ description: "Bucket size such as 1m or 1h (default 1h).",
99
+ },
100
+ threshold: {
101
+ type: "number",
102
+ description: "Z-score gate for a flag (default 3).",
103
+ },
104
+ },
105
+ required: ["index"],
106
+ additionalProperties: false,
107
+ } as const;
108
+
109
+ const INDEX_INPUT = {
110
+ type: "object",
111
+ properties: {
112
+ url: { type: "string", description: "Fetch this URL and index its text." },
113
+ path: { type: "string", description: "Index this file from outside the project." },
114
+ },
115
+ additionalProperties: false,
116
+ } as const;
117
+
118
+ const FORGET_INPUT = {
119
+ type: "object",
120
+ properties: {
121
+ id: { type: "string", description: "Memory id returned by xerj_remember or xerj_recall." },
122
+ },
123
+ required: ["id"],
124
+ additionalProperties: false,
125
+ } as const;
126
+
127
+ export function buildTools(options: ToolOptions): ReadonlyArray<{
128
+ name: string;
129
+ description: string;
130
+ input: unknown;
131
+ options: { namespace: string };
132
+ execute: (input: unknown, context: unknown) => Promise<unknown>;
133
+ }> {
134
+ return [
135
+ {
136
+ name: "search",
137
+ description:
138
+ "Search this project's local XERJ index. Returns cited passages with path:line instead of whole files. Use it before reading files or grepping when you need to find where something is implemented or described.",
139
+ input: SEARCH_INPUT,
140
+ options: { namespace: "xerj" },
141
+ execute: async (input, context) => {
142
+ const args = input as { query: string; k?: number; max_tokens?: number };
143
+ const tool = context as { sessionID: string; signal: AbortSignal };
144
+ const search = options.getSearch();
145
+ if (!search) return unavailable();
146
+
147
+ const result = await search.search(args.query, {
148
+ k: args.k,
149
+ maxTokens: args.max_tokens ?? options.config.context.maxTokens,
150
+ signal: tool.signal,
151
+ });
152
+ await record(options, tool.sessionID, result);
153
+
154
+ return result.hits.length === 0
155
+ ? { content: "No passages matched.", metadata: { xerj: { hits: 0, bytes: 0 } } }
156
+ : {
157
+ content: result.text,
158
+ metadata: {
159
+ xerj: {
160
+ hits: result.hits.length,
161
+ bytes: result.bytes,
162
+ strategy: result.strategy,
163
+ narrowingBytes: result.narrowingBytes,
164
+ },
165
+ },
166
+ };
167
+ },
168
+ },
169
+ {
170
+ name: "def",
171
+ description:
172
+ "Find the declaration of a symbol in this project's index. Returns file:line and the declaration text, which is cheaper than reading the file around a grep hit.",
173
+ input: DEF_INPUT,
174
+ options: { namespace: "xerj" },
175
+ execute: async (input, context) => {
176
+ const args = input as { symbol: string; k?: number };
177
+ const tool = context as { sessionID: string; signal: AbortSignal };
178
+ const search = options.getSearch();
179
+ if (!search) return unavailable();
180
+
181
+ const result = await search.definition(args.symbol, {
182
+ k: args.k,
183
+ maxTokens: options.config.context.maxTokens,
184
+ signal: tool.signal,
185
+ });
186
+ await record(options, tool.sessionID, result);
187
+
188
+ return result.hits.length === 0
189
+ ? { content: "No declaration matched.", metadata: { xerj: { hits: 0, bytes: 0 } } }
190
+ : {
191
+ content: result.text,
192
+ metadata: { xerj: { hits: result.hits.length, bytes: result.bytes } },
193
+ };
194
+ },
195
+ },
196
+ {
197
+ name: "status",
198
+ description:
199
+ "Report the XERJ search node, project index, and retrieval counters. Use it to check whether local search is available before falling back to reading files.",
200
+ input: STATUS_INPUT,
201
+ options: { namespace: "xerj" },
202
+ execute: async () => ({
203
+ content: JSON.stringify(await options.snapshot(), null, 2),
204
+ metadata: { xerj: { kind: "status" } },
205
+ }),
206
+ },
207
+ {
208
+ name: "remember",
209
+ description:
210
+ "Store a durable project fact, decision, or preference in XERJ memory so a later session recalls it. Use it when you learn something that is not obvious from the code.",
211
+ input: REMEMBER_INPUT,
212
+ options: { namespace: "xerj" },
213
+ execute: async (input, context) => {
214
+ const args = input as { text: string; kind?: string; topic?: string };
215
+ const tool = context as { sessionID: string };
216
+ const memory = options.getMemory();
217
+ if (!memory) return unavailable();
218
+
219
+ const saved = await memory.remember({ ...args, sessionID: tool.sessionID });
220
+ return {
221
+ content: saved.created ? `Remembered as ${saved.id}.` : `Already remembered as ${saved.id}.`,
222
+ metadata: { xerj: { memory: saved.id, created: saved.created } },
223
+ };
224
+ },
225
+ },
226
+ {
227
+ name: "recall",
228
+ description:
229
+ "Recall project memories by meaning. Use it before asking the user something they may have already decided, or when a task depends on an earlier decision.",
230
+ input: RECALL_INPUT,
231
+ options: { namespace: "xerj" },
232
+ execute: async (input) => {
233
+ const args = input as { query: string; k?: number };
234
+ const memory = options.getMemory();
235
+ if (!memory) return unavailable();
236
+
237
+ const hits = await memory.recall(args.query, clampK(args.k ?? 5));
238
+ const block = formatMemories(hits, options.config.context.memoryTokens * 4);
239
+ return block.length === 0
240
+ ? { content: "No memories matched.", metadata: { xerj: { memories: 0 } } }
241
+ : { content: block, metadata: { xerj: { memories: hits.length } } };
242
+ },
243
+ },
244
+ {
245
+ name: "index",
246
+ description:
247
+ "Index a URL or an external file so later searches can find it. Use it after fetching a page you expect to need again.",
248
+ input: INDEX_INPUT,
249
+ options: { namespace: "xerj" },
250
+ execute: async (input, context) => {
251
+ const args = input as { url?: string; path?: string };
252
+ const tool = context as { signal: AbortSignal };
253
+ const spool = options.getSpool();
254
+ if (!spool) return unavailable();
255
+
256
+ if (args.url) {
257
+ const response = await fetch(args.url, { signal: withTimeout(tool.signal, 10_000) });
258
+ if (!response.ok) {
259
+ return { content: `Fetch failed: HTTP ${response.status}.`, metadata: { xerj: { indexed: false } } };
260
+ }
261
+
262
+ const text = await response.text();
263
+ const stored = await spool.ingestUrl(args.url, text);
264
+ options.getSearch()?.invalidate();
265
+ return stored
266
+ ? {
267
+ content: `Indexed ${stored.bytes} bytes from ${args.url}.`,
268
+ metadata: { xerj: { indexed: true, bytes: stored.bytes } },
269
+ }
270
+ : { content: "Nothing indexable at that URL.", metadata: { xerj: { indexed: false } } };
271
+ }
272
+
273
+ if (args.path) {
274
+ const stored = await spool.ingestExternalFile(args.path);
275
+ if (stored) options.getSearch()?.invalidate();
276
+ return stored
277
+ ? {
278
+ content: `Indexed ${stored.path} (${stored.bytes} bytes).`,
279
+ metadata: { xerj: { indexed: true, bytes: stored.bytes } },
280
+ }
281
+ : { content: `Could not index ${args.path}: unsupported, binary, or unreadable.`, metadata: { xerj: { indexed: false } } };
282
+ }
283
+
284
+ return { content: "Pass either a url or a path.", metadata: { xerj: { indexed: false } } };
285
+ },
286
+ },
287
+ {
288
+ name: "anomaly",
289
+ description:
290
+ "Find spikes in a numeric metric series indexed in XERJ. On-demand batch scoring, not continuous monitoring; returns only the flagged buckets.",
291
+ input: ANOMALY_INPUT,
292
+ options: { namespace: "xerj" },
293
+ execute: async (input) => {
294
+ const args = input as {
295
+ index: string;
296
+ time_field?: string;
297
+ function?: string;
298
+ field?: string;
299
+ bucket_span?: string;
300
+ threshold?: number;
301
+ };
302
+ const anomaly = options.getAnomaly();
303
+ if (!anomaly) return unavailable();
304
+
305
+ const result = await anomaly.score({
306
+ index: args.index,
307
+ timeField: args.time_field,
308
+ function: args.function,
309
+ field: args.field,
310
+ bucketSpan: args.bucket_span,
311
+ threshold: args.threshold,
312
+ });
313
+
314
+ return {
315
+ content: formatAnomalies(result),
316
+ metadata: {
317
+ xerj: {
318
+ anomalyCount: result.anomalyCount,
319
+ detector: result.detector,
320
+ buckets: result.buckets,
321
+ },
322
+ },
323
+ };
324
+ },
325
+ },
326
+ {
327
+ name: "forget",
328
+ description: "Retract a memory that is no longer true. Takes the id returned by xerj_remember or xerj_recall.",
329
+ input: FORGET_INPUT,
330
+ options: { namespace: "xerj" },
331
+ execute: async (input) => {
332
+ const args = input as { id: string };
333
+ const memory = options.getMemory();
334
+ if (!memory) return unavailable();
335
+
336
+ const forgotten = await memory.forget(args.id);
337
+ return {
338
+ content: forgotten ? `Forgot ${args.id}.` : `No memory with id ${args.id}.`,
339
+ metadata: { xerj: { memory: args.id, forgotten } },
340
+ };
341
+ },
342
+ },
343
+ ];
344
+ }
345
+
346
+ async function record(
347
+ options: ToolOptions,
348
+ sessionID: string,
349
+ result: Awaited<ReturnType<SearchService["search"]>>,
350
+ ): Promise<void> {
351
+ const paths = result.hits.flatMap((hit) => (hit.path ? [hit.path] : []));
352
+ await options.ledger.recordQuery(sessionID, paths, result.bytes, result.hits.length);
353
+ }
354
+
355
+ function withTimeout(signal: AbortSignal, ms: number): AbortSignal {
356
+ const controller = new AbortController();
357
+ const timer = setTimeout(() => controller.abort(), ms);
358
+ const forward = (): void => controller.abort();
359
+
360
+ if (signal.aborted) controller.abort();
361
+ else signal.addEventListener("abort", forward, { once: true });
362
+
363
+ controller.signal.addEventListener(
364
+ "abort",
365
+ () => {
366
+ clearTimeout(timer);
367
+ signal.removeEventListener("abort", forward);
368
+ },
369
+ { once: true },
370
+ );
371
+
372
+ return controller.signal;
373
+ }
374
+
375
+ function clampK(value: number): number {
376
+ return Math.min(20, Math.max(1, Math.round(value)));
377
+ }
378
+
379
+ function unavailable(): { content: string; metadata: Record<string, unknown> } {
380
+ return {
381
+ content: "XERJ is not ready yet; the node or index is still starting.",
382
+ metadata: { xerj: { hits: 0, bytes: 0, ready: false } },
383
+ };
384
+ }