@tokagent/tokagentos 2.0.24 → 2.0.30

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/scaffold-patches/packages/agent/src/api/plugin-routes.ts +1889 -0
  3. package/scaffold-patches/packages/agent/src/api/server.ts +4509 -0
  4. package/scaffold-patches/packages/agent/src/api/trigger-routes.ts +942 -0
  5. package/scaffold-patches/packages/agent/src/runtime/core-plugins.ts +4 -0
  6. package/scaffold-patches/packages/agent/src/triggers/runtime.ts +955 -0
  7. package/scaffold-patches/packages/app-core/src/api/automations-compat-routes.ts +924 -0
  8. package/scaffold-patches/packages/app-core/src/api/client-agent.ts +2755 -0
  9. package/scaffold-patches/packages/app-core/src/components/pages/AutomationsView.tsx +446 -26
  10. package/scaffold-patches/packages/app-core/src/components/pages/SettingsView.tsx +155 -0
  11. package/scaffold-patches/packages/shared/src/onboarding-presets.characters.ts +16 -16
  12. package/templates/fullstack-app/package.json +9 -5
  13. package/templates/fullstack-app/plugins/plugin-tokagent-billing/package.json +1 -1
  14. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/__tests__/routes/estimate-routes.test.ts +5 -2
  15. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/app.js +896 -19
  16. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/index.html +280 -94
  17. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/style.css +969 -235
  18. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/keys-routes.ts +170 -0
  19. package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/messages-proxy-routes.ts +114 -3
  20. package/templates/fullstack-app/plugins/plugin-web-fetch/build.ts +35 -0
  21. package/templates/fullstack-app/plugins/plugin-web-fetch/package.json +37 -0
  22. package/templates/fullstack-app/plugins/plugin-web-fetch/src/index.ts +471 -0
  23. package/templates/fullstack-app/plugins/plugin-web-fetch/tsconfig.json +20 -0
  24. package/templates/fullstack-app/scripts/ensure-plugin-builds.mjs +1 -0
  25. package/templates/fullstack-app/scripts/verify-llm-plugins.mjs +122 -0
  26. package/templates-manifest.json +1 -1
@@ -13,6 +13,9 @@
13
13
 
14
14
  import type { Route, RouteRequest, RouteResponse, IAgentRuntime } from "@elizaos/core";
15
15
  import type { IncomingMessage } from "node:http";
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import process from "node:process";
16
19
  import {
17
20
  mintApiKey,
18
21
  listApiKeys,
@@ -184,6 +187,149 @@ async function handleRevokeKey(
184
187
  }
185
188
  }
186
189
 
