operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +42 -0
- package/dist/app-server/client.js +8 -5
- package/dist/app-server/main.js +1 -1
- package/dist/app-server/params.d.ts +115 -0
- package/dist/app-server/params.js +81 -0
- package/dist/app-server/protocol.d.ts +10 -4
- package/dist/app-server/protocol.js +45 -37
- package/dist/app-server/server.d.ts +0 -1
- package/dist/app-server/server.js +46 -35
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/cron/cron-expr.js +1 -1
- package/dist/cron/extension.js +2 -2
- package/dist/extensions/index.d.ts +19 -5
- package/dist/extensions/index.js +66 -10
- package/dist/extensions/loader.d.ts +2 -2
- package/dist/extensions/loader.js +23 -15
- package/dist/extensions/manager.d.ts +15 -13
- package/dist/extensions/manager.js +28 -25
- package/dist/extensions/runtime.d.ts +69 -74
- package/dist/extensions/runtime.js +183 -381
- package/dist/extensions/types.d.ts +55 -45
- package/dist/harness.d.ts +339 -180
- package/dist/harness.js +673 -420
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.d.ts +25 -8
- package/dist/local.js +103 -33
- package/dist/providers.d.ts +144 -0
- package/dist/providers.js +280 -0
- package/dist/telemetry-posthog.d.ts +88 -0
- package/dist/telemetry-posthog.js +142 -0
- package/dist/telemetry.d.ts +4 -0
- package/dist/telemetry.js +3 -0
- package/dist/workspace-mcp.d.ts +62 -0
- package/dist/workspace-mcp.js +222 -0
- package/package.json +10 -3
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model providers configured by the host, as opposed to the ones the engine ships with.
|
|
3
|
+
*
|
|
4
|
+
* The built-in catalog knows every major provider, but each of those is a fixed list at a fixed
|
|
5
|
+
* URL. A self-hosted endpoint — vLLM, Ollama, LiteLLM, a gateway, a proxy in front of a vendor —
|
|
6
|
+
* is neither: its address is local knowledge, and which models it serves is a question only that
|
|
7
|
+
* endpoint can answer. So a provider here is a URL plus how to authenticate, and its model list is
|
|
8
|
+
* fetched from the endpoint (`GET {baseUrl}/models`, the OpenAI-compatible shape every one of them
|
|
9
|
+
* speaks) rather than declared.
|
|
10
|
+
*
|
|
11
|
+
* The file is `<homeDir>/providers.toml`, so every local host — the TUI, the app server, a script
|
|
12
|
+
* that builds its own harness — sees the same providers without each inventing a config format.
|
|
13
|
+
* Nothing else in the framework reads it: this is the provider registry, not a general config.
|
|
14
|
+
*
|
|
15
|
+
* ```toml
|
|
16
|
+
* # A self-hosted endpoint. Models come from GET http://localhost:8000/v1/models.
|
|
17
|
+
* [providers.local]
|
|
18
|
+
* base_url = "http://localhost:8000/v1"
|
|
19
|
+
* api_key_env = "LOCAL_API_KEY" # optional; omit for a keyless server
|
|
20
|
+
* context_window = 128000 # the default applied to every model it reports
|
|
21
|
+
*
|
|
22
|
+
* # Per-model facts the endpoint does not report.
|
|
23
|
+
* [providers.local.models."qwen3-coder"]
|
|
24
|
+
* name = "Qwen3 Coder"
|
|
25
|
+
* context_window = 256000
|
|
26
|
+
* reasoning = true
|
|
27
|
+
*
|
|
28
|
+
* # A provider whose models are declared rather than fetched.
|
|
29
|
+
* [providers.corp]
|
|
30
|
+
* base_url = "https://llm.corp.internal/v1"
|
|
31
|
+
* api_key_env = "CORP_API_KEY"
|
|
32
|
+
* fetch_models = false
|
|
33
|
+
* [providers.corp.models."gpt-4o-mini"]
|
|
34
|
+
* context_window = 128000
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import { readFile } from "node:fs/promises";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import { parse as parseToml } from "smol-toml";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
import { createModelRuntime, createProvider, lazyApi, } from "operon-agents-core";
|
|
42
|
+
/** The wire dialects a configured endpoint can speak. Almost everything speaks the first. */
|
|
43
|
+
export const PROVIDER_APIS = ["openai-completions", "openai-responses", "anthropic-messages"];
|
|
44
|
+
const ModelOverrideSchema = z.object({
|
|
45
|
+
name: z.string().optional(),
|
|
46
|
+
context_window: z.number().int().positive().optional(),
|
|
47
|
+
max_output_tokens: z.number().int().positive().optional(),
|
|
48
|
+
reasoning: z.boolean().optional(),
|
|
49
|
+
/** Whether the model accepts image input alongside text. */
|
|
50
|
+
vision: z.boolean().optional(),
|
|
51
|
+
api: z.enum(PROVIDER_APIS).optional(),
|
|
52
|
+
});
|
|
53
|
+
const ProviderEntrySchema = z.object({
|
|
54
|
+
base_url: z.string().min(1),
|
|
55
|
+
/** Display name for pickers; defaults to the provider id. */
|
|
56
|
+
name: z.string().optional(),
|
|
57
|
+
api: z.enum(PROVIDER_APIS).default("openai-completions"),
|
|
58
|
+
/** Environment variable holding the key. Preferred over `api_key`, which lands in the file. */
|
|
59
|
+
api_key_env: z.string().optional(),
|
|
60
|
+
api_key: z.string().optional(),
|
|
61
|
+
/** Ask the endpoint what it serves. Set false to use only the declared `models`. */
|
|
62
|
+
fetch_models: z.boolean().default(true),
|
|
63
|
+
/** Defaults applied to every model this provider reports. */
|
|
64
|
+
context_window: z.number().int().positive().default(128_000),
|
|
65
|
+
max_output_tokens: z.number().int().positive().default(8_192),
|
|
66
|
+
reasoning: z.boolean().default(false),
|
|
67
|
+
vision: z.boolean().default(false),
|
|
68
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
69
|
+
/** Per-model facts, keyed by the id the endpoint reports. */
|
|
70
|
+
models: z.record(z.string(), ModelOverrideSchema).default({}),
|
|
71
|
+
});
|
|
72
|
+
export const ProvidersFileSchema = z.object({
|
|
73
|
+
providers: z.record(z.string(), ProviderEntrySchema).default({}),
|
|
74
|
+
});
|
|
75
|
+
export function providersConfigPath(homeDir) {
|
|
76
|
+
return join(homeDir, "providers.toml");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read `<homeDir>/providers.toml`. A missing file is the normal case and yields no providers; a
|
|
80
|
+
* malformed one yields none plus a warning, because refusing to start over a config file the host
|
|
81
|
+
* may not even use would be worse than running with the built-in providers alone.
|
|
82
|
+
*/
|
|
83
|
+
export async function loadProviderConfigs(homeDir) {
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
text = await readFile(providersConfigPath(homeDir), "utf-8");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { providers: {}, warnings: [] };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const parsed = ProvidersFileSchema.parse(parseToml(text));
|
|
93
|
+
return { providers: parsed.providers, warnings: [] };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
providers: {},
|
|
98
|
+
warnings: [`Ignoring ${providersConfigPath(homeDir)}: ${error instanceof Error ? error.message : String(error)}`],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What a keyless endpoint sends as its bearer token. The engine's OpenAI dialect refuses to build a
|
|
104
|
+
* client without one, and a local server that wants no key ignores whatever arrives; pi uses the
|
|
105
|
+
* same literal for the same reason.
|
|
106
|
+
*/
|
|
107
|
+
const NO_KEY_PLACEHOLDER = "unused";
|
|
108
|
+
/** The key this provider authenticates with, or undefined when its env var is declared but unset. */
|
|
109
|
+
function configuredKey(entry) {
|
|
110
|
+
if (entry.api_key !== undefined)
|
|
111
|
+
return entry.api_key;
|
|
112
|
+
if (entry.api_key_env !== undefined)
|
|
113
|
+
return process.env[entry.api_key_env];
|
|
114
|
+
return NO_KEY_PLACEHOLDER;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Auth for an endpoint described by a config file rather than by the engine.
|
|
118
|
+
*
|
|
119
|
+
* The built-in `envApiKeyAuth` resolves from environment variables only, which makes a keyless
|
|
120
|
+
* local server permanently unconfigured — and an unconfigured provider is one the engine never
|
|
121
|
+
* refreshes and never lists, so its models would not exist. Here the config is the credential: an
|
|
122
|
+
* inline key, the named environment variable, or, for a server that wants none, the address alone.
|
|
123
|
+
*/
|
|
124
|
+
function configAuth(providerId, entry) {
|
|
125
|
+
return {
|
|
126
|
+
name: `${entry.name ?? providerId} API key`,
|
|
127
|
+
resolve: async ({ ctx, credential, signal }) => {
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
if (credential?.key !== undefined && credential.key.length > 0) {
|
|
130
|
+
return { auth: { apiKey: credential.key }, source: "stored credential" };
|
|
131
|
+
}
|
|
132
|
+
if (entry.api_key !== undefined) {
|
|
133
|
+
return { auth: { apiKey: entry.api_key }, source: "providers.toml" };
|
|
134
|
+
}
|
|
135
|
+
if (entry.api_key_env !== undefined) {
|
|
136
|
+
const value = await ctx.env(entry.api_key_env);
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
// A declared variable that is not set means "configured, but not usable yet" — reporting it
|
|
139
|
+
// as configured would only move the failure to the first request.
|
|
140
|
+
return value ? { auth: { apiKey: value }, source: entry.api_key_env } : undefined;
|
|
141
|
+
}
|
|
142
|
+
return { auth: { apiKey: NO_KEY_PLACEHOLDER }, source: entry.base_url };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Zero cost: a self-hosted endpoint has no per-token price, and a wrong number is worse than none. */
|
|
147
|
+
const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
148
|
+
function modelFrom(providerId, entry, modelId) {
|
|
149
|
+
const override = entry.models[modelId];
|
|
150
|
+
return {
|
|
151
|
+
id: modelId,
|
|
152
|
+
name: override?.name ?? modelId,
|
|
153
|
+
api: (override?.api ?? entry.api),
|
|
154
|
+
provider: providerId,
|
|
155
|
+
baseUrl: entry.base_url,
|
|
156
|
+
reasoning: override?.reasoning ?? entry.reasoning,
|
|
157
|
+
input: (override?.vision ?? entry.vision) ? ["text", "image"] : ["text"],
|
|
158
|
+
cost: NO_COST,
|
|
159
|
+
contextWindow: override?.context_window ?? entry.context_window,
|
|
160
|
+
maxTokens: override?.max_output_tokens ?? entry.max_output_tokens,
|
|
161
|
+
...(entry.headers !== undefined ? { headers: entry.headers } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** The model ids an OpenAI-compatible `/models` response lists, in the order the endpoint gave. */
|
|
165
|
+
export function parseModelsResponse(body) {
|
|
166
|
+
const data = body?.data;
|
|
167
|
+
if (!Array.isArray(data))
|
|
168
|
+
return [];
|
|
169
|
+
const ids = [];
|
|
170
|
+
for (const item of data) {
|
|
171
|
+
const id = item?.id;
|
|
172
|
+
if (typeof id === "string" && id.length > 0)
|
|
173
|
+
ids.push(id);
|
|
174
|
+
}
|
|
175
|
+
return ids;
|
|
176
|
+
}
|
|
177
|
+
function modelsUrl(baseUrl) {
|
|
178
|
+
return `${baseUrl.replace(/\/+$/, "")}/models`;
|
|
179
|
+
}
|
|
180
|
+
async function fetchEndpointModels(providerId, entry, context) {
|
|
181
|
+
const declared = Object.keys(entry.models).map((id) => modelFrom(providerId, entry, id));
|
|
182
|
+
if (!entry.fetch_models || !context.allowNetwork)
|
|
183
|
+
return declared;
|
|
184
|
+
const key = configuredKey(entry);
|
|
185
|
+
const response = await fetch(modelsUrl(entry.base_url), {
|
|
186
|
+
headers: {
|
|
187
|
+
...(key !== undefined ? { authorization: `Bearer ${key}` } : {}),
|
|
188
|
+
...entry.headers,
|
|
189
|
+
},
|
|
190
|
+
signal: context.signal,
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
throw new Error(`${modelsUrl(entry.base_url)} answered ${String(response.status)} ${response.statusText}`);
|
|
194
|
+
}
|
|
195
|
+
const ids = parseModelsResponse(await response.json());
|
|
196
|
+
// The endpoint is authoritative on what it serves; a declared model it does not list stays,
|
|
197
|
+
// because an endpoint that reports nothing (some proxies do) should not empty the picker.
|
|
198
|
+
const fetched = ids.map((id) => modelFrom(providerId, entry, id));
|
|
199
|
+
const seen = new Set(fetched.map((model) => model.id));
|
|
200
|
+
return [...fetched, ...declared.filter((model) => !seen.has(model.id))];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Turn one config entry into a provider the engine can stream through. Its model list starts at
|
|
204
|
+
* whatever the config declared and is replaced by the endpoint's on the first refresh.
|
|
205
|
+
*/
|
|
206
|
+
export function providerFromConfig(providerId, entry) {
|
|
207
|
+
return createProvider({
|
|
208
|
+
id: providerId,
|
|
209
|
+
name: entry.name ?? providerId,
|
|
210
|
+
baseUrl: entry.base_url,
|
|
211
|
+
...(entry.headers !== undefined ? { headers: entry.headers } : {}),
|
|
212
|
+
auth: { apiKey: configAuth(providerId, entry) },
|
|
213
|
+
models: Object.keys(entry.models).map((id) => modelFrom(providerId, entry, id)),
|
|
214
|
+
fetchModels: (context) => fetchEndpointModels(providerId, entry, context),
|
|
215
|
+
api: dialectStreams(entry.api),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** pi's API implementations load on first use, so an unused dialect is never imported. */
|
|
219
|
+
function dialectStreams(api) {
|
|
220
|
+
switch (api) {
|
|
221
|
+
case "anthropic-messages":
|
|
222
|
+
return lazyApi(() => import("@earendil-works/pi-ai/api/anthropic-messages"));
|
|
223
|
+
case "openai-responses":
|
|
224
|
+
return lazyApi(() => import("@earendil-works/pi-ai/api/openai-responses"));
|
|
225
|
+
case "openai-completions":
|
|
226
|
+
return lazyApi(() => import("@earendil-works/pi-ai/api/openai-completions"));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* One model runtime carrying the engine's built-in providers plus everything in
|
|
231
|
+
* `<homeDir>/providers.toml`, with each configured endpoint asked what it serves.
|
|
232
|
+
*
|
|
233
|
+
* Hand the same runtime to `defineModel({ provider, model, runtime })` and to
|
|
234
|
+
* `createLocalHarness({ modelRuntime })`, so the model a host resolves and the models its sessions
|
|
235
|
+
* can switch to come from one registry.
|
|
236
|
+
*/
|
|
237
|
+
export async function createModelRuntimeFromConfig(options) {
|
|
238
|
+
const runtime = createModelRuntime();
|
|
239
|
+
const { providers, warnings } = await loadProviderConfigs(options.homeDir);
|
|
240
|
+
for (const warning of warnings)
|
|
241
|
+
options.onWarning?.(warning);
|
|
242
|
+
const ids = [];
|
|
243
|
+
for (const [id, entry] of Object.entries(providers)) {
|
|
244
|
+
if (entry.api_key_env !== undefined && !process.env[entry.api_key_env]) {
|
|
245
|
+
options.onWarning?.(`Provider "${id}" reads its key from ${entry.api_key_env}, which is not set; its models will not be listed.`);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
runtime.models.setProvider(providerFromConfig(id, entry));
|
|
249
|
+
ids.push(id);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
options.onWarning?.(`Provider "${id}" was not registered: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (ids.length === 0 || options.refresh === false)
|
|
256
|
+
return runtime;
|
|
257
|
+
// An unreachable endpoint is reported, not fatal: the rest of the providers still work, and a
|
|
258
|
+
// model named directly still resolves from whatever the config declared.
|
|
259
|
+
const controller = new AbortController();
|
|
260
|
+
const timer = setTimeout(() => {
|
|
261
|
+
controller.abort();
|
|
262
|
+
}, options.refreshTimeoutMs ?? 5_000);
|
|
263
|
+
try {
|
|
264
|
+
const result = await runtime.models.refresh({ providers: ids, signal: controller.signal });
|
|
265
|
+
for (const [providerId, error] of result.errors) {
|
|
266
|
+
options.onWarning?.(`Could not read models from provider "${providerId}": ${error.message}`);
|
|
267
|
+
}
|
|
268
|
+
if (result.aborted) {
|
|
269
|
+
options.onWarning?.(`Reading models from configured providers timed out after ${String(options.refreshTimeoutMs ?? 5_000)}ms.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
options.onWarning?.(`Could not refresh configured providers: ${error instanceof Error ? error.message : String(error)}`);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
}
|
|
278
|
+
return runtime;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
|
|
3
|
+
*
|
|
4
|
+
* Two ways in, one path after:
|
|
5
|
+
*
|
|
6
|
+
* (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
|
|
7
|
+
* products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
|
|
8
|
+
* callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
|
|
9
|
+
* DROPS the event — the framework never substitutes an id of its own.
|
|
10
|
+
*
|
|
11
|
+
* (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
|
|
12
|
+
* buffering stay wherever the product already keeps them (operon desktop wraps its
|
|
13
|
+
* consent-gated main-process sink this way, so there is exactly one PostHog channel).
|
|
14
|
+
*
|
|
15
|
+
* Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
|
|
16
|
+
* enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
|
|
17
|
+
*/
|
|
18
|
+
import { type TelemetryAppender, type TelemetryEvent, type TelemetryPrimitive } from "operon-agents-core/telemetry";
|
|
19
|
+
/** The minimum a sink has to look like. `posthog-node`'s `PostHog` satisfies it as-is. */
|
|
20
|
+
export interface PostHogClientLike {
|
|
21
|
+
capture(message: {
|
|
22
|
+
readonly distinctId?: string;
|
|
23
|
+
readonly event: string;
|
|
24
|
+
readonly properties: Record<string, TelemetryPrimitive>;
|
|
25
|
+
readonly timestamp?: Date;
|
|
26
|
+
}): void;
|
|
27
|
+
flush?(): Promise<void> | void;
|
|
28
|
+
shutdown?(timeoutMs?: number): Promise<void> | void;
|
|
29
|
+
}
|
|
30
|
+
export interface PostHogAppIdentity {
|
|
31
|
+
/** Product name stamped on every event as `app_name` (e.g. `operon`). */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
/** Product version stamped as `app_version`. */
|
|
34
|
+
readonly version?: string;
|
|
35
|
+
}
|
|
36
|
+
interface PostHogAppenderBaseOptions {
|
|
37
|
+
readonly app: PostHogAppIdentity;
|
|
38
|
+
/** Who the event is about. Required in mode (a); optional in mode (b) where the client decides. */
|
|
39
|
+
readonly getDistinctId?: () => string | undefined;
|
|
40
|
+
/** Extra properties stamped on every event (a channel tag, say). Same red lines apply. */
|
|
41
|
+
readonly commonProperties?: Readonly<Record<string, TelemetryPrimitive>>;
|
|
42
|
+
/** Where dropped-event and client errors go. Default: `console.warn`, once per reason. */
|
|
43
|
+
readonly warn?: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
/** Mode (a): the appender owns a `posthog-node` client. */
|
|
46
|
+
export interface PostHogAppenderClientOptions extends PostHogAppenderBaseOptions {
|
|
47
|
+
readonly apiKey: string;
|
|
48
|
+
/** PostHog ingestion host, e.g. `https://us.i.posthog.com`. */
|
|
49
|
+
readonly host: string;
|
|
50
|
+
readonly getDistinctId: () => string | undefined;
|
|
51
|
+
/** Client batching knobs; passed through. */
|
|
52
|
+
readonly flushAt?: number;
|
|
53
|
+
readonly flushIntervalMs?: number;
|
|
54
|
+
/** Test seam: build the client from resolved options instead of `new PostHog(...)`. */
|
|
55
|
+
readonly createClient?: (apiKey: string, options: {
|
|
56
|
+
host: string;
|
|
57
|
+
flushAt?: number;
|
|
58
|
+
flushInterval?: number;
|
|
59
|
+
}) => PostHogClientLike;
|
|
60
|
+
}
|
|
61
|
+
/** Mode (b): the product injects the sink. */
|
|
62
|
+
export interface PostHogAppenderInjectedOptions extends PostHogAppenderBaseOptions {
|
|
63
|
+
readonly client: PostHogClientLike;
|
|
64
|
+
}
|
|
65
|
+
export type PostHogAppenderOptions = PostHogAppenderClientOptions | PostHogAppenderInjectedOptions;
|
|
66
|
+
export declare class PostHogAppender implements TelemetryAppender {
|
|
67
|
+
private client;
|
|
68
|
+
private readonly ready;
|
|
69
|
+
private readonly getDistinctId;
|
|
70
|
+
private readonly requireDistinctId;
|
|
71
|
+
private readonly common;
|
|
72
|
+
private readonly warn;
|
|
73
|
+
private readonly warned;
|
|
74
|
+
/** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
|
|
75
|
+
private pending;
|
|
76
|
+
private closed;
|
|
77
|
+
private droppedCount;
|
|
78
|
+
constructor(options: PostHogAppenderOptions);
|
|
79
|
+
/** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
|
|
80
|
+
get dropped(): number;
|
|
81
|
+
track(event: TelemetryEvent): void;
|
|
82
|
+
private send;
|
|
83
|
+
private warnOnce;
|
|
84
|
+
flush(): Promise<void>;
|
|
85
|
+
shutdown(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=telemetry-posthog.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
|
|
3
|
+
*
|
|
4
|
+
* Two ways in, one path after:
|
|
5
|
+
*
|
|
6
|
+
* (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
|
|
7
|
+
* products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
|
|
8
|
+
* callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
|
|
9
|
+
* DROPS the event — the framework never substitutes an id of its own.
|
|
10
|
+
*
|
|
11
|
+
* (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
|
|
12
|
+
* buffering stay wherever the product already keeps them (operon desktop wraps its
|
|
13
|
+
* consent-gated main-process sink this way, so there is exactly one PostHog channel).
|
|
14
|
+
*
|
|
15
|
+
* Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
|
|
16
|
+
* enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { redactTelemetryProperties } from "operon-agents-core/telemetry";
|
|
20
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
21
|
+
function frameworkVersion() {
|
|
22
|
+
try {
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const pkg = require("../package.json");
|
|
25
|
+
return pkg.version ?? "unknown";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "unknown";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isInjected(options) {
|
|
32
|
+
return "client" in options;
|
|
33
|
+
}
|
|
34
|
+
async function createNodeClient(apiKey, options) {
|
|
35
|
+
// Loaded lazily so a product in mode (b) never pays for the module, and so the import lives
|
|
36
|
+
// in one place if the client ever changes.
|
|
37
|
+
const mod = await import("posthog-node");
|
|
38
|
+
return new mod.PostHog(apiKey, { host: options.host, flushAt: options.flushAt, flushInterval: options.flushInterval, disableGeoip: true });
|
|
39
|
+
}
|
|
40
|
+
export class PostHogAppender {
|
|
41
|
+
client;
|
|
42
|
+
ready;
|
|
43
|
+
getDistinctId;
|
|
44
|
+
requireDistinctId;
|
|
45
|
+
common;
|
|
46
|
+
warn;
|
|
47
|
+
warned = new Set();
|
|
48
|
+
/** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
|
|
49
|
+
pending = [];
|
|
50
|
+
closed = false;
|
|
51
|
+
droppedCount = 0;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
this.getDistinctId = options.getDistinctId;
|
|
54
|
+
this.requireDistinctId = !isInjected(options);
|
|
55
|
+
this.common = Object.freeze({
|
|
56
|
+
app_name: options.app.name,
|
|
57
|
+
app_version: options.app.version ?? null,
|
|
58
|
+
framework_version: frameworkVersion(),
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
...(options.commonProperties ?? {}),
|
|
61
|
+
});
|
|
62
|
+
this.warn = options.warn ?? ((message) => console.warn(`[telemetry/posthog] ${message}`));
|
|
63
|
+
if (isInjected(options)) {
|
|
64
|
+
this.client = options.client;
|
|
65
|
+
this.ready = Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const create = options.createClient;
|
|
69
|
+
const resolved = { host: options.host, flushAt: options.flushAt, flushInterval: options.flushIntervalMs };
|
|
70
|
+
this.ready = (create === undefined ? createNodeClient(options.apiKey, resolved) : Promise.resolve(create(options.apiKey, resolved)))
|
|
71
|
+
.then((client) => {
|
|
72
|
+
this.client = client;
|
|
73
|
+
const queued = this.pending;
|
|
74
|
+
this.pending = [];
|
|
75
|
+
for (const event of queued)
|
|
76
|
+
this.send(event);
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
this.warnOnce("client-init", `client failed to initialise; events will be dropped: ${String(error)}`);
|
|
80
|
+
this.pending = [];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
|
|
85
|
+
get dropped() {
|
|
86
|
+
return this.droppedCount;
|
|
87
|
+
}
|
|
88
|
+
track(event) {
|
|
89
|
+
if (this.closed) {
|
|
90
|
+
this.droppedCount += 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (this.client === undefined) {
|
|
94
|
+
if (this.pending.length >= 200) {
|
|
95
|
+
this.droppedCount += 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.pending.push(event);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.send(event);
|
|
102
|
+
}
|
|
103
|
+
send(event) {
|
|
104
|
+
const client = this.client;
|
|
105
|
+
if (client === undefined)
|
|
106
|
+
return;
|
|
107
|
+
const distinctId = this.getDistinctId?.();
|
|
108
|
+
if (this.requireDistinctId && distinctId === undefined) {
|
|
109
|
+
this.droppedCount += 1;
|
|
110
|
+
this.warnOnce("no-distinct-id", "getDistinctId() returned undefined; event dropped (the framework never mints an id)");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const properties = { ...this.common, ...redactTelemetryProperties(event.properties) };
|
|
114
|
+
try {
|
|
115
|
+
client.capture({
|
|
116
|
+
...(distinctId === undefined ? {} : { distinctId }),
|
|
117
|
+
event: event.name,
|
|
118
|
+
properties,
|
|
119
|
+
timestamp: new Date(event.timestamp),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
this.warnOnce("capture", `client.capture threw: ${String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
warnOnce(key, message) {
|
|
127
|
+
if (this.warned.has(key))
|
|
128
|
+
return;
|
|
129
|
+
this.warned.add(key);
|
|
130
|
+
this.warn(message);
|
|
131
|
+
}
|
|
132
|
+
async flush() {
|
|
133
|
+
await this.ready;
|
|
134
|
+
await Promise.resolve(this.client?.flush?.());
|
|
135
|
+
}
|
|
136
|
+
async shutdown() {
|
|
137
|
+
await this.ready;
|
|
138
|
+
this.closed = true;
|
|
139
|
+
await Promise.resolve(this.client?.shutdown?.(SHUTDOWN_TIMEOUT_MS));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=telemetry-posthog.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * from "operon-agents-core/telemetry";
|
|
2
|
+
export { PostHogAppender } from "./telemetry-posthog.ts";
|
|
3
|
+
export type { PostHogAppenderOptions, PostHogAppenderClientOptions, PostHogAppenderInjectedOptions, PostHogClientLike, PostHogAppIdentity } from "./telemetry-posthog.ts";
|
|
4
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Capability, type McpServerConfig, type McpServersCapabilityOptions, type McpServersHandle } from "operon-agents-core";
|
|
2
|
+
/** How long a directory's connections outlive its last session by default: five minutes. */
|
|
3
|
+
export declare const DEFAULT_WORKSPACE_MCP_IDLE_MS: number;
|
|
4
|
+
export interface WorkspaceMcpPoolOptions extends Omit<McpServersCapabilityOptions, "sharedServers"> {
|
|
5
|
+
/**
|
|
6
|
+
* How long a directory's connections stay up after the last session using them closes, in ms.
|
|
7
|
+
* A session opened there within the window reuses them. `0` closes them at once. Defaults to
|
|
8
|
+
* {@link DEFAULT_WORKSPACE_MCP_IDLE_MS}.
|
|
9
|
+
*/
|
|
10
|
+
readonly idleMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/** One session's hold on a directory's connections. `release` is idempotent. */
|
|
13
|
+
export interface WorkspaceMcpLease {
|
|
14
|
+
readonly servers: McpServersHandle;
|
|
15
|
+
release(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare class WorkspaceMcpPool {
|
|
18
|
+
private readonly options;
|
|
19
|
+
/** The generation a NEW borrow in each directory gets. */
|
|
20
|
+
private readonly current;
|
|
21
|
+
/** Every generation not yet shut down — current ones and retired ones still on loan. */
|
|
22
|
+
private readonly live;
|
|
23
|
+
private closing;
|
|
24
|
+
constructor(options?: WorkspaceMcpPoolOptions);
|
|
25
|
+
/** Directories whose connections are currently up (on loan or idling). */
|
|
26
|
+
get size(): number;
|
|
27
|
+
/**
|
|
28
|
+
* Borrow the connections for `workDir`, building them if this directory has none for exactly
|
|
29
|
+
* these `configs`. Resolves once they have connected — fault-isolated, so a server that failed
|
|
30
|
+
* is a status on the handle, never a rejection. Rejects for what `createMcpServers` refuses
|
|
31
|
+
* up front (a transport this host does not run), before anything is built.
|
|
32
|
+
*/
|
|
33
|
+
acquire(workDir: string, configs: Record<string, McpServerConfig>): Promise<WorkspaceMcpLease>;
|
|
34
|
+
/** Shut every directory's connections down, on loan or not. Later borrows throw. Idempotent. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
/** The last lease came back: close a retired generation now, a current one after the grace. */
|
|
37
|
+
private idle;
|
|
38
|
+
private shutdown;
|
|
39
|
+
private assertOpen;
|
|
40
|
+
}
|
|
41
|
+
export interface WorkspaceMcpCapabilityOptions {
|
|
42
|
+
readonly pool: WorkspaceMcpPool;
|
|
43
|
+
/** The session's working directory — the key the connections are shared under. */
|
|
44
|
+
readonly workDir: string;
|
|
45
|
+
/** The servers every session in `workDir` shares. */
|
|
46
|
+
readonly servers: Record<string, McpServerConfig>;
|
|
47
|
+
/** The session's own servers, layered over the shared ones (same name: the session's wins). */
|
|
48
|
+
readonly overlay?: Record<string, McpServerConfig>;
|
|
49
|
+
/**
|
|
50
|
+
* Options for the overlay's connections; the pooled ones were configured on the pool. Its
|
|
51
|
+
* `allowedTransports` is enforced on BOTH — the host's statement of what it runs does not get
|
|
52
|
+
* weaker because a server happens to be shared.
|
|
53
|
+
*/
|
|
54
|
+
readonly overlayOptions?: Omit<McpServersCapabilityOptions, "sharedServers">;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `mcp` capability over a directory's pooled connections: borrows them at `openSession`,
|
|
58
|
+
* gives them back at `closeSession` (session close or detach alike). In between it is exactly
|
|
59
|
+
* `mcpServersCapability` viewing a shared set — same tools, same `session.mcp`, same overlay.
|
|
60
|
+
*/
|
|
61
|
+
export declare function workspaceMcpCapability(options: WorkspaceMcpCapabilityOptions): Capability;
|
|
62
|
+
//# sourceMappingURL=workspace-mcp.d.ts.map
|