@beryl-so/cli 0.27.0 → 0.29.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.
- package/README.md +11 -0
- package/dist/adapters/cli.js +2 -1
- package/dist/adapters/mcp.js +66 -3
- package/dist/commands/health.js +43 -0
- package/dist/http.js +6 -1
- package/dist/registry/index.js +2 -0
- package/dist/telemetry.js +272 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -245,6 +245,17 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
|
|
|
245
245
|
| `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk | `runs_download` |
|
|
246
246
|
| `beryl runs explain <result-id>` | Explain, with AI, why a test result failed | `runs_explain` |
|
|
247
247
|
|
|
248
|
+
### health
|
|
249
|
+
|
|
250
|
+
Site Health — how your site reads to search engines and visitors: content, speed, mobile, links and security, graded from a real check of your live pages.
|
|
251
|
+
|
|
252
|
+
`beryl health` with no subcommand runs `health get`.
|
|
253
|
+
|
|
254
|
+
| Command | Summary | MCP tool |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| `beryl health get` | Show the latest Site Health report for a project environment | `health_get` |
|
|
257
|
+
| `beryl health run` | Run a fresh Site Health check for a project environment | `health_run` |
|
|
258
|
+
|
|
248
259
|
### explorations
|
|
249
260
|
|
|
250
261
|
Inspect the agent's exploration runs — how it crawled a site and authored its tests.
|
package/dist/adapters/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
|
|
|
4
4
|
import { ApiClient } from "../http.js";
|
|
5
5
|
import { autoFormat, bold, cyan, dim } from "../output.js";
|
|
6
6
|
import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
|
|
7
|
+
import { withTool } from "../telemetry.js";
|
|
7
8
|
import { cliVersion } from "../version-check.js";
|
|
8
9
|
import { mcpToolFor } from "./mcp.js";
|
|
9
10
|
export const GLOBAL_FLAGS = [
|
|
@@ -324,7 +325,7 @@ export async function runCli(argv) {
|
|
|
324
325
|
const client = new ApiClient(config.apiUrl, config.token);
|
|
325
326
|
const ctx = createContext({ client, config, json: parsed.json, mcp: false });
|
|
326
327
|
try {
|
|
327
|
-
const result = (await spec.run(ctx, parsed.input)) ?? {};
|
|
328
|
+
const result = (await withTool(spec.name, () => spec.run(ctx, parsed.input))) ?? {};
|
|
328
329
|
if (parsed.json) {
|
|
329
330
|
if (result.data !== undefined)
|
|
330
331
|
process.stdout.write(JSON.stringify(result.data) + "\n");
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { instrument } from "@posthog/mcp";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { loadConfig } from "../config.js";
|
|
6
7
|
import { createContext } from "../context.js";
|
|
7
|
-
import { CliError } from "../errors.js";
|
|
8
|
-
import { ApiClient } from "../http.js";
|
|
8
|
+
import { AuthError, CliError } from "../errors.js";
|
|
9
|
+
import { ApiClient, ApiError } from "../http.js";
|
|
9
10
|
import { parseArgv } from "./cli.js";
|
|
10
11
|
import { commands } from "../registry/index.js";
|
|
12
|
+
import { analyticsOptions, createPostHogClient, setClient, setSurface, shutdownTelemetry, withTool, } from "../telemetry.js";
|
|
11
13
|
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
12
14
|
export function toolName(spec) {
|
|
13
15
|
return spec.name.replace(/ /g, "_").replace(/-/g, "_");
|
|
@@ -190,6 +192,48 @@ export function currentAuth(fallback) {
|
|
|
190
192
|
}
|
|
191
193
|
export function __resetAuthCacheForTests() {
|
|
192
194
|
authCache = undefined;
|
|
195
|
+
identifyCache = undefined;
|
|
196
|
+
}
|
|
197
|
+
let identifyCache;
|
|
198
|
+
/** Resolves the signed-in account once per token so MCP events land on the same PostHog
|
|
199
|
+
* person as that user's webapp sessions. Anonymous (null) when unauthenticated: the
|
|
200
|
+
* session is still counted, just not attributed. */
|
|
201
|
+
export function identifyUser(baseCtx) {
|
|
202
|
+
return async () => {
|
|
203
|
+
const auth = currentAuth(baseCtx);
|
|
204
|
+
const cached = identifyCache;
|
|
205
|
+
if (cached && cached.token === auth.config.token)
|
|
206
|
+
return cached.identity;
|
|
207
|
+
let identity = null;
|
|
208
|
+
if (auth.config.token) {
|
|
209
|
+
try {
|
|
210
|
+
const me = (await auth.client.get("/account/"));
|
|
211
|
+
if (me?.id) {
|
|
212
|
+
identity = {
|
|
213
|
+
distinctId: me.id,
|
|
214
|
+
// Internal dogfooding is stamped rather than dropped: unlike the webapp we
|
|
215
|
+
// want our own MCP sessions visible when debugging, so dashboards exclude
|
|
216
|
+
// them by cohort instead.
|
|
217
|
+
properties: {
|
|
218
|
+
email: me.email,
|
|
219
|
+
name: me.name,
|
|
220
|
+
is_internal: Boolean(me.is_vibemonitor),
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// A rejected token is a settled answer worth caching; a network blip or a 5xx is
|
|
227
|
+
// not. This server outlives the blip, so caching one would leave every later tool
|
|
228
|
+
// call in the session anonymous with no way back.
|
|
229
|
+
const settled = err instanceof AuthError || (err instanceof ApiError && err.status < 500);
|
|
230
|
+
if (!settled)
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
identifyCache = { token: auth.config.token, identity };
|
|
235
|
+
return identity;
|
|
236
|
+
};
|
|
193
237
|
}
|
|
194
238
|
// The running version is stated up front because this server is long-lived and never
|
|
195
239
|
// hot-reloads: a session can sit on a days-old build while `@latest` has moved, and
|
|
@@ -210,6 +254,7 @@ export function mcpInstructions() {
|
|
|
210
254
|
"call `guide` before authoring your first test plan.");
|
|
211
255
|
}
|
|
212
256
|
export async function serveMcp(baseCtx) {
|
|
257
|
+
setSurface("mcp");
|
|
213
258
|
// Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
|
|
214
259
|
// tools, and stderr is the one channel a stdio MCP server can safely log to.
|
|
215
260
|
void warnIfStale(cliVersion(), (msg) => console.error(msg));
|
|
@@ -250,7 +295,8 @@ export async function serveMcp(baseCtx) {
|
|
|
250
295
|
err: push,
|
|
251
296
|
});
|
|
252
297
|
try {
|
|
253
|
-
const
|
|
298
|
+
const input = toInput(spec, request.params.arguments ?? {});
|
|
299
|
+
const result = (await withTool(request.params.name, () => spec.run(ctx, input))) ?? {};
|
|
254
300
|
return toolResult(result, lines);
|
|
255
301
|
}
|
|
256
302
|
catch (err) {
|
|
@@ -261,8 +307,25 @@ export async function serveMcp(baseCtx) {
|
|
|
261
307
|
};
|
|
262
308
|
}
|
|
263
309
|
});
|
|
310
|
+
server.oninitialized = () => setClient(server.getClientVersion());
|
|
311
|
+
instrument(server, createPostHogClient(), analyticsOptions(baseCtx.config.apiUrl, identifyUser(baseCtx)));
|
|
312
|
+
// A coding agent ends an MCP server either by closing the pipe or by signalling it, and
|
|
313
|
+
// only the first path reaches onclose — without these the last batch of events dies with
|
|
314
|
+
// the process. Bounded inside shutdownTelemetry, so a wedged flush can't hold up exit.
|
|
315
|
+
const flushAndExit = (signal) => {
|
|
316
|
+
void shutdownTelemetry().then(() => {
|
|
317
|
+
process.kill(process.pid, signal);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
321
|
+
process.once(signal, () => {
|
|
322
|
+
process.removeAllListeners(signal);
|
|
323
|
+
flushAndExit(signal);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
264
326
|
await server.connect(new StdioServerTransport());
|
|
265
327
|
await new Promise((resolve) => {
|
|
266
328
|
server.onclose = resolve;
|
|
267
329
|
});
|
|
330
|
+
await shutdownTelemetry();
|
|
268
331
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { flagStr, projectPath } from "./util.js";
|
|
2
|
+
const ENV_FLAG = {
|
|
3
|
+
name: "env",
|
|
4
|
+
type: "string",
|
|
5
|
+
description: "Environment id (defaults to the project's default environment)",
|
|
6
|
+
};
|
|
7
|
+
export const healthCommands = [
|
|
8
|
+
{
|
|
9
|
+
name: "health get",
|
|
10
|
+
groupDefault: true,
|
|
11
|
+
groupSummary: "Site Health — how your site reads to search engines and visitors: content, " +
|
|
12
|
+
"speed, mobile, links and security, graded from a real check of your live pages.",
|
|
13
|
+
summary: "Show the latest Site Health report for a project environment",
|
|
14
|
+
description: "A check runs automatically when a project or environment gets its URL. While one " +
|
|
15
|
+
"is in flight the report comes back with status queued/running and no grades yet; " +
|
|
16
|
+
"call again to pick up the finished result.",
|
|
17
|
+
scope: "project",
|
|
18
|
+
flags: [ENV_FLAG],
|
|
19
|
+
async run(ctx, input) {
|
|
20
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
21
|
+
return {
|
|
22
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/seo-report`, {
|
|
23
|
+
environment_id: flagStr(input, "env"),
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "health run",
|
|
30
|
+
summary: "Run a fresh Site Health check for a project environment",
|
|
31
|
+
description: "Queues a new check and returns immediately. Returns the in-flight report instead " +
|
|
32
|
+
"of stacking a second one when a check is already running.",
|
|
33
|
+
scope: "project",
|
|
34
|
+
flags: [ENV_FLAG],
|
|
35
|
+
examples: ["beryl health run", "beryl health run --env 4f…"],
|
|
36
|
+
async run(ctx, input) {
|
|
37
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
38
|
+
return {
|
|
39
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/seo-report`, undefined, { environment_id: flagStr(input, "env") }),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
package/dist/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthError, CliError } from "./errors.js";
|
|
2
|
+
import { attributionHeaders } from "./telemetry.js";
|
|
2
3
|
export const API_PREFIX = "/api/v1";
|
|
3
4
|
export class ApiError extends CliError {
|
|
4
5
|
status;
|
|
@@ -29,7 +30,11 @@ export class ApiClient {
|
|
|
29
30
|
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
|
|
30
31
|
}
|
|
31
32
|
async request(method, path, opts = {}) {
|
|
32
|
-
const headers = {
|
|
33
|
+
const headers = {
|
|
34
|
+
...attributionHeaders(),
|
|
35
|
+
...this.authHeaders(),
|
|
36
|
+
...opts.headers,
|
|
37
|
+
};
|
|
33
38
|
let body;
|
|
34
39
|
if (opts.form) {
|
|
35
40
|
body = opts.form;
|
package/dist/registry/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { authCommands } from "../commands/auth.js";
|
|
|
4
4
|
import { configCommands } from "../commands/config-vars.js";
|
|
5
5
|
import { environmentCommands } from "../commands/environments.js";
|
|
6
6
|
import { explorationCommands } from "../commands/explorations.js";
|
|
7
|
+
import { healthCommands } from "../commands/health.js";
|
|
7
8
|
import { mailboxCommands } from "../commands/mailboxes.js";
|
|
8
9
|
import { initCommands } from "../commands/init.js";
|
|
9
10
|
import { mcpCommands } from "../commands/mcp.js";
|
|
@@ -57,6 +58,7 @@ export const commands = [
|
|
|
57
58
|
...environmentCommands,
|
|
58
59
|
...testCommands,
|
|
59
60
|
...runCommands,
|
|
61
|
+
...healthCommands,
|
|
60
62
|
...explorationCommands,
|
|
61
63
|
...configCommands,
|
|
62
64
|
...mailboxCommands,
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { PostHog } from "posthog-node";
|
|
4
|
+
import { cliVersion } from "./version-check.js";
|
|
5
|
+
// The webapp already ships this public (write-only) project key in its JS bundle, and
|
|
6
|
+
// dev/prod deploys share it — environments are separated by the `environment` property
|
|
7
|
+
// on every event, not by project. Nothing here is configurable on a customer machine.
|
|
8
|
+
const POSTHOG_KEY = "phc_JRs3q08uFEuLKGR6llGAEBpwAuVHtPoEXAj9IcFFhRH";
|
|
9
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
10
|
+
// One process = one session, for both surfaces: a CLI invocation is a one-shot session,
|
|
11
|
+
// an MCP server lives as long as the coding agent keeps it spawned.
|
|
12
|
+
export const sessionId = randomUUID();
|
|
13
|
+
let surface = "cli";
|
|
14
|
+
let client;
|
|
15
|
+
export function setSurface(value) {
|
|
16
|
+
surface = value;
|
|
17
|
+
}
|
|
18
|
+
export function setClient(value) {
|
|
19
|
+
client = value;
|
|
20
|
+
}
|
|
21
|
+
export function currentSurface() {
|
|
22
|
+
return surface;
|
|
23
|
+
}
|
|
24
|
+
export function currentClient() {
|
|
25
|
+
return client;
|
|
26
|
+
}
|
|
27
|
+
// The MCP SDK dispatches tool calls concurrently, so "which tool is running" has to be
|
|
28
|
+
// per-async-context, not a module global.
|
|
29
|
+
const toolStore = new AsyncLocalStorage();
|
|
30
|
+
export function withTool(tool, fn) {
|
|
31
|
+
return toolStore.run(tool, fn);
|
|
32
|
+
}
|
|
33
|
+
export function currentTool() {
|
|
34
|
+
return toolStore.getStore();
|
|
35
|
+
}
|
|
36
|
+
export function userAgent() {
|
|
37
|
+
const parts = [surface];
|
|
38
|
+
if (client)
|
|
39
|
+
parts.push(client.version ? `${client.name}/${client.version}` : client.name);
|
|
40
|
+
parts.push(`${process.platform} ${process.arch}`, `node/${process.versions.node}`);
|
|
41
|
+
return `beryl-cli/${cliVersion()} (${parts.join("; ")})`;
|
|
42
|
+
}
|
|
43
|
+
export function attributionHeaders() {
|
|
44
|
+
const headers = {
|
|
45
|
+
"User-Agent": userAgent(),
|
|
46
|
+
"X-Beryl-Session": sessionId,
|
|
47
|
+
};
|
|
48
|
+
const tool = currentTool();
|
|
49
|
+
if (tool)
|
|
50
|
+
headers["X-Beryl-Tool"] = tool;
|
|
51
|
+
return headers;
|
|
52
|
+
}
|
|
53
|
+
export function analyticsEnvironment(apiUrl) {
|
|
54
|
+
let host;
|
|
55
|
+
try {
|
|
56
|
+
host = new URL(apiUrl).hostname;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "local";
|
|
60
|
+
}
|
|
61
|
+
if (host === "api.beryl.so")
|
|
62
|
+
return "prod";
|
|
63
|
+
if (host === "dev.beryl.so")
|
|
64
|
+
return "dev";
|
|
65
|
+
return "local";
|
|
66
|
+
}
|
|
67
|
+
// Values kept verbatim: ids, references, enum choices and other low-cardinality
|
|
68
|
+
// selectors that make the Sessions view readable. Everything else is reduced to a
|
|
69
|
+
// shape, so free-text arguments (plans, credentials, mail bodies) never leave the
|
|
70
|
+
// machine. Keys are the MCP tool's parameter names, hyphens and all.
|
|
71
|
+
const KEEP_VALUE_KEYS = new Set([
|
|
72
|
+
"account",
|
|
73
|
+
"auth",
|
|
74
|
+
"env",
|
|
75
|
+
"env-id",
|
|
76
|
+
"environment",
|
|
77
|
+
"format",
|
|
78
|
+
"frequency",
|
|
79
|
+
"id",
|
|
80
|
+
"limit",
|
|
81
|
+
"login-method",
|
|
82
|
+
"mailbox",
|
|
83
|
+
"name",
|
|
84
|
+
"project",
|
|
85
|
+
"role",
|
|
86
|
+
"run",
|
|
87
|
+
"scope",
|
|
88
|
+
"status",
|
|
89
|
+
"test",
|
|
90
|
+
"type",
|
|
91
|
+
"url",
|
|
92
|
+
"workspace",
|
|
93
|
+
]);
|
|
94
|
+
// Catch-all for keys we never enumerated. Deliberately a loose substring match, which
|
|
95
|
+
// makes it fire on innocent names too (`login-method`, `--force-new-login`, `--otp`) —
|
|
96
|
+
// so the allowlist above and booleans, both settled decisions, are checked first, and
|
|
97
|
+
// this only decides the keys nobody classified. It still guards a real `--otp 123456`,
|
|
98
|
+
// because numbers are kept only after it has had its say.
|
|
99
|
+
const SECRET_KEY = /pass|secret|token|key|otp|code|credential|login|value|blob|cookie|email/i;
|
|
100
|
+
const PAT_PREFIX = "beryl_pat_";
|
|
101
|
+
const REDACTED = "<redacted>";
|
|
102
|
+
function shapeOf(value) {
|
|
103
|
+
if (typeof value === "string")
|
|
104
|
+
return `<string:${value.length}>`;
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return `<array:${value.length}>`;
|
|
107
|
+
if (typeof value === "object")
|
|
108
|
+
return "<object>";
|
|
109
|
+
return `<${typeof value}>`;
|
|
110
|
+
}
|
|
111
|
+
function containsSecret(value) {
|
|
112
|
+
if (typeof value === "string")
|
|
113
|
+
return value.includes(PAT_PREFIX);
|
|
114
|
+
if (Array.isArray(value))
|
|
115
|
+
return value.some(containsSecret);
|
|
116
|
+
if (value && typeof value === "object")
|
|
117
|
+
return Object.values(value).some(containsSecret);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
export function redactArguments(args) {
|
|
124
|
+
if (!isRecord(args))
|
|
125
|
+
return shapeOf(args);
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const [key, value] of Object.entries(args)) {
|
|
128
|
+
if (value === null || value === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
if (containsSecret(value)) {
|
|
131
|
+
out[key] = REDACTED;
|
|
132
|
+
}
|
|
133
|
+
else if (KEEP_VALUE_KEYS.has(key) && typeof value === "string") {
|
|
134
|
+
out[key] = value;
|
|
135
|
+
}
|
|
136
|
+
else if (typeof value === "boolean") {
|
|
137
|
+
// true/false cannot carry a credential, whatever the key is called.
|
|
138
|
+
out[key] = value;
|
|
139
|
+
}
|
|
140
|
+
else if (SECRET_KEY.test(key)) {
|
|
141
|
+
out[key] = shapeOf(value);
|
|
142
|
+
}
|
|
143
|
+
else if (typeof value === "number") {
|
|
144
|
+
out[key] = value;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out[key] = shapeOf(value);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
// The SDK reports parameters as the JSON-RPC envelope — {request:{id,jsonrpc,method,
|
|
153
|
+
// params:{name,arguments,_meta}}} — so the caller's values live at request.params.arguments.
|
|
154
|
+
// An envelope we don't recognise is treated as arguments wholesale rather than passed
|
|
155
|
+
// through: an unfamiliar shape must fail closed, not leak.
|
|
156
|
+
//
|
|
157
|
+
// `_meta` gets the same treatment as arguments. It is the protocol's open extension point,
|
|
158
|
+
// so its contents are whatever the connected client decided to put there (today, Claude
|
|
159
|
+
// Code's tool-use and progress ids) — unknown by definition, and not ours to forward.
|
|
160
|
+
export function redactCapturedParameters(captured) {
|
|
161
|
+
if (!isRecord(captured))
|
|
162
|
+
return shapeOf(captured);
|
|
163
|
+
const request = captured.request;
|
|
164
|
+
const params = isRecord(request) ? request.params : undefined;
|
|
165
|
+
if (!isRecord(request) || !isRecord(params))
|
|
166
|
+
return redactArguments(captured);
|
|
167
|
+
const redacted = { ...params };
|
|
168
|
+
for (const key of ["arguments", "_meta"]) {
|
|
169
|
+
if (params[key] !== undefined)
|
|
170
|
+
redacted[key] = redactArguments(params[key]);
|
|
171
|
+
}
|
|
172
|
+
return { ...captured, request: { ...request, params: redacted } };
|
|
173
|
+
}
|
|
174
|
+
// How much a tool answered with, never what. Reads lengths off strings the SDK already
|
|
175
|
+
// built — constant time whatever the payload — so this must not serialize the result.
|
|
176
|
+
export function responseShape(response) {
|
|
177
|
+
if (!isRecord(response))
|
|
178
|
+
return undefined;
|
|
179
|
+
const content = response.content;
|
|
180
|
+
if (!Array.isArray(content))
|
|
181
|
+
return undefined;
|
|
182
|
+
let bytes = 0;
|
|
183
|
+
for (const part of content) {
|
|
184
|
+
if (!isRecord(part))
|
|
185
|
+
continue;
|
|
186
|
+
// text / image+audio / embedded resource, whose payload hangs one level deeper.
|
|
187
|
+
const resource = isRecord(part.resource) ? part.resource : undefined;
|
|
188
|
+
const payload = part.text ?? part.data ?? resource?.text ?? resource?.blob;
|
|
189
|
+
if (typeof payload === "string")
|
|
190
|
+
bytes += payload.length;
|
|
191
|
+
}
|
|
192
|
+
return { response_parts: content.length, response_bytes: bytes };
|
|
193
|
+
}
|
|
194
|
+
// Agent-written free text: it is told not to include secrets, but that is a prompt, not a
|
|
195
|
+
// guarantee — so it gets the same PAT check as any argument, and a hard length cap.
|
|
196
|
+
const MAX_INTENT = 500;
|
|
197
|
+
function redactIntent(intent) {
|
|
198
|
+
if (typeof intent !== "string")
|
|
199
|
+
return undefined;
|
|
200
|
+
if (intent.includes(PAT_PREFIX))
|
|
201
|
+
return REDACTED;
|
|
202
|
+
return intent.length > MAX_INTENT ? intent.slice(0, MAX_INTENT) : intent;
|
|
203
|
+
}
|
|
204
|
+
// Tool responses carry plans, mailbox contents and run artifacts wholesale — there is no
|
|
205
|
+
// key-level policy that makes them safe, so only their size survives.
|
|
206
|
+
export const beforeSend = (event) => {
|
|
207
|
+
const props = event.properties;
|
|
208
|
+
Object.assign(props, responseShape(props.$mcp_response));
|
|
209
|
+
delete props.$mcp_response;
|
|
210
|
+
if (props.$mcp_parameters !== undefined) {
|
|
211
|
+
props.$mcp_parameters = redactCapturedParameters(props.$mcp_parameters);
|
|
212
|
+
}
|
|
213
|
+
if (props.$mcp_intent !== undefined) {
|
|
214
|
+
props.$mcp_intent = redactIntent(props.$mcp_intent);
|
|
215
|
+
}
|
|
216
|
+
return event;
|
|
217
|
+
};
|
|
218
|
+
export function baseEventProperties(apiUrl) {
|
|
219
|
+
return {
|
|
220
|
+
environment: analyticsEnvironment(apiUrl),
|
|
221
|
+
cli_version: cliVersion(),
|
|
222
|
+
os: process.platform,
|
|
223
|
+
arch: process.arch,
|
|
224
|
+
node_version: process.versions.node,
|
|
225
|
+
api_url: apiUrl,
|
|
226
|
+
// Same uuid as the X-Beryl-Session header, so a PostHog session and the Sentry tags
|
|
227
|
+
// on the API requests it caused describe the same thing.
|
|
228
|
+
beryl_session_id: sessionId,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
let posthog;
|
|
232
|
+
export function createPostHogClient() {
|
|
233
|
+
posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
|
|
234
|
+
return posthog;
|
|
235
|
+
}
|
|
236
|
+
export function analyticsOptions(apiUrl, identify) {
|
|
237
|
+
const properties = baseEventProperties(apiUrl);
|
|
238
|
+
return {
|
|
239
|
+
identify,
|
|
240
|
+
beforeSend,
|
|
241
|
+
eventProperties: () => properties,
|
|
242
|
+
// Worth its cost: without the agent's own reason for a call, a session reads as a list
|
|
243
|
+
// of tool names and you cannot tell deliberate work from a loop. The description is
|
|
244
|
+
// ours and deliberately terse — the SDK's default one is ~4x longer, and every word is
|
|
245
|
+
// paid for on all 86 advertised tools on every request (measured: +12.8k tokens vs +3k).
|
|
246
|
+
context: {
|
|
247
|
+
description: "Why this call is being made and how it serves the user's goal, in under 15 words. " +
|
|
248
|
+
"Third person. Never include credentials, tokens or personal data.",
|
|
249
|
+
},
|
|
250
|
+
// Would inject a second parameter into every tool to survive reconnects. A stdio
|
|
251
|
+
// process is already one session, and a user's work is stitched by distinct_id
|
|
252
|
+
// anyway — not worth another schema rewrite for tidier grouping.
|
|
253
|
+
enableConversationId: false,
|
|
254
|
+
reportMissing: false,
|
|
255
|
+
// Tool failures are already returned to the agent as isError and reported by the
|
|
256
|
+
// API's own Sentry; a second $exception stream would double-count them.
|
|
257
|
+
enableExceptionAutocapture: false,
|
|
258
|
+
logger: (message) => process.stderr.write(`[beryl telemetry] ${message}\n`),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export async function shutdownTelemetry() {
|
|
262
|
+
if (!posthog)
|
|
263
|
+
return;
|
|
264
|
+
const client = posthog;
|
|
265
|
+
posthog = undefined;
|
|
266
|
+
try {
|
|
267
|
+
await client.shutdown(2000);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Telemetry must never delay or fail the process it is observing.
|
|
271
|
+
}
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"docs": "tsx scripts/gen-docs.ts"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
+
"@posthog/mcp": "^0.11.6",
|
|
36
|
+
"posthog-node": "^5.49.1"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@playwright/test": "^1.61.1",
|