190
+ // ---------------------------------------------------------------------------
191
+ // POST /v1/keys/install — write BILLING_CHAT_KEY to project .env
192
+ // ---------------------------------------------------------------------------
193
+ //
194
+ // LOCAL ONLY. This endpoint runs on the user's local agent (whether it's
195
+ // configured as billing client or billing server) and:
196
+ // 1. Validates the request body has a syntactically valid `sk-ai-*` key
197
+ // 2. Atomically upserts `BILLING_CHAT_KEY=<key>` into `<cwd>/.env`
198
+ // (preserving all other entries; existing commented `# BILLING_CHAT_KEY`
199
+ // lines are replaced in place rather than duplicated)
200
+ // 3. Mirrors the new value into process.env immediately so in-flight
201
+ // chat calls pick it up without waiting for the restart
202
+ //
203
+ // The restart itself is DELEGATED to the existing `POST /api/restart`
204
+ // endpoint — the dashboard calls this install endpoint first, and then
205
+ // the restart endpoint second. Splitting them avoids duplicating restart
206
+ // strategy logic across runners (dev-ui in-process bounce, prod CLI
207
+ // supervisor catching exit 75, etc.) and keeps this route a pure
208
+ // "write the file" operation.
209
+ //
210
+ // AUTH: requires the same authenticated identity as the rest of /v1/keys/*
211
+ // (SIWE session OR existing API key). Format-validates `sk-ai-...` but does
212
+ // not verify the key was minted by this user — anyone with shell access to
213
+ // the user's machine could already edit .env directly, so the auth check
214
+ // is meant to guard against trivial CSRF, not a malicious LAN attacker.
215
+ const SK_AI_KEY_RE = /^sk-ai-[A-Za-z0-9_-]{16,128}$/;
216
+
217
+ async function readIfExists(filePath: string): Promise<string | null> {
218
+ try {
219
+ return await fs.readFile(filePath, "utf8");
220
+ } catch (err) {
221
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
222
+ throw err;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Atomically upsert `KEY=VALUE` in a project-root .env file.
228
+ *
229
+ * - If the key already exists on a line (commented or not), replace that
230
+ * line with the new uncommented `KEY=VALUE`.
231
+ * - Otherwise append `KEY=VALUE` to the end (with one preceding blank line
232
+ * if the file ends with non-empty content).
233
+ *
234
+ * Atomicity: write to `<filePath>.tmp` then rename. The rename is atomic
235
+ * on POSIX. We do NOT keep a `.bak` for the project .env because users
236
+ * version-control their .env templates separately and the .env itself is
237
+ * gitignored — a `.bak` would just be visual noise.
238
+ *
239
+ * Values are written verbatim (no quoting). sk-ai-* keys are URL-safe
240
+ * base64 (`/^sk-ai-[A-Za-z0-9_-]+$/`) so they never need quoting; callers
241
+ * MUST validate before invoking this function.
242
+ */
243
+ async function upsertDotenvLine(
244
+ filePath: string,
245
+ key: string,
246
+ value: string,
247
+ ): Promise<void> {
248
+ const existing = (await readIfExists(filePath)) ?? "";
249
+ const lines = existing.length === 0 ? [] : existing.split(/\r?\n/);
250
+ // dotenv-style split leaves a trailing empty element for files ending in
251
+ // newline. Strip it so we can manage trailing newlines explicitly.
252
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
253
+ lines.pop();
254
+ }
255
+ const re = new RegExp(`^\\s*#?\\s*${key.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\s*=`);
256
+ let updatedAt = -1;
257
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
258
+ if (re.test(lines[i] ?? "")) {
259
+ lines[i] = `${key}=${value}`;
260
+ updatedAt = i;
261
+ break;
262
+ }
263
+ }
264
+ if (updatedAt < 0) {
265
+ if (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() !== "") {
266
+ lines.push("");
267
+ }
268
+ lines.push(`${key}=${value}`);
269
+ }
270
+ const nextContents = `${lines.join("\n")}\n`;
271
+ const tmp = `${filePath}.tmp`;
272
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
273
+ const handle = await fs.open(tmp, "w", 0o600);
274
+ try {
275
+ await handle.writeFile(nextContents, "utf8");
276
+ await handle.sync();
277
+ } finally {
278
+ await handle.close();
279
+ }
280
+ await fs.rename(tmp, filePath);
281
+ }
282
+
283
+ async function handleInstallKey(
284
+ req: RouteRequest,
285
+ res: RouteResponse,
286
+ _runtime: IAgentRuntime,
287
+ ): Promise<void> {
288
+ // Auth check. In client-mode, `resolveBillingIdentity` returns null (no
289
+ // local DB / authSecret) — but the local user is the one running the
290
+ // server, and we serve this from localhost only, so we accept the request
291
+ // unconditionally in client-mode as long as the body is well-formed.
292
+ // In server-mode, require a valid identity.
293
+ const identity = await resolveBillingIdentity(toIncomingMessage(req));
294
+ const billingState = getBillingState();
295
+ const isClientMode = billingState.config.billingMode === "client";
296
+ if (!identity && !isClientMode) {
297
+ res.status(401).json({ error: "Authentication required." });
298
+ return;
299
+ }
300
+
301
+ const body = req.body as Record<string, unknown> | undefined;
302
+ const key = typeof body?.["key"] === "string" ? body["key"].trim() : "";
303
+ if (!SK_AI_KEY_RE.test(key)) {
304
+ res.status(400).json({
305
+ error: "Invalid key format — expected sk-ai-... (16+ url-safe chars).",
306
+ });
307
+ return;
308
+ }
309
+
310
+ const envPath = path.join(process.cwd(), ".env");
311
+ try {
312
+ await upsertDotenvLine(envPath, "BILLING_CHAT_KEY", key);
313
+ } catch (err) {
314
+ const message = err instanceof Error ? err.message : String(err);
315
+ res.status(500).json({ error: `Failed to write .env: ${message}` });
316
+ return;
317
+ }
318
+
319
+ // Update in-flight env so subsequent chat calls work even before restart.
320
+ // configureBillingChatMirror() at startup mirrors BILLING_CHAT_KEY → OPENAI_API_KEY,
321
+ // but the OpenAI plugin may cache its key at init — restart is still the safe
322
+ // path. The dashboard calls POST /api/restart after this returns 200.
323
+ process.env["BILLING_CHAT_KEY"] = key;
324
+ process.env["OPENAI_API_KEY"] = key;
325
+
326
+ res.status(200).json({
327
+ ok: true,
328
+ envPath,
329
+ message: "Key saved to .env. Call POST /api/restart to apply.",
330
+ });
331
+ }
332
+
187
333
  // ---------------------------------------------------------------------------
