@anyforge/control 0.12.0 → 0.13.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/dist/cli.js +149 -142
- package/dist/opencode-plugin.js +567 -0
- package/package.json +2 -2
|
@@ -0,0 +1,567 @@
|
|
|
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
|
+
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;
|
|
184
|
+
var TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/;
|
|
185
|
+
var OpencodeTelemetry = class {
|
|
186
|
+
constructor(options) {
|
|
187
|
+
this.options = options;
|
|
188
|
+
this.setMcpServers(options.mcpServers);
|
|
189
|
+
}
|
|
190
|
+
options;
|
|
191
|
+
mcpPrefixes = [];
|
|
192
|
+
models = new Remembered();
|
|
193
|
+
callTools = new Remembered();
|
|
194
|
+
permissionTools = new Remembered();
|
|
195
|
+
reported = new Remembered();
|
|
196
|
+
toolStarts = new Remembered();
|
|
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
|
+
}
|
|
202
|
+
/** `chat.message`: the member sent a prompt. Its text is never read. */
|
|
203
|
+
prompt(nowMs) {
|
|
204
|
+
return { logs: [this.record("user_prompt", nowMs, [])], usage: [] };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `chat.params`: the provider and the base URL this call goes to. A provider
|
|
208
|
+
* pointed at the AnyForge proxy is remembered, and its calls send no tokens.
|
|
209
|
+
*/
|
|
210
|
+
provider(providerID, baseURL) {
|
|
211
|
+
const id = str(providerID);
|
|
212
|
+
const url = str(baseURL);
|
|
213
|
+
if (!id || !url || !this.options.anyforgeHost) return;
|
|
214
|
+
let host;
|
|
215
|
+
try {
|
|
216
|
+
host = new URL(url).host;
|
|
217
|
+
} catch {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (host === this.options.anyforgeHost) this.proxiedProviders.add(id);
|
|
221
|
+
else this.proxiedProviders.delete(id);
|
|
222
|
+
}
|
|
223
|
+
/** One OpenCode bus event (the plugin's `event` hook). */
|
|
224
|
+
event(event, nowMs) {
|
|
225
|
+
if (!isRecord(event) || !isRecord(event.properties)) return EMPTY;
|
|
226
|
+
const props = event.properties;
|
|
227
|
+
switch (event.type) {
|
|
228
|
+
case "message.updated":
|
|
229
|
+
return this.message(props.info, nowMs);
|
|
230
|
+
case "message.part.updated":
|
|
231
|
+
return this.part(props.part, nowMs);
|
|
232
|
+
case "permission.updated":
|
|
233
|
+
this.notePermission(str(props.id), str(props.callID), str(props.type));
|
|
234
|
+
return EMPTY;
|
|
235
|
+
case "permission.asked":
|
|
236
|
+
this.notePermission(str(props.id), isRecord(props.tool) ? str(props.tool.callID) : void 0, str(props.permission));
|
|
237
|
+
return EMPTY;
|
|
238
|
+
case "permission.replied":
|
|
239
|
+
return this.decision(str(props.requestID) ?? str(props.permissionID), str(props.reply) ?? str(props.response), nowMs);
|
|
240
|
+
default:
|
|
241
|
+
return EMPTY;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
message(info, nowMs) {
|
|
245
|
+
if (!isRecord(info) || info.role !== "assistant") return EMPTY;
|
|
246
|
+
const id = str(info.id);
|
|
247
|
+
const modelID = str(info.modelID);
|
|
248
|
+
const providerID = str(info.providerID);
|
|
249
|
+
if (!id) return EMPTY;
|
|
250
|
+
if (modelID && providerID) this.models.set(id, { modelID, providerID });
|
|
251
|
+
const error = isRecord(info.error) ? info.error : null;
|
|
252
|
+
if (!error || error.name === "MessageAbortedError" || this.reported.has(`error:${id}`)) return EMPTY;
|
|
253
|
+
this.reported.set(`error:${id}`, true);
|
|
254
|
+
return { logs: [this.record("api_error", nowMs, modelID ? [attr("model", modelID)] : [])], usage: [] };
|
|
255
|
+
}
|
|
256
|
+
part(part, nowMs) {
|
|
257
|
+
if (!isRecord(part)) return EMPTY;
|
|
258
|
+
const id = str(part.id);
|
|
259
|
+
if (!id) return EMPTY;
|
|
260
|
+
if (part.type === "step-finish") return this.step(part, id, nowMs);
|
|
261
|
+
if (part.type === "tool") return this.tool(part, id, nowMs);
|
|
262
|
+
return EMPTY;
|
|
263
|
+
}
|
|
264
|
+
/** One finished model call (V1 `step-finish` part). */
|
|
265
|
+
step(part, id, nowMs) {
|
|
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 : {};
|
|
282
|
+
const cache = isRecord(tokens.cache) ? tokens.cache : {};
|
|
283
|
+
const reading = {
|
|
284
|
+
model: model?.modelID ?? "unknown",
|
|
285
|
+
input: num(tokens.input),
|
|
286
|
+
// Reasoning tokens are billed as output.
|
|
287
|
+
output: num(tokens.output) + num(tokens.reasoning),
|
|
288
|
+
cacheRead: num(cache.read),
|
|
289
|
+
cacheWrite: num(cache.write),
|
|
290
|
+
costUsd: num(cost)
|
|
291
|
+
};
|
|
292
|
+
const any = reading.input + reading.output + reading.cacheRead + reading.cacheWrite + reading.costUsd > 0;
|
|
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
|
+
};
|
|
369
|
+
}
|
|
370
|
+
tool(part, id, nowMs) {
|
|
371
|
+
const tool = str(part.tool);
|
|
372
|
+
const state = isRecord(part.state) ? part.state : null;
|
|
373
|
+
if (!tool || !state) return EMPTY;
|
|
374
|
+
const callID = str(part.callID);
|
|
375
|
+
if (callID) this.callTools.set(callID, tool);
|
|
376
|
+
if (state.status !== "completed" && state.status !== "error") return EMPTY;
|
|
377
|
+
if (this.reported.has(`tool:${id}`)) return EMPTY;
|
|
378
|
+
this.reported.set(`tool:${id}`, true);
|
|
379
|
+
const time = isRecord(state.time) ? state.time : {};
|
|
380
|
+
const duration = num(time.end) - num(time.start);
|
|
381
|
+
const attributes = [
|
|
382
|
+
...this.toolAttributes(tool),
|
|
383
|
+
attr("success", state.status === "completed"),
|
|
384
|
+
...duration > 0 && duration < 864e5 ? [attr("duration_ms", Math.round(duration))] : []
|
|
385
|
+
];
|
|
386
|
+
const logs = [this.record("tool_result", nowMs, attributes)];
|
|
387
|
+
if (tool === "skill") logs.push(this.record("skill_activated", nowMs, [attr("skill.name", this.skillName(state.input))]));
|
|
388
|
+
return { logs, usage: [] };
|
|
389
|
+
}
|
|
390
|
+
notePermission(id, callID, permission) {
|
|
391
|
+
if (!id) return;
|
|
392
|
+
const tool = (callID && this.callTools.get(callID)) ?? permission;
|
|
393
|
+
if (tool) this.permissionTools.set(id, tool);
|
|
394
|
+
}
|
|
395
|
+
/** A person answered a permission prompt. Rules that allow or deny on their own send no event. */
|
|
396
|
+
decision(id, reply, nowMs) {
|
|
397
|
+
if (!id || !reply || this.reported.has(`decision:${id}`)) return EMPTY;
|
|
398
|
+
const outcome = reply === "once" ? { decision: "accept", source: "user_temporary" } : reply === "always" ? { decision: "accept", source: "user_permanent" } : reply === "reject" ? { decision: "reject", source: "user_reject" } : null;
|
|
399
|
+
if (!outcome) return EMPTY;
|
|
400
|
+
this.reported.set(`decision:${id}`, true);
|
|
401
|
+
const tool = this.permissionTools.get(id);
|
|
402
|
+
return {
|
|
403
|
+
logs: [this.record("tool_decision", nowMs, [
|
|
404
|
+
...tool ? this.toolAttributes(tool) : [],
|
|
405
|
+
attr("decision", outcome.decision),
|
|
406
|
+
attr("source", outcome.source)
|
|
407
|
+
])],
|
|
408
|
+
usage: []
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** `tool_name` and `tool_source`, in the form Claude Code uses for an MCP tool. */
|
|
412
|
+
toolAttributes(tool) {
|
|
413
|
+
const mcp = this.mcpPrefixes.find((m) => tool.startsWith(m.prefix) && tool.length > m.prefix.length);
|
|
414
|
+
if (mcp) {
|
|
415
|
+
const name = this.options.sendNames ? `mcp__${mcp.server}__${tool.slice(mcp.prefix.length)}` : "mcp_tool";
|
|
416
|
+
return [attr("tool_name", TOOL_NAME.test(name) ? name : "mcp_tool"), attr("tool_source", "mcp")];
|
|
417
|
+
}
|
|
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")];
|
|
420
|
+
}
|
|
421
|
+
skillName(input) {
|
|
422
|
+
const name = isRecord(input) ? str(input.name) : void 0;
|
|
423
|
+
return this.options.sendNames && name && TOOL_NAME.test(name) ? name : "custom_skill";
|
|
424
|
+
}
|
|
425
|
+
record(event, nowMs, attributes) {
|
|
426
|
+
const nano = `${Math.floor(nowMs)}000000`;
|
|
427
|
+
return {
|
|
428
|
+
timeUnixNano: nano,
|
|
429
|
+
observedTimeUnixNano: nano,
|
|
430
|
+
body: { stringValue: `opencode.${event}` },
|
|
431
|
+
attributes: [attr("event.name", event), attr("event.timestamp", new Date(nowMs).toISOString()), ...attributes]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// src/opencodePlugin/plugin.ts
|
|
437
|
+
var FLUSH_INTERVAL_MS = 1e4;
|
|
438
|
+
async function readJson(path) {
|
|
439
|
+
try {
|
|
440
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
441
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function mcpServerNames(input) {
|
|
447
|
+
try {
|
|
448
|
+
const res = await input.client?.config?.get?.();
|
|
449
|
+
const config = res && typeof res === "object" && "data" in res ? res.data : res;
|
|
450
|
+
const mcp = config?.mcp;
|
|
451
|
+
if (!mcp || typeof mcp !== "object") return [];
|
|
452
|
+
const entries = mcp;
|
|
453
|
+
const servers = entries.servers && typeof entries.servers === "object" ? Object.keys(entries.servers) : [];
|
|
454
|
+
const legacy = Object.keys(entries).filter((k) => k !== "servers" && k !== "timeout" && typeof entries[k] === "object");
|
|
455
|
+
return [.../* @__PURE__ */ new Set([...servers, ...legacy])];
|
|
456
|
+
} catch {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function start(mcpServers) {
|
|
461
|
+
const anyforgeDir = join(homedir(), ".anyforge");
|
|
462
|
+
const creds = await readJson(join(anyforgeDir, "credentials"));
|
|
463
|
+
const apiKey = typeof creds?.apiKey === "string" ? creds.apiKey : null;
|
|
464
|
+
const endpoint = typeof creds?.endpoint === "string" ? creds.endpoint.replace(/\/+$/, "") : null;
|
|
465
|
+
if (!apiKey || !endpoint) return null;
|
|
466
|
+
let anyforgeHost;
|
|
467
|
+
try {
|
|
468
|
+
anyforgeHost = new URL(endpoint).host;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const settings = await readJson(join(anyforgeDir, "opencode-telemetry.json"));
|
|
473
|
+
const telemetry = new OpencodeTelemetry({ sendNames: settings?.sendNames === true, mcpServers, anyforgeHost });
|
|
474
|
+
const exporter = new TelemetryExporter({
|
|
475
|
+
endpoint,
|
|
476
|
+
apiKey,
|
|
477
|
+
fetch: (async (url, init) => ({ status: (await fetch(url, init)).status })),
|
|
478
|
+
resource: [{ key: "service.name", value: { stringValue: "opencode" } }]
|
|
479
|
+
});
|
|
480
|
+
const timer = setInterval(() => {
|
|
481
|
+
void exporter.flush();
|
|
482
|
+
}, FLUSH_INTERVAL_MS);
|
|
483
|
+
timer.unref?.();
|
|
484
|
+
return {
|
|
485
|
+
telemetry,
|
|
486
|
+
exporter,
|
|
487
|
+
take: (t) => {
|
|
488
|
+
exporter.addLogs(t.logs);
|
|
489
|
+
exporter.addUsage(t.usage);
|
|
490
|
+
},
|
|
491
|
+
stop: async () => {
|
|
492
|
+
clearInterval(timer);
|
|
493
|
+
await exporter.flush();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
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;
|
|
565
|
+
export {
|
|
566
|
+
plugin_default as default
|
|
567
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyforge/control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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",
|