@anyforge/control 0.13.0 → 0.14.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.
@@ -150,19 +150,55 @@ function attr(key, value) {
150
150
  function sanitize(name) {
151
151
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
152
152
  }
153
+ function mcpPrefixesOf(server2) {
154
+ return [.../* @__PURE__ */ new Set([`${sanitize(server2)}_`, `${server2}_`, `${server2}.`, `${server2}/`, `${server2}:`])];
155
+ }
156
+ var BUILTIN_TOOLS = /* @__PURE__ */ new Set([
157
+ "bash",
158
+ "read",
159
+ "write",
160
+ "edit",
161
+ "multiedit",
162
+ "patch",
163
+ "apply_patch",
164
+ "glob",
165
+ "grep",
166
+ "list",
167
+ "ls",
168
+ "webfetch",
169
+ "websearch",
170
+ "codesearch",
171
+ "todowrite",
172
+ "todoread",
173
+ "task",
174
+ "skill",
175
+ "lsp",
176
+ "question",
177
+ "batch",
178
+ "invalid",
179
+ "external_directory",
180
+ "doom_loop"
181
+ ]);
182
+ var UNNAMED_TOOL = "other_tool";
183
+ var STOPPED_ERROR = /abort|cancel|interrupt/i;
153
184
  var TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/;
154
185
  var OpencodeTelemetry = class {
155
186
  constructor(options) {
156
187
  this.options = options;
157
- this.mcpPrefixes = options.mcpServers.map((server) => ({ server, prefix: `${sanitize(server)}_` })).sort((a, b) => b.prefix.length - a.prefix.length);
188
+ this.setMcpServers(options.mcpServers);
158
189
  }
159
190
  options;
160
- mcpPrefixes;
191
+ mcpPrefixes = [];
161
192
  models = new Remembered();
162
193
  callTools = new Remembered();
163
194
  permissionTools = new Remembered();
164
195
  reported = new Remembered();
196
+ toolStarts = new Remembered();
165
197
  proxiedProviders = /* @__PURE__ */ new Set();
198
+ /** The configured MCP servers, which V2 reports after the plugin has started. */
199
+ setMcpServers(servers) {
200
+ this.mcpPrefixes = servers.flatMap((server2) => mcpPrefixesOf(server2).map((prefix) => ({ server: server2, prefix }))).sort((a, b) => b.prefix.length - a.prefix.length);
201
+ }
166
202
  /** `chat.message`: the member sent a prompt. Its text is never read. */
167
203
  prompt(nowMs) {
168
204
  return { logs: [this.record("user_prompt", nowMs, [])], usage: [] };
@@ -225,14 +261,24 @@ var OpencodeTelemetry = class {
225
261
  if (part.type === "tool") return this.tool(part, id, nowMs);
226
262
  return EMPTY;
227
263
  }
228
- /** One finished model call: an `api_request`, and its tokens unless the proxy metered them. */
264
+ /** One finished model call (V1 `step-finish` part). */
229
265
  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 : {};
266
+ return this.modelCall(`step:${id}`, str(part.messageID), part.tokens, part.cost, "api_request", nowMs);
267
+ }
268
+ /**
269
+ * One model call: an `api_request` (or `api_error`) and its tokens, unless
270
+ * the AnyForge proxy already metered them. Reported once per key.
271
+ */
272
+ modelCall(key, messageID, rawTokens, cost, event, nowMs) {
273
+ if (this.reported.has(key)) return EMPTY;
274
+ this.reported.set(key, true);
275
+ const model = this.models.get(messageID ?? "");
276
+ const logs = [this.record(event, nowMs, model ? [attr("model", model.modelID)] : [])];
277
+ return { logs, usage: this.usage(model, rawTokens, cost) };
278
+ }
279
+ usage(model, rawTokens, cost) {
280
+ if (model && this.proxiedProviders.has(model.providerID)) return [];
281
+ const tokens = isRecord(rawTokens) ? rawTokens : {};
236
282
  const cache = isRecord(tokens.cache) ? tokens.cache : {};
237
283
  const reading = {
238
284
  model: model?.modelID ?? "unknown",
@@ -241,10 +287,85 @@ var OpencodeTelemetry = class {
241
287
  output: num(tokens.output) + num(tokens.reasoning),
242
288
  cacheRead: num(cache.read),
243
289
  cacheWrite: num(cache.write),
244
- costUsd: num(part.cost)
290
+ costUsd: num(cost)
245
291
  };
246
292
  const any = reading.input + reading.output + reading.cacheRead + reading.cacheWrite + reading.costUsd > 0;
247
- return { logs, usage: any ? [reading] : [] };
293
+ return any ? [reading] : [];
294
+ }
295
+ // ── OpenCode V2 ──────────────────────────────────────────────────────────
296
+ /** One V2 session or permission event, `{ id, type, data }`. */
297
+ eventV2(event, nowMs) {
298
+ if (!isRecord(event) || !isRecord(event.data)) return EMPTY;
299
+ const data = event.data;
300
+ const eventId = str(event.id);
301
+ switch (event.type) {
302
+ case "session.inbox.enqueued": {
303
+ const inboxID = str(data.inboxID);
304
+ if (!isRecord(data.item) || data.item.type !== "user" || !inboxID || this.reported.has(`prompt:${inboxID}`)) return EMPTY;
305
+ this.reported.set(`prompt:${inboxID}`, true);
306
+ return this.prompt(nowMs);
307
+ }
308
+ case "session.step.started": {
309
+ const messageID = str(data.assistantMessageID);
310
+ const model = isRecord(data.model) ? data.model : {};
311
+ const modelID = str(model.id);
312
+ const providerID = str(model.providerID);
313
+ if (messageID && modelID && providerID) this.models.set(messageID, { modelID, providerID });
314
+ return EMPTY;
315
+ }
316
+ case "session.step.ended":
317
+ if (!eventId) return EMPTY;
318
+ return this.modelCall(`step2:${eventId}`, str(data.assistantMessageID), data.tokens, data.cost, "api_request", nowMs);
319
+ case "session.step.failed": {
320
+ if (!eventId) return EMPTY;
321
+ const error = isRecord(data.error) ? data.error : {};
322
+ if (STOPPED_ERROR.test(str(error.type) ?? "")) {
323
+ this.reported.set(`step2:${eventId}`, true);
324
+ return { logs: [], usage: this.usage(this.models.get(str(data.assistantMessageID) ?? ""), data.tokens, data.cost) };
325
+ }
326
+ return this.modelCall(`step2:${eventId}`, str(data.assistantMessageID), data.tokens, data.cost, "api_error", nowMs);
327
+ }
328
+ case "session.skill.activated": {
329
+ if (!eventId || this.reported.has(`skill:${eventId}`)) return EMPTY;
330
+ this.reported.set(`skill:${eventId}`, true);
331
+ return { logs: [this.record("skill_activated", nowMs, [attr("skill.name", this.skillName({ name: data.name }))])], usage: [] };
332
+ }
333
+ case "permission.asked": {
334
+ const source = isRecord(data.source) ? data.source : {};
335
+ this.notePermission(str(data.id), str(source.id), str(data.action));
336
+ return EMPTY;
337
+ }
338
+ case "permission.replied":
339
+ return this.decision(str(data.requestID), str(data.reply), nowMs);
340
+ default:
341
+ return EMPTY;
342
+ }
343
+ }
344
+ /** V2 `tool` hook `execute.before`: remember the call's tool and start time. */
345
+ toolStarted(callID, tool, nowMs) {
346
+ const id = str(callID);
347
+ const name = str(tool);
348
+ if (!id || !name) return;
349
+ this.callTools.set(id, name);
350
+ this.toolStarts.set(id, nowMs);
351
+ }
352
+ /** V2 `tool` hook `execute.after`: one finished call. V2 reports skills as their own event. */
353
+ toolEnded(callID, tool, status, nowMs) {
354
+ const id = str(callID);
355
+ const name = str(tool) ?? (id ? this.callTools.get(id) : void 0);
356
+ if (!id || !name || status !== "completed" && status !== "error") return EMPTY;
357
+ if (this.reported.has(`tool2:${id}`)) return EMPTY;
358
+ this.reported.set(`tool2:${id}`, true);
359
+ const started = this.toolStarts.get(id);
360
+ const duration = started === void 0 ? 0 : nowMs - started;
361
+ return {
362
+ logs: [this.record("tool_result", nowMs, [
363
+ ...this.toolAttributes(name),
364
+ attr("success", status === "completed"),
365
+ ...duration > 0 && duration < 864e5 ? [attr("duration_ms", Math.round(duration))] : []
366
+ ])],
367
+ usage: []
368
+ };
248
369
  }
249
370
  tool(part, id, nowMs) {
250
371
  const tool = str(part.tool);
@@ -294,7 +415,8 @@ var OpencodeTelemetry = class {
294
415
  const name = this.options.sendNames ? `mcp__${mcp.server}__${tool.slice(mcp.prefix.length)}` : "mcp_tool";
295
416
  return [attr("tool_name", TOOL_NAME.test(name) ? name : "mcp_tool"), attr("tool_source", "mcp")];
296
417
  }
297
- return TOOL_NAME.test(tool) ? [attr("tool_name", tool), attr("tool_source", "builtin")] : [attr("tool_source", "builtin")];
418
+ const named = this.options.sendNames ? TOOL_NAME.test(tool) : BUILTIN_TOOLS.has(tool);
419
+ return [attr("tool_name", named ? tool : UNNAMED_TOOL), attr("tool_source", "builtin")];
298
420
  }
299
421
  skillName(input) {
300
422
  const name = isRecord(input) ? str(input.name) : void 0;
@@ -335,24 +457,20 @@ async function mcpServerNames(input) {
335
457
  return [];
336
458
  }
337
459
  }
338
- var AnyforgeTelemetry = async (input) => {
460
+ async function start(mcpServers) {
339
461
  const anyforgeDir = join(homedir(), ".anyforge");
340
462
  const creds = await readJson(join(anyforgeDir, "credentials"));
341
463
  const apiKey = typeof creds?.apiKey === "string" ? creds.apiKey : null;
342
464
  const endpoint = typeof creds?.endpoint === "string" ? creds.endpoint.replace(/\/+$/, "") : null;
343
- if (!apiKey || !endpoint) return {};
344
- let anyforgeHost = null;
465
+ if (!apiKey || !endpoint) return null;
466
+ let anyforgeHost;
345
467
  try {
346
468
  anyforgeHost = new URL(endpoint).host;
347
469
  } catch {
348
- return {};
470
+ return null;
349
471
  }
350
472
  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
- });
473
+ const telemetry = new OpencodeTelemetry({ sendNames: settings?.sendNames === true, mcpServers, anyforgeHost });
356
474
  const exporter = new TelemetryExporter({
357
475
  endpoint,
358
476
  apiKey,
@@ -363,36 +481,87 @@ var AnyforgeTelemetry = async (input) => {
363
481
  void exporter.flush();
364
482
  }, FLUSH_INTERVAL_MS);
365
483
  timer.unref?.();
366
- const take = (t) => {
367
- exporter.addLogs(t.logs);
368
- exporter.addUsage(t.usage);
369
- };
370
484
  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
- }
485
+ telemetry,
486
+ exporter,
487
+ take: (t) => {
488
+ exporter.addLogs(t.logs);
489
+ exporter.addUsage(t.usage);
389
490
  },
390
- dispose: async () => {
491
+ stop: async () => {
391
492
  clearInterval(timer);
392
493
  await exporter.flush();
393
494
  }
394
495
  };
395
- };
496
+ }
497
+ function quietly(step) {
498
+ try {
499
+ step();
500
+ } catch {
501
+ }
502
+ }
503
+ async function server(input) {
504
+ const run = await start(await mcpServerNames(input));
505
+ if (!run) return {};
506
+ const { telemetry, take, exporter } = run;
507
+ return {
508
+ "chat.message": async () => quietly(() => take(telemetry.prompt(Date.now()))),
509
+ "chat.params": async (params) => quietly(() => telemetry.provider(params?.provider?.info?.id, params?.provider?.options?.baseURL)),
510
+ event: async ({ event }) => quietly(() => {
511
+ take(telemetry.event(event, Date.now()));
512
+ if (event?.type === "session.idle") void exporter.flush();
513
+ }),
514
+ dispose: () => run.stop()
515
+ };
516
+ }
517
+ var V2_EVENTS = [
518
+ "session.inbox.enqueued",
519
+ "session.step.started",
520
+ "session.step.ended",
521
+ "session.step.failed",
522
+ "session.skill.activated",
523
+ "session.execution.succeeded",
524
+ "session.execution.failed",
525
+ "permission.asked",
526
+ "permission.replied"
527
+ ];
528
+ async function setup(ctx) {
529
+ const run = await start([]);
530
+ if (!run) return;
531
+ const { telemetry, take, exporter } = run;
532
+ const registrations = [];
533
+ const register = async (registering) => {
534
+ try {
535
+ registrations.push(await registering());
536
+ } catch {
537
+ }
538
+ };
539
+ await register(() => ctx.mcp.transform((editor) => quietly(() => telemetry.setMcpServers(editor.list().map(([name]) => name)))));
540
+ await register(() => ctx.session.hook("model.request", (input) => quietly(() => telemetry.provider(input?.model?.providerID, input?.baseURL))));
541
+ await register(() => ctx.tool.hook("execute.before", (input) => quietly(() => telemetry.toolStarted(input?.id, input?.tool, Date.now()))));
542
+ await register(() => ctx.tool.hook("execute.after", (input) => quietly(() => take(telemetry.toolEnded(input?.id, input?.tool, input?.status, Date.now())))));
543
+ const abort = new AbortController();
544
+ void (async () => {
545
+ try {
546
+ for await (const event of ctx.event.subscribe({ types: V2_EVENTS, signal: abort.signal })) {
547
+ quietly(() => {
548
+ take(telemetry.eventV2(event, Date.now()));
549
+ const type = event?.type;
550
+ if (type === "session.execution.succeeded" || type === "session.execution.failed") void exporter.flush();
551
+ });
552
+ }
553
+ } catch {
554
+ }
555
+ })();
556
+ return async () => {
557
+ abort.abort();
558
+ await Promise.all(registrations.map((r) => r.dispose().catch(() => {
559
+ })));
560
+ await run.stop();
561
+ };
562
+ }
563
+ var AnyforgeTelemetry = { id: "anyforge-telemetry", setup, server };
564
+ var plugin_default = AnyforgeTelemetry;
396
565
  export {
397
- AnyforgeTelemetry
566
+ plugin_default as default
398
567
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyforge/control",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",