@anyforge/control 0.12.0 → 0.13.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,398 @@
1
+ // src/opencodePlugin/plugin.ts
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // src/opencodePlugin/exporter.ts
7
+ var SCOPE = { name: "anyforge-opencode-plugin" };
8
+ var MAX_BUFFERED_LOGS = 2e3;
9
+ var LOGS_PER_EXPORT = 1e3;
10
+ var TelemetryExporter = class {
11
+ constructor(options) {
12
+ this.options = options;
13
+ }
14
+ options;
15
+ logs = [];
16
+ usage = /* @__PURE__ */ new Map();
17
+ stopped = false;
18
+ sending = null;
19
+ addLogs(records) {
20
+ if (this.stopped || records.length === 0) return;
21
+ this.logs.push(...records);
22
+ const max = this.options.maxBufferedLogs ?? MAX_BUFFERED_LOGS;
23
+ if (this.logs.length > max) this.logs.splice(0, this.logs.length - max);
24
+ }
25
+ addUsage(readings) {
26
+ if (this.stopped) return;
27
+ for (const r of readings) {
28
+ const sum = this.usage.get(r.model) ?? { model: r.model, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
29
+ sum.input += r.input;
30
+ sum.output += r.output;
31
+ sum.cacheRead += r.cacheRead;
32
+ sum.cacheWrite += r.cacheWrite;
33
+ sum.costUsd += r.costUsd;
34
+ this.usage.set(r.model, sum);
35
+ }
36
+ }
37
+ /** Has a key refusal stopped this exporter? */
38
+ get disabled() {
39
+ return this.stopped;
40
+ }
41
+ /** Send what is buffered. Never throws; concurrent calls share one send. */
42
+ flush(nowMs = Date.now()) {
43
+ if (!this.sending) this.sending = this.send(nowMs).finally(() => {
44
+ this.sending = null;
45
+ });
46
+ return this.sending;
47
+ }
48
+ async send(nowMs) {
49
+ if (this.stopped) return;
50
+ if (this.usage.size > 0) {
51
+ const usage = [...this.usage.values()];
52
+ this.usage = /* @__PURE__ */ new Map();
53
+ const status = await this.post("/api/otel/v1/metrics", metricsBody(this.options.resource, usage, nowMs));
54
+ if (!ok(status) && !this.stopped) this.addUsage(usage);
55
+ }
56
+ while (this.logs.length > 0 && !this.stopped) {
57
+ const batch = this.logs.slice(0, LOGS_PER_EXPORT);
58
+ const status = await this.post("/api/otel/v1/logs", logsBody(this.options.resource, batch));
59
+ if (!ok(status)) return;
60
+ this.logs.splice(0, batch.length);
61
+ }
62
+ }
63
+ async post(path, body) {
64
+ try {
65
+ const res = await this.options.fetch(`${this.options.endpoint}${path}`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.options.apiKey}` },
68
+ body: JSON.stringify(body)
69
+ });
70
+ if (res.status === 401 || res.status === 403) {
71
+ this.stopped = true;
72
+ this.logs = [];
73
+ this.usage = /* @__PURE__ */ new Map();
74
+ }
75
+ return res.status;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ };
81
+ function ok(status) {
82
+ return status !== null && status >= 200 && status < 300;
83
+ }
84
+ function logsBody(resource, records) {
85
+ return { resourceLogs: [{ resource: { attributes: resource }, scopeLogs: [{ scope: SCOPE, logRecords: records }] }] };
86
+ }
87
+ function metricsBody(resource, usage, nowMs) {
88
+ const time = `${Math.floor(nowMs)}000000`;
89
+ const model = (m) => ({ key: "model", value: { stringValue: m } });
90
+ const tokenPoints = usage.flatMap((u) => [
91
+ ["input", u.input],
92
+ ["output", u.output],
93
+ ["cacheRead", u.cacheRead],
94
+ ["cacheCreation", u.cacheWrite]
95
+ ].filter(([, n]) => n > 0).map(([type, n]) => ({
96
+ attributes: [{ key: "type", value: { stringValue: type } }, model(u.model)],
97
+ asInt: String(Math.round(n)),
98
+ timeUnixNano: time
99
+ })));
100
+ const costPoints = usage.filter((u) => u.costUsd > 0).map((u) => ({ attributes: [model(u.model)], asDouble: u.costUsd, timeUnixNano: time }));
101
+ const sum = (dataPoints) => ({ aggregationTemporality: 1, isMonotonic: true, dataPoints });
102
+ return {
103
+ resourceMetrics: [{
104
+ resource: { attributes: resource },
105
+ scopeMetrics: [{
106
+ scope: SCOPE,
107
+ metrics: [
108
+ ...tokenPoints.length ? [{ name: "opencode.token.usage", unit: "tokens", sum: sum(tokenPoints) }] : [],
109
+ ...costPoints.length ? [{ name: "opencode.cost.usage", unit: "USD", sum: sum(costPoints) }] : []
110
+ ]
111
+ }]
112
+ }]
113
+ };
114
+ }
115
+
116
+ // src/opencodePlugin/translate.ts
117
+ var EMPTY = { logs: [], usage: [] };
118
+ var MAX_REMEMBERED = 5e3;
119
+ var Remembered = class {
120
+ map = /* @__PURE__ */ new Map();
121
+ get(key) {
122
+ return this.map.get(key);
123
+ }
124
+ has(key) {
125
+ return this.map.has(key);
126
+ }
127
+ set(key, value) {
128
+ this.map.delete(key);
129
+ this.map.set(key, value);
130
+ if (this.map.size > MAX_REMEMBERED) {
131
+ const oldest = this.map.keys().next().value;
132
+ if (oldest !== void 0) this.map.delete(oldest);
133
+ }
134
+ }
135
+ };
136
+ function isRecord(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value);
138
+ }
139
+ function str(value) {
140
+ return typeof value === "string" && value.length > 0 ? value : void 0;
141
+ }
142
+ function num(value) {
143
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
144
+ }
145
+ function attr(key, value) {
146
+ if (typeof value === "boolean") return { key, value: { boolValue: value } };
147
+ if (typeof value === "number") return Number.isInteger(value) ? { key, value: { intValue: String(value) } } : { key, value: { doubleValue: value } };
148
+ return { key, value: { stringValue: value } };
149
+ }
150
+ function sanitize(name) {
151
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_");
152
+ }
153
+ var TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/;
154
+ var OpencodeTelemetry = class {
155
+ constructor(options) {
156
+ this.options = options;
157
+ this.mcpPrefixes = options.mcpServers.map((server) => ({ server, prefix: `${sanitize(server)}_` })).sort((a, b) => b.prefix.length - a.prefix.length);
158
+ }
159
+ options;
160
+ mcpPrefixes;
161
+ models = new Remembered();
162
+ callTools = new Remembered();
163
+ permissionTools = new Remembered();
164
+ reported = new Remembered();
165
+ proxiedProviders = /* @__PURE__ */ new Set();
166
+ /** `chat.message`: the member sent a prompt. Its text is never read. */
167
+ prompt(nowMs) {
168
+ return { logs: [this.record("user_prompt", nowMs, [])], usage: [] };
169
+ }
170
+ /**
171
+ * `chat.params`: the provider and the base URL this call goes to. A provider
172
+ * pointed at the AnyForge proxy is remembered, and its calls send no tokens.
173
+ */
174
+ provider(providerID, baseURL) {
175
+ const id = str(providerID);
176
+ const url = str(baseURL);
177
+ if (!id || !url || !this.options.anyforgeHost) return;
178
+ let host;
179
+ try {
180
+ host = new URL(url).host;
181
+ } catch {
182
+ return;
183
+ }
184
+ if (host === this.options.anyforgeHost) this.proxiedProviders.add(id);
185
+ else this.proxiedProviders.delete(id);
186
+ }
187
+ /** One OpenCode bus event (the plugin's `event` hook). */
188
+ event(event, nowMs) {
189
+ if (!isRecord(event) || !isRecord(event.properties)) return EMPTY;
190
+ const props = event.properties;
191
+ switch (event.type) {
192
+ case "message.updated":
193
+ return this.message(props.info, nowMs);
194
+ case "message.part.updated":
195
+ return this.part(props.part, nowMs);
196
+ case "permission.updated":
197
+ this.notePermission(str(props.id), str(props.callID), str(props.type));
198
+ return EMPTY;
199
+ case "permission.asked":
200
+ this.notePermission(str(props.id), isRecord(props.tool) ? str(props.tool.callID) : void 0, str(props.permission));
201
+ return EMPTY;
202
+ case "permission.replied":
203
+ return this.decision(str(props.requestID) ?? str(props.permissionID), str(props.reply) ?? str(props.response), nowMs);
204
+ default:
205
+ return EMPTY;
206
+ }
207
+ }
208
+ message(info, nowMs) {
209
+ if (!isRecord(info) || info.role !== "assistant") return EMPTY;
210
+ const id = str(info.id);
211
+ const modelID = str(info.modelID);
212
+ const providerID = str(info.providerID);
213
+ if (!id) return EMPTY;
214
+ if (modelID && providerID) this.models.set(id, { modelID, providerID });
215
+ const error = isRecord(info.error) ? info.error : null;
216
+ if (!error || error.name === "MessageAbortedError" || this.reported.has(`error:${id}`)) return EMPTY;
217
+ this.reported.set(`error:${id}`, true);
218
+ return { logs: [this.record("api_error", nowMs, modelID ? [attr("model", modelID)] : [])], usage: [] };
219
+ }
220
+ part(part, nowMs) {
221
+ if (!isRecord(part)) return EMPTY;
222
+ const id = str(part.id);
223
+ if (!id) return EMPTY;
224
+ if (part.type === "step-finish") return this.step(part, id, nowMs);
225
+ if (part.type === "tool") return this.tool(part, id, nowMs);
226
+ return EMPTY;
227
+ }
228
+ /** One finished model call: an `api_request`, and its tokens unless the proxy metered them. */
229
+ step(part, id, nowMs) {
230
+ if (this.reported.has(`step:${id}`)) return EMPTY;
231
+ this.reported.set(`step:${id}`, true);
232
+ const model = this.models.get(str(part.messageID) ?? "");
233
+ const logs = [this.record("api_request", nowMs, model ? [attr("model", model.modelID)] : [])];
234
+ if (model && this.proxiedProviders.has(model.providerID)) return { logs, usage: [] };
235
+ const tokens = isRecord(part.tokens) ? part.tokens : {};
236
+ const cache = isRecord(tokens.cache) ? tokens.cache : {};
237
+ const reading = {
238
+ model: model?.modelID ?? "unknown",
239
+ input: num(tokens.input),
240
+ // Reasoning tokens are billed as output.
241
+ output: num(tokens.output) + num(tokens.reasoning),
242
+ cacheRead: num(cache.read),
243
+ cacheWrite: num(cache.write),
244
+ costUsd: num(part.cost)
245
+ };
246
+ const any = reading.input + reading.output + reading.cacheRead + reading.cacheWrite + reading.costUsd > 0;
247
+ return { logs, usage: any ? [reading] : [] };
248
+ }
249
+ tool(part, id, nowMs) {
250
+ const tool = str(part.tool);
251
+ const state = isRecord(part.state) ? part.state : null;
252
+ if (!tool || !state) return EMPTY;
253
+ const callID = str(part.callID);
254
+ if (callID) this.callTools.set(callID, tool);
255
+ if (state.status !== "completed" && state.status !== "error") return EMPTY;
256
+ if (this.reported.has(`tool:${id}`)) return EMPTY;
257
+ this.reported.set(`tool:${id}`, true);
258
+ const time = isRecord(state.time) ? state.time : {};
259
+ const duration = num(time.end) - num(time.start);
260
+ const attributes = [
261
+ ...this.toolAttributes(tool),
262
+ attr("success", state.status === "completed"),
263
+ ...duration > 0 && duration < 864e5 ? [attr("duration_ms", Math.round(duration))] : []
264
+ ];
265
+ const logs = [this.record("tool_result", nowMs, attributes)];
266
+ if (tool === "skill") logs.push(this.record("skill_activated", nowMs, [attr("skill.name", this.skillName(state.input))]));
267
+ return { logs, usage: [] };
268
+ }
269
+ notePermission(id, callID, permission) {
270
+ if (!id) return;
271
+ const tool = (callID && this.callTools.get(callID)) ?? permission;
272
+ if (tool) this.permissionTools.set(id, tool);
273
+ }
274
+ /** A person answered a permission prompt. Rules that allow or deny on their own send no event. */
275
+ decision(id, reply, nowMs) {
276
+ if (!id || !reply || this.reported.has(`decision:${id}`)) return EMPTY;
277
+ const outcome = reply === "once" ? { decision: "accept", source: "user_temporary" } : reply === "always" ? { decision: "accept", source: "user_permanent" } : reply === "reject" ? { decision: "reject", source: "user_reject" } : null;
278
+ if (!outcome) return EMPTY;
279
+ this.reported.set(`decision:${id}`, true);
280
+ const tool = this.permissionTools.get(id);
281
+ return {
282
+ logs: [this.record("tool_decision", nowMs, [
283
+ ...tool ? this.toolAttributes(tool) : [],
284
+ attr("decision", outcome.decision),
285
+ attr("source", outcome.source)
286
+ ])],
287
+ usage: []
288
+ };
289
+ }
290
+ /** `tool_name` and `tool_source`, in the form Claude Code uses for an MCP tool. */
291
+ toolAttributes(tool) {
292
+ const mcp = this.mcpPrefixes.find((m) => tool.startsWith(m.prefix) && tool.length > m.prefix.length);
293
+ if (mcp) {
294
+ const name = this.options.sendNames ? `mcp__${mcp.server}__${tool.slice(mcp.prefix.length)}` : "mcp_tool";
295
+ return [attr("tool_name", TOOL_NAME.test(name) ? name : "mcp_tool"), attr("tool_source", "mcp")];
296
+ }
297
+ return TOOL_NAME.test(tool) ? [attr("tool_name", tool), attr("tool_source", "builtin")] : [attr("tool_source", "builtin")];
298
+ }
299
+ skillName(input) {
300
+ const name = isRecord(input) ? str(input.name) : void 0;
301
+ return this.options.sendNames && name && TOOL_NAME.test(name) ? name : "custom_skill";
302
+ }
303
+ record(event, nowMs, attributes) {
304
+ const nano = `${Math.floor(nowMs)}000000`;
305
+ return {
306
+ timeUnixNano: nano,
307
+ observedTimeUnixNano: nano,
308
+ body: { stringValue: `opencode.${event}` },
309
+ attributes: [attr("event.name", event), attr("event.timestamp", new Date(nowMs).toISOString()), ...attributes]
310
+ };
311
+ }
312
+ };
313
+
314
+ // src/opencodePlugin/plugin.ts
315
+ var FLUSH_INTERVAL_MS = 1e4;
316
+ async function readJson(path) {
317
+ try {
318
+ const parsed = JSON.parse(await readFile(path, "utf8"));
319
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+ async function mcpServerNames(input) {
325
+ try {
326
+ const res = await input.client?.config?.get?.();
327
+ const config = res && typeof res === "object" && "data" in res ? res.data : res;
328
+ const mcp = config?.mcp;
329
+ if (!mcp || typeof mcp !== "object") return [];
330
+ const entries = mcp;
331
+ const servers = entries.servers && typeof entries.servers === "object" ? Object.keys(entries.servers) : [];
332
+ const legacy = Object.keys(entries).filter((k) => k !== "servers" && k !== "timeout" && typeof entries[k] === "object");
333
+ return [.../* @__PURE__ */ new Set([...servers, ...legacy])];
334
+ } catch {
335
+ return [];
336
+ }
337
+ }
338
+ var AnyforgeTelemetry = async (input) => {
339
+ const anyforgeDir = join(homedir(), ".anyforge");
340
+ const creds = await readJson(join(anyforgeDir, "credentials"));
341
+ const apiKey = typeof creds?.apiKey === "string" ? creds.apiKey : null;
342
+ const endpoint = typeof creds?.endpoint === "string" ? creds.endpoint.replace(/\/+$/, "") : null;
343
+ if (!apiKey || !endpoint) return {};
344
+ let anyforgeHost = null;
345
+ try {
346
+ anyforgeHost = new URL(endpoint).host;
347
+ } catch {
348
+ return {};
349
+ }
350
+ const settings = await readJson(join(anyforgeDir, "opencode-telemetry.json"));
351
+ const telemetry = new OpencodeTelemetry({
352
+ sendNames: settings?.sendNames === true,
353
+ mcpServers: await mcpServerNames(input),
354
+ anyforgeHost
355
+ });
356
+ const exporter = new TelemetryExporter({
357
+ endpoint,
358
+ apiKey,
359
+ fetch: (async (url, init) => ({ status: (await fetch(url, init)).status })),
360
+ resource: [{ key: "service.name", value: { stringValue: "opencode" } }]
361
+ });
362
+ const timer = setInterval(() => {
363
+ void exporter.flush();
364
+ }, FLUSH_INTERVAL_MS);
365
+ timer.unref?.();
366
+ const take = (t) => {
367
+ exporter.addLogs(t.logs);
368
+ exporter.addUsage(t.usage);
369
+ };
370
+ return {
371
+ "chat.message": async () => {
372
+ try {
373
+ take(telemetry.prompt(Date.now()));
374
+ } catch {
375
+ }
376
+ },
377
+ "chat.params": async (params) => {
378
+ try {
379
+ telemetry.provider(params?.provider?.info?.id, params?.provider?.options?.baseURL);
380
+ } catch {
381
+ }
382
+ },
383
+ event: async ({ event }) => {
384
+ try {
385
+ take(telemetry.event(event, Date.now()));
386
+ if (event?.type === "session.idle") void exporter.flush();
387
+ } catch {
388
+ }
389
+ },
390
+ dispose: async () => {
391
+ clearInterval(timer);
392
+ await exporter.flush();
393
+ }
394
+ };
395
+ };
396
+ export {
397
+ AnyforgeTelemetry
398
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyforge/control",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "AnyForge CLI — hosted Oracle conversations, a governed LLM proxy, and a scriptable client for approvals, crews, work items and workflows.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://anyforge.ai",
@@ -23,7 +23,7 @@
23
23
  "README.md"
24
24
  ],
25
25
  "scripts": {
26
- "build": "esbuild src/cli.ts --bundle --minify --platform=node --format=esm --outfile=dist/cli.js --packages=external && chmod +x dist/cli.js",
26
+ "build": "esbuild src/cli.ts --bundle --minify --platform=node --format=esm --outfile=dist/cli.js --packages=external && chmod +x dist/cli.js && esbuild src/opencodePlugin/plugin.ts --bundle --platform=node --format=esm --outfile=dist/opencode-plugin.js",
27
27
  "type-check": "tsc --noEmit",
28
28
  "test": "npm run build && node scripts/test.mjs",
29
29
  "dev": "tsx src/cli.ts",