188
334
  // Route definitions
189
335
  // ---------------------------------------------------------------------------
@@ -197,6 +343,18 @@ export const keysRoutes: Route[] = [
197
343
  name: "billing-keys-mint",
198
344
  handler: handleMintKey,
199
345
  },
346
+ // MUST be registered BEFORE the /v1/keys/:id DELETE route in the array
347
+ // (routes are matched in registration order on rawPath: true with
348
+ // params). `install` is a string literal that could otherwise match the
349
+ // `:id` param and route the install POST through revoke handling.
350
+ {
351
+ type: "POST",
352
+ path: "/v1/keys/install",
353
+ rawPath: true,
354
+ public: true,
355
+ name: "billing-keys-install",
356
+ handler: handleInstallKey,
357
+ },
200
358
  {
201
359
  type: "GET",
202
360
  path: "/v1/keys",
@@ -235,6 +393,18 @@ function clientKeysRoutes(): Route[] {
235
393
  );
236
394
  },
237
395
  },
396
+ // /v1/keys/install is LOCAL on both modes (writes the local agent's
397
+ // own .env), so it uses the same direct handler as server-mode.
398
+ // Must precede /v1/keys/:id in this array for the same param-matching
399
+ // reason explained on the server-mode array.
400
+ {
401
+ type: "POST",
402
+ path: "/v1/keys/install",
403
+ rawPath: true,
404
+ public: true,
405
+ name: "billing-keys-install",
406
+ handler: handleInstallKey,
407
+ },
238
408
  {
239
409
  type: "GET",
240
410
  path: "/v1/keys",
@@ -26,12 +26,115 @@ import type { Route, RouteRequest, RouteResponse, IAgentRuntime } from "@elizaos
26
26
  import type { IncomingMessage } from "node:http";
27
27
  import { getBillingState, isBillingStateInitialized } from "../state.js";
28
28
  import { applyBillingGate } from "../middleware/billing-gate.js";
29
- import { computeActualCostUsd } from "@tokagentos/billing";
29
+ import {
30
+ computeActualCostUsd,
31
+ estimateInputTokens,
32
+ } from "@tokagentos/billing";
30
33
 
31
34
  function billingUnavailable(res: RouteResponse): void {
32
35
  res.status(503).json({ error: "Billing service unavailable." });
33
36
  }
34
37
 
38
+ // ---------------------------------------------------------------------------
39
+ // Anthropic prompt-cache auto-injection
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Anthropic's minimum cacheable prefix is 1024 tokens for Sonnet/Opus and
44
+ * 2048 for Haiku. Below that the cache_control marker is a no-op. Use the
45
+ * stricter bound so the optimization always pays off when we add it.
46
+ */
47
+ const MIN_CACHEABLE_PREFIX_TOKENS = 2048;
48
+
49
+ /** Returns true if any node anywhere in `value` has a `cache_control` key. */
50
+ function hasCacheControlDeep(value: unknown): boolean {
51
+ if (!value || typeof value !== "object") return false;
52
+ if (Array.isArray(value)) return value.some(hasCacheControlDeep);
53
+ const obj = value as Record<string, unknown>;
54
+ if ("cache_control" in obj) return true;
55
+ for (const k of Object.keys(obj)) {
56
+ if (hasCacheControlDeep(obj[k])) return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Auto-inject Anthropic prompt-cache markers on stable parts of the request.
63
+ *
64
+ * The billing engine already supports cache pricing end-to-end (see
65
+ * pricing/rates.ts cacheRead/cacheWrite columns and computeActualCostUsd),
66
+ * but most anthropic-sdk callers never set cache_control themselves. Without
67
+ * markers, Anthropic re-reads the full system + tools prefix on every turn
68
+ * at base input rate. With markers, the prefix is served from cache at ~10×
69
+ * cheaper after the first call within 5 minutes.
70
+ *
71
+ * What we touch:
72
+ * - `system`: normalised to array form, marker on the LAST text block
73
+ * - `tools`: marker on the LAST tool definition (Anthropic caches the
74
+ * entire prefix up to and including the marker, so this also covers
75
+ * `system`)
76
+ *
77
+ * What we DON'T touch:
78
+ * - Non-Claude models — other providers ignore or reject the field; their
79
+ * caching is implicit.
80
+ * - Bodies that already have ANY cache_control set — respect client intent.
81
+ * - Bodies whose stable prefix is below Anthropic's minimum cacheable size.
82
+ *
83
+ * Returns a new body when injection happens; the same reference otherwise.
84
+ * Never mutates the input.
85
+ */
86
+ function maybeInjectAnthropicCache(
87
+ body: Record<string, unknown>,
88
+ ): Record<string, unknown> {
89
+ const model = body.model;
90
+ if (typeof model !== "string" || !model.startsWith("claude-")) return body;
91
+ if (hasCacheControlDeep(body)) return body;
92
+
93
+ const tools = Array.isArray(body.tools) ? body.tools : undefined;
94
+ const sys = body.system;
95
+ const prefixTokens = estimateInputTokens([], tools, sys);
96
+ if (prefixTokens < MIN_CACHEABLE_PREFIX_TOKENS) return body;
97
+
98
+ const next: Record<string, unknown> = { ...body };
99
+
100
+ if (typeof sys === "string" && sys.length > 0) {
101
+ next.system = [
102
+ { type: "text", text: sys, cache_control: { type: "ephemeral" } },
103
+ ];
104
+ } else if (Array.isArray(sys) && sys.length > 0) {
105
+ const cloned = sys.map((b) =>
106
+ b && typeof b === "object" ? { ...(b as Record<string, unknown>) } : b,
107
+ );
108
+ for (let i = cloned.length - 1; i >= 0; i--) {
109
+ const blk = cloned[i];
110
+ if (
111
+ blk &&
112
+ typeof blk === "object" &&
113
+ (blk as Record<string, unknown>).type === "text"
114
+ ) {
115
+ (cloned[i] as Record<string, unknown>).cache_control = {
116
+ type: "ephemeral",
117
+ };
118
+ break;
119
+ }
120
+ }
121
+ next.system = cloned;
122
+ }
123
+
124
+ if (tools && tools.length > 0) {
125
+ const clonedTools = tools.map((t) =>
126
+ t && typeof t === "object" ? { ...(t as Record<string, unknown>) } : t,
127
+ );
128
+ const last = clonedTools[clonedTools.length - 1];
129
+ if (last && typeof last === "object") {
130
+ (last as Record<string, unknown>).cache_control = { type: "ephemeral" };
131
+ }
132
+ next.tools = clonedTools;
133
+ }
134
+
135
+ return next;
136
+ }
137
+
35
138
  /**
36
139
  * Convert a plugin RouteRequest into the IncomingMessage shape that
37
140
  * applyBillingGate / resolveBillingIdentity expect.
@@ -104,14 +207,22 @@ async function proxyToLiteLLM(
104
207
  const config = state.config;
105
208
  if (!config.enabled) return billingUnavailable(res);
106
209
 
107
- const body = req.body as Record<string, unknown> | undefined;
108
- if (!body || typeof body !== "object") {
210
+ const rawBody = req.body as Record<string, unknown> | undefined;
211
+ if (!rawBody || typeof rawBody !== "object") {
109
212
  res.status(400).json({
110
213
  error: { type: "invalid_request_error", message: "JSON body required" },
111
214
  });
112
215
  return;
113
216
  }
114
217
 
218
+ // Auto-inject Anthropic prompt-cache markers on stable parts of the
219
+ // request. Done BEFORE the billing gate so the reservation sees the
220
+ // markers (gate.detectCacheControl reads them to size at cacheWrite rate
221
+ // — slightly higher first-call reservation, dramatically lower steady
222
+ // state). No-op for non-Claude models or bodies where the caller already
223
+ // set cache_control. See maybeInjectAnthropicCache for the full policy.
224
+ const body = maybeInjectAnthropicCache(rawBody);
225
+
115
226
  // Detect streaming. plugin-openai (Vercel AI SDK) defaults to
116
227
  // stream:true and there's no way to disable from the agent's chat flow,
117
228
  // so we MUST support it. For non-stream we buffer the JSON response;
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Build script for @tokagent/plugin-web-fetch. Produces ESM in dist/.
4
+ */
5
+ import { existsSync, rmSync } from "node:fs";
6
+
7
+ const watch = process.argv.includes("--watch");
8
+
9
+ async function build() {
10
+ if (existsSync("dist")) rmSync("dist", { recursive: true });
11
+ await Bun.build({
12
+ entrypoints: ["./src/index.ts"],
13
+ outdir: "./dist",
14
+ target: "node",
15
+ format: "esm",
16
+ external: ["@elizaos/core"],
17
+ sourcemap: "external",
18
+ });
19
+ console.log("✓ build complete");
20
+ }
21
+
22
+ if (watch) {
23
+ await build();
24
+ const watcher = Bun.watch("./src", { recursive: true });
25
+ for await (const _ of watcher) {
26
+ console.log("[watch] rebuilding...");
27
+ try {
28
+ await build();
29
+ } catch (e) {
30
+ console.error(e);
31
+ }
32
+ }
33
+ } else {
34
+ await build();
35
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@tokagent/plugin-web-fetch",
3
+ "version": "0.1.0",
4
+ "description": "Web fetch + Tavily search actions for the agent. FETCH_URL (Node built-in fetch, no key) + WEB_SEARCH (Tavily-backed, requires TAVILY_API_KEY).",
5
+ "type": "module",
6
+ "private": true,
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": ["dist", "README.md"],
18
+ "scripts": {
19
+ "build": "bun run build.ts",
20
+ "dev": "bun run build.ts --watch",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "@elizaos/core": "workspace:*"
25
+ },
26
+ "agentConfig": {
27
+ "pluginType": "elizaos:plugin:1.0.0",
28
+ "pluginParameters": {
29
+ "TAVILY_API_KEY": {
30
+ "type": "string",
31
+ "description": "Tavily search API key — required for the WEB_SEARCH action. Get a free key (1,000 searches/month, no credit card) at https://app.tavily.com/sign-in. Without it the agent cannot fulfill 'search the web' requests; it will reply with a clear error pointing here. Saving this key persists to config.env and triggers a runtime restart so the action picks it up.",
32
+ "required": false,
33
+ "sensitive": true
34
+ }
35
+ }
36
+ }
37
+ }