@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.
@@ -0,0 +1,55 @@
1
+ // Per-process counters for events the plugin can observe directly. They reset
2
+ // when the server restarts, so the UI labels them as this server's lifetime.
3
+ // Index-run totals live in the corpus snapshots (persisted), not here.
4
+
5
+ export interface CounterSnapshot {
6
+ readonly rpcCalls: number;
7
+ readonly injectedTurns: number;
8
+ readonly nodeSpawns: number;
9
+ readonly http429: number;
10
+ readonly indexRuns: number;
11
+ }
12
+
13
+ /** Counts events without changing them; callers never branch on a counter. */
14
+ export class Counters {
15
+ private rpc = 0;
16
+ private injected = 0;
17
+ private spawns = 0;
18
+ private limited = 0;
19
+ private indexed = 0;
20
+
21
+ /** A TUI poll or any other RPC call reached the server part. */
22
+ noteRpc(): void {
23
+ this.rpc += 1;
24
+ }
25
+
26
+ /** A retrieved block was added to a prompt. */
27
+ noteInjection(): void {
28
+ this.injected += 1;
29
+ }
30
+
31
+ /** A node process was started rather than attached to. */
32
+ noteSpawn(): void {
33
+ this.spawns += 1;
34
+ }
35
+
36
+ /** The node answered 429; autoindex waits these out on its own. */
37
+ note429(): void {
38
+ this.limited += 1;
39
+ }
40
+
41
+ /** One index pass finished, in any corpus. */
42
+ noteIndexRun(): void {
43
+ this.indexed += 1;
44
+ }
45
+
46
+ snapshot(): CounterSnapshot {
47
+ return {
48
+ rpcCalls: this.rpc,
49
+ injectedTurns: this.injected,
50
+ nodeSpawns: this.spawns,
51
+ http429: this.limited,
52
+ indexRuns: this.indexed,
53
+ };
54
+ }
55
+ }
@@ -0,0 +1,344 @@
1
+ // Retrieval accounting. Everything here is measured: bytes actually handed to
2
+ // the model, the size of each cited file, and whether the model read that file
3
+ // anyway. The last point is what keeps "tokens saved" from being fiction: a
4
+ // file the model read in full contributes nothing, even if a passage from it
5
+ // was cited first.
6
+
7
+ import { stat } from "node:fs/promises";
8
+ import { isAbsolute, join, resolve, sep } from "node:path";
9
+ import type { KeyValueStorage, Logger } from "../log.ts";
10
+
11
+ export interface CitedFile {
12
+ readonly path: string;
13
+ readonly citations: number;
14
+ readonly bytes: number;
15
+ }
16
+
17
+ export interface LedgerSnapshot {
18
+ readonly queries: number;
19
+ readonly hits: number;
20
+ readonly hitRate: number;
21
+ readonly bytesReturned: number;
22
+ readonly baselineBytes: number;
23
+ readonly filesCited: number;
24
+ readonly filesRead: number;
25
+ readonly bytesRead: number;
26
+ readonly savedBytes: number;
27
+ readonly tokensSaved: number;
28
+ readonly topFiles: ReadonlyArray<CitedFile>;
29
+ }
30
+
31
+ export interface LedgerOptions {
32
+ readonly storage: KeyValueStorage;
33
+ readonly projectRoot: string;
34
+ readonly bytesPerToken: number;
35
+ readonly maxBaselineBytes: number;
36
+ readonly logger: Logger;
37
+ }
38
+
39
+ interface SessionState {
40
+ readonly cited: Map<string, number>;
41
+ readonly citations: Map<string, number>;
42
+ /** Bytes the model already read from a cited file. */
43
+ readonly read: Map<string, number>;
44
+ queries: number;
45
+ hits: number;
46
+ returned: number;
47
+ }
48
+
49
+ interface LifetimeState {
50
+ queries: number;
51
+ hits: number;
52
+ returned: number;
53
+ bytesRead: number;
54
+ readonly cited: Map<string, number>;
55
+ readonly citations: Map<string, number>;
56
+ readonly read: Map<string, number>;
57
+ }
58
+
59
+ const STORAGE_KEY = "stats/lifetime";
60
+ const MAX_TRACKED_FILES = 5000;
61
+ const FLUSH_DELAY_MS = 2_000;
62
+
63
+ export class Ledger {
64
+ private readonly sessions = new Map<string, SessionState>();
65
+ private readonly sizes = new Map<string, number>();
66
+ private lifetime: LifetimeState = {
67
+ queries: 0,
68
+ hits: 0,
69
+ returned: 0,
70
+ bytesRead: 0,
71
+ cited: new Map(),
72
+ citations: new Map(),
73
+ read: new Map(),
74
+ };
75
+ private loadPromise: Promise<void> | null = null;
76
+ private timer: ReturnType<typeof setTimeout> | null = null;
77
+
78
+ constructor(private readonly options: LedgerOptions) {
79
+ // Warm the persisted counters so the first status after a restart is real.
80
+ void this.load();
81
+ }
82
+
83
+ async recordQuery(
84
+ sessionID: string,
85
+ paths: ReadonlyArray<string>,
86
+ bytes: number,
87
+ hits: number,
88
+ ): Promise<void> {
89
+ await this.load();
90
+ const session = this.session(sessionID);
91
+
92
+ session.queries += 1;
93
+ session.returned += bytes;
94
+ this.lifetime.queries += 1;
95
+ this.lifetime.returned += bytes;
96
+
97
+ if (hits > 0) {
98
+ session.hits += 1;
99
+ this.lifetime.hits += 1;
100
+ }
101
+
102
+ for (const path of new Set(paths)) {
103
+ session.citations.set(path, (session.citations.get(path) ?? 0) + 1);
104
+ this.lifetime.citations.set(
105
+ path,
106
+ (this.lifetime.citations.get(path) ?? 0) + 1,
107
+ );
108
+
109
+ if (!session.cited.has(path)) {
110
+ const size = await this.fileSize(path);
111
+ if (size === null) continue;
112
+ session.cited.set(path, size);
113
+ this.lifetime.cited.set(path, size);
114
+ }
115
+ }
116
+
117
+ this.trim();
118
+ this.scheduleFlush();
119
+ }
120
+
121
+ async recordRead(sessionID: string, filePath: string, bytes: number): Promise<void> {
122
+ await this.load();
123
+ const path = this.relative(filePath);
124
+ if (path === null) return;
125
+
126
+ // A read with no measured size counts as the whole file.
127
+ const amount = Number.isFinite(bytes) && bytes > 0 ? bytes : Number.POSITIVE_INFINITY;
128
+ const session = this.session(sessionID);
129
+ bumpRead(session.read, path, amount);
130
+ bumpRead(this.lifetime.read, path, amount);
131
+
132
+ if (Number.isFinite(amount)) {
133
+ this.lifetime.bytesRead += amount;
134
+ }
135
+
136
+ this.scheduleFlush();
137
+ }
138
+
139
+ snapshot(sessionID?: string): LedgerSnapshot {
140
+ const session = sessionID ? this.sessions.get(sessionID) : undefined;
141
+ const cited = session?.cited ?? this.lifetime.cited;
142
+ const citations = session?.citations ?? this.lifetime.citations;
143
+ const read = session?.read ?? this.lifetime.read;
144
+ const queries = session?.queries ?? this.lifetime.queries;
145
+ const hits = session?.hits ?? this.lifetime.hits;
146
+ const returned = session?.returned ?? this.lifetime.returned;
147
+
148
+ let baseline = 0;
149
+ let filesCited = 0;
150
+ for (const [path, size] of cited) {
151
+ filesCited += 1;
152
+ const alreadyRead = read.get(path) ?? 0;
153
+ baseline += Math.max(0, size - alreadyRead);
154
+ }
155
+
156
+ const saved = Math.max(0, baseline - returned);
157
+ return {
158
+ queries,
159
+ hits,
160
+ hitRate: queries === 0 ? 0 : hits / queries,
161
+ bytesReturned: returned,
162
+ baselineBytes: baseline,
163
+ filesCited,
164
+ filesRead: read.size,
165
+ bytesRead: this.lifetime.bytesRead,
166
+ savedBytes: saved,
167
+ tokensSaved: Math.round(saved / this.options.bytesPerToken),
168
+ topFiles: topCited(citations, cited),
169
+ };
170
+ }
171
+
172
+ async flush(): Promise<void> {
173
+ if (this.timer) {
174
+ clearTimeout(this.timer);
175
+ this.timer = null;
176
+ }
177
+ await this.save();
178
+ }
179
+
180
+ private load(): Promise<void> {
181
+ this.loadPromise ??= this.loadFromStorage();
182
+ return this.loadPromise;
183
+ }
184
+
185
+ private async loadFromStorage(): Promise<void> {
186
+ const stored = await this.options.storage.get(STORAGE_KEY).catch(() => undefined);
187
+ if (typeof stored !== "object" || stored === null) return;
188
+
189
+ const record = stored as Record<string, unknown>;
190
+ this.lifetime = {
191
+ queries: numberOr(record.queries, 0),
192
+ hits: numberOr(record.hits, 0),
193
+ returned: numberOr(record.returned, 0),
194
+ bytesRead: numberOr(record.bytesRead, 0),
195
+ cited: fileMap(record.cited),
196
+ citations: countMap(record.citations),
197
+ read: fileMap(record.read),
198
+ };
199
+ }
200
+
201
+ private session(sessionID: string): SessionState {
202
+ let session = this.sessions.get(sessionID);
203
+ if (!session) {
204
+ session = {
205
+ cited: new Map(),
206
+ citations: new Map(),
207
+ read: new Map(),
208
+ queries: 0,
209
+ hits: 0,
210
+ returned: 0,
211
+ };
212
+ this.sessions.set(sessionID, session);
213
+ }
214
+ return session;
215
+ }
216
+
217
+ private async fileSize(path: string): Promise<number | null> {
218
+ const cached = this.sizes.get(path);
219
+ if (cached !== undefined) return cached;
220
+
221
+ const absolute = this.absolute(path);
222
+ if (absolute === null) return null;
223
+
224
+ const info = await stat(absolute).catch(() => null);
225
+ if (!info?.isFile()) return null;
226
+
227
+ const size = Math.min(info.size, this.options.maxBaselineBytes);
228
+ this.sizes.set(path, size);
229
+ return size;
230
+ }
231
+
232
+ private absolute(path: string): string | null {
233
+ const root = resolve(this.options.projectRoot);
234
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(join(root, path));
235
+ if (absolute !== root && !absolute.startsWith(root + sep)) return null;
236
+ return absolute;
237
+ }
238
+
239
+ private relative(path: string): string | null {
240
+ const absolute = this.absolute(path);
241
+ if (absolute === null) return null;
242
+ const root = resolve(this.options.projectRoot);
243
+ return absolute === root ? "" : absolute.slice(root.length + 1);
244
+ }
245
+
246
+ /** Keeps the tracked set bounded without losing recent evidence. */
247
+ private trim(): void {
248
+ while (this.lifetime.cited.size > MAX_TRACKED_FILES) {
249
+ const oldest = this.lifetime.cited.keys().next().value;
250
+ if (oldest === undefined) break;
251
+ this.lifetime.cited.delete(oldest);
252
+ }
253
+ while (this.lifetime.citations.size > MAX_TRACKED_FILES) {
254
+ const oldest = this.lifetime.citations.keys().next().value;
255
+ if (oldest === undefined) break;
256
+ this.lifetime.citations.delete(oldest);
257
+ }
258
+ while (this.lifetime.read.size > MAX_TRACKED_FILES) {
259
+ const oldest = this.lifetime.read.keys().next().value;
260
+ if (oldest === undefined) break;
261
+ this.lifetime.read.delete(oldest);
262
+ }
263
+ }
264
+
265
+ private scheduleFlush(): void {
266
+ if (this.timer) return;
267
+ this.timer = setTimeout(() => {
268
+ this.timer = null;
269
+ void this.save();
270
+ }, FLUSH_DELAY_MS);
271
+ }
272
+
273
+ private async save(): Promise<void> {
274
+ const payload = {
275
+ queries: this.lifetime.queries,
276
+ hits: this.lifetime.hits,
277
+ returned: this.lifetime.returned,
278
+ bytesRead: this.lifetime.bytesRead,
279
+ cited: [...this.lifetime.cited].slice(-MAX_TRACKED_FILES),
280
+ citations: [...this.lifetime.citations].slice(-MAX_TRACKED_FILES),
281
+ // Infinity is not JSON; -1 means "the whole file was read".
282
+ read: [...this.lifetime.read]
283
+ .slice(-MAX_TRACKED_FILES)
284
+ .map(([path, amount]) => [path, Number.isFinite(amount) ? amount : -1]),
285
+ };
286
+
287
+ await this.options.storage.set(STORAGE_KEY, payload as never).catch((error: unknown) => {
288
+ this.options.logger.debug("could not persist stats", String(error));
289
+ });
290
+ }
291
+ }
292
+
293
+ function numberOr(value: unknown, fallback: number): number {
294
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
295
+ }
296
+
297
+ function fileMap(value: unknown): Map<string, number> {
298
+ const map = new Map<string, number>();
299
+ if (!Array.isArray(value)) return map;
300
+
301
+ for (const entry of value) {
302
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
303
+ const [path, size] = entry;
304
+ if (typeof path === "string" && typeof size === "number") {
305
+ map.set(path, size < 0 ? Number.POSITIVE_INFINITY : size);
306
+ }
307
+ }
308
+ return map;
309
+ }
310
+
311
+ function countMap(value: unknown): Map<string, number> {
312
+ const map = new Map<string, number>();
313
+ if (!Array.isArray(value)) return map;
314
+
315
+ for (const entry of value) {
316
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
317
+ const [path, count] = entry;
318
+ if (typeof path === "string" && typeof count === "number" && count > 0) {
319
+ map.set(path, count);
320
+ }
321
+ }
322
+ return map;
323
+ }
324
+
325
+ const TOP_FILES = 5;
326
+
327
+ function topCited(
328
+ citations: ReadonlyMap<string, number>,
329
+ cited: ReadonlyMap<string, number>,
330
+ ): CitedFile[] {
331
+ return [...citations.entries()]
332
+ .map(([path, count]) => ({ path, citations: count, bytes: cited.get(path) ?? 0 }))
333
+ .sort((left, right) => right.citations - left.citations || left.path.localeCompare(right.path))
334
+ .slice(0, TOP_FILES);
335
+ }
336
+
337
+ function bumpRead(map: Map<string, number>, path: string, amount: number): void {
338
+ if (map.get(path) === Number.POSITIVE_INFINITY) return;
339
+ if (amount === Number.POSITIVE_INFINITY) {
340
+ map.set(path, amount);
341
+ return;
342
+ }
343
+ map.set(path, (map.get(path) ?? 0) + amount);
344
+ }
@@ -0,0 +1,211 @@
1
+ // On-demand anomaly scoring. XERJ has no datafeed: creating a detector and
2
+ // scoring it returns the whole timeline, and only the flagged buckets are
3
+ // formatted for the model.
4
+ //
5
+ // Verified against v1.0.0-rc.100: PUT creates or replaces a detector;
6
+ // POST /_score accepts `anomaly_threshold`; the response carries
7
+ // `anomaly_count` and an `anomalies` array with timestamp, actual, expected,
8
+ // z-score, and score.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import type { Logger } from "../log.ts";
12
+ import type { XerjClient } from "../node/client.ts";
13
+
14
+ export type AnomalyFunction = "count" | "mean" | "min" | "max" | "sum";
15
+
16
+ const FUNCTIONS: ReadonlySet<string> = new Set(["count", "mean", "min", "max", "sum"]);
17
+ const DEFAULT_TIME_FIELD = "@timestamp";
18
+ const DEFAULT_BUCKET_SPAN = "1h";
19
+ const DEFAULT_THRESHOLD = 3;
20
+ const MAX_POINTS = 10;
21
+
22
+ export interface AnomalyRequest {
23
+ readonly index: string;
24
+ readonly timeField?: string;
25
+ readonly function?: string;
26
+ readonly field?: string;
27
+ readonly bucketSpan?: string;
28
+ readonly threshold?: number;
29
+ }
30
+
31
+ export interface AnomalyPoint {
32
+ readonly timestamp: string;
33
+ readonly actual: number | null;
34
+ readonly expected: number | null;
35
+ readonly zScore: number | null;
36
+ readonly score: number | null;
37
+ }
38
+
39
+ export interface AnomalyResult {
40
+ readonly detector: string;
41
+ readonly index: string;
42
+ readonly anomalyCount: number;
43
+ readonly buckets: number;
44
+ readonly threshold: number;
45
+ readonly points: ReadonlyArray<AnomalyPoint>;
46
+ }
47
+
48
+ export class AnomalyService {
49
+ constructor(
50
+ private readonly client: XerjClient,
51
+ private readonly logger: Logger,
52
+ ) {}
53
+
54
+ async score(request: AnomalyRequest): Promise<AnomalyResult> {
55
+ const index = request.index.trim();
56
+ if (index.length === 0) throw new Error("anomaly index is required");
57
+
58
+ const fn = normalizeFunction(request.function);
59
+ if (fn !== "count" && (request.field ?? "").trim().length === 0) {
60
+ throw new Error(`anomaly function ${fn} needs a field`);
61
+ }
62
+
63
+ const timeField = request.timeField?.trim() || DEFAULT_TIME_FIELD;
64
+ const bucketSpan = request.bucketSpan?.trim() || DEFAULT_BUCKET_SPAN;
65
+ const threshold = normalizeThreshold(request.threshold);
66
+ const detector = detectorId({ index, timeField, fn, field: request.field, bucketSpan });
67
+
68
+ const config = {
69
+ source_index: index,
70
+ time_field: timeField,
71
+ function: fn,
72
+ ...(fn === "count" ? {} : { field: request.field }),
73
+ bucket_span: bucketSpan,
74
+ anomaly_threshold: threshold,
75
+ description: `opencode-xerj anomaly detector for ${index}`,
76
+ };
77
+
78
+ await this.client.put(
79
+ `/_ml/anomaly_detectors/${encodeURIComponent(detector)}`,
80
+ config,
81
+ "could not create the anomaly detector",
82
+ );
83
+
84
+ const response = await this.client.post(
85
+ `/_ml/anomaly_detectors/${encodeURIComponent(detector)}/_score`,
86
+ { anomaly_threshold: threshold },
87
+ "anomaly scoring failed",
88
+ );
89
+
90
+ this.logger.debug("scored an anomaly detector", { detector, index });
91
+ return parseAnomalyScore(detector, index, response, threshold);
92
+ }
93
+ }
94
+
95
+ /** Stable per configuration so repeated calls replace one detector. */
96
+ export function detectorId(input: {
97
+ index: string;
98
+ timeField: string;
99
+ fn: string;
100
+ field?: string;
101
+ bucketSpan: string;
102
+ }): string {
103
+ const parts = [
104
+ input.index,
105
+ input.timeField,
106
+ input.fn,
107
+ input.field ?? "",
108
+ input.bucketSpan,
109
+ ];
110
+ const key = parts.join("|");
111
+ return `xop-${createHash("sha256").update(key).digest("hex").slice(0, 12)}`;
112
+ }
113
+
114
+ export function parseAnomalyScore(
115
+ detector: string,
116
+ index: string,
117
+ response: unknown,
118
+ fallbackThreshold: number,
119
+ ): AnomalyResult {
120
+ if (!isRecord(response)) throw new Error("malformed anomaly response");
121
+
122
+ const raw = Array.isArray(response.anomalies) ? response.anomalies : [];
123
+ const points = raw.flatMap((entry): AnomalyPoint[] => {
124
+ if (!isRecord(entry)) return [];
125
+ const timestamp = stringValue(entry.timestamp_iso) ?? stringValue(entry.timestamp);
126
+ if (timestamp === null) return [];
127
+
128
+ return [
129
+ {
130
+ timestamp,
131
+ actual: numberValue(entry.actual),
132
+ expected: numberValue(entry.expected),
133
+ zScore: numberValue(entry.z_score),
134
+ score: numberValue(entry.anomaly_score),
135
+ },
136
+ ];
137
+ });
138
+
139
+ return {
140
+ detector,
141
+ index,
142
+ anomalyCount: numberValue(response.anomaly_count) ?? points.length,
143
+ buckets: numberValue(response.bucket_count) ?? numberValue(response.buckets) ?? 0,
144
+ threshold: numberValue(response.anomaly_threshold) ?? fallbackThreshold,
145
+ points,
146
+ };
147
+ }
148
+
149
+ export function formatAnomalies(result: AnomalyResult): string {
150
+ if (result.anomalyCount === 0) {
151
+ return `No anomalies in ${describeBuckets(result.buckets)} (threshold ${result.threshold}).`;
152
+ }
153
+
154
+ const summary =
155
+ `${result.anomalyCount} ` +
156
+ `${result.anomalyCount === 1 ? "anomaly" : "anomalies"} in ` +
157
+ `${describeBuckets(result.buckets)} (threshold ${result.threshold}).`;
158
+ const lines = [summary, `detector ${result.detector} over ${result.index}`];
159
+
160
+ for (const point of result.points.slice(0, MAX_POINTS)) {
161
+ lines.push(
162
+ [
163
+ point.timestamp,
164
+ `actual=${formatNumber(point.actual)}`,
165
+ `expected=${formatNumber(point.expected)}`,
166
+ `z=${formatNumber(point.zScore, 1)}`,
167
+ `score=${formatNumber(point.score, 1)}`,
168
+ ].join(" "),
169
+ );
170
+ }
171
+
172
+ if (result.points.length > MAX_POINTS) {
173
+ lines.push(`… ${result.points.length - MAX_POINTS} more`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ function describeBuckets(buckets: number): string {
179
+ return buckets > 0 ? `${buckets} buckets` : "the scored timeline";
180
+ }
181
+
182
+ function normalizeFunction(value: string | undefined): AnomalyFunction {
183
+ const fn = (value ?? "count").trim().toLowerCase();
184
+ if (!FUNCTIONS.has(fn)) {
185
+ throw new Error(`anomaly function must be one of ${[...FUNCTIONS].join(", ")}`);
186
+ }
187
+ return fn as AnomalyFunction;
188
+ }
189
+
190
+ function normalizeThreshold(value: number | undefined): number {
191
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
192
+ return DEFAULT_THRESHOLD;
193
+ }
194
+ return value;
195
+ }
196
+
197
+ function formatNumber(value: number | null, digits = 2): string {
198
+ return value === null ? "-" : value.toFixed(digits);
199
+ }
200
+
201
+ function stringValue(value: unknown): string | null {
202
+ return typeof value === "string" ? value : null;
203
+ }
204
+
205
+ function numberValue(value: unknown): number | null {
206
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
207
+ }
208
+
209
+ function isRecord(value: unknown): value is Record<string, unknown> {
210
+ return typeof value === "object" && value !== null && !Array.isArray(value);
211
+ }