@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
@@ -0,0 +1,942 @@
1
+ import crypto from "node:crypto";
2
+ import {
3
+ type TriggerRunRecord as CoreTriggerRunRecord,
4
+ type IAgentRuntime,
5
+ stringToUuid,
6
+ type Task,
7
+ type TriggerConfig,
8
+ type TriggerKind,
9
+ type TriggerType,
10
+ type TriggerWakeMode,
11
+ type UUID,
12
+ } from "@elizaos/core";
13
+ import type {
14
+ TriggerExecutionOptions,
15
+ TriggerExecutionResult,
16
+ } from "../triggers/runtime.js";
17
+ import type {
18
+ NormalizedTriggerDraft,
19
+ TriggerHealthSnapshot,
20
+ TriggerSummary,
21
+ TriggerTaskMetadata,
22
+ } from "../triggers/types.js";
23
+ import type { RouteHelpers, RouteRequestContext } from "./route-helpers.js";
24
+
25
+ export type TriggerRouteHelpers = RouteHelpers;
26
+
27
+ interface TriggerDraftInput {
28
+ displayName?: string;
29
+ instructions?: string;
30
+ triggerType?: TriggerType;
31
+ wakeMode?: TriggerWakeMode;
32
+ enabled?: boolean;
33
+ createdBy?: string;
34
+ timezone?: string;
35
+ intervalMs?: number;
36
+ scheduledAtIso?: string;
37
+ cronExpression?: string;
38
+ eventKind?: string;
39
+ maxRuns?: number;
40
+ kind?: TriggerKind;
41
+ workflowId?: string;
42
+ workflowName?: string;
43
+ }
44
+
45
+ interface NormalizeTriggerDraftFallback {
46
+ displayName: string;
47
+ instructions: string;
48
+ triggerType: TriggerType;
49
+ wakeMode: TriggerWakeMode;
50
+ enabled: boolean;
51
+ createdBy: string;
52
+ }
53
+
54
+ export interface TriggerRouteContext extends RouteRequestContext {
55
+ runtime: IAgentRuntime | null;
56
+ executeTriggerTask: (
57
+ runtime: IAgentRuntime,
58
+ task: Task,
59
+ options: TriggerExecutionOptions,
60
+ ) => Promise<TriggerExecutionResult>;
61
+ getTriggerHealthSnapshot: (
62
+ runtime: IAgentRuntime,
63
+ ) => Promise<TriggerHealthSnapshot>;
64
+ getTriggerLimit: (runtime: IAgentRuntime) => number;
65
+ listTriggerTasks: (runtime: IAgentRuntime) => Promise<Task[]>;
66
+ readTriggerConfig: (task: Task) => TriggerConfig | null;
67
+ readTriggerRuns: (task: Task) => CoreTriggerRunRecord[];
68
+ taskToTriggerSummary: (task: Task) => TriggerSummary | null;
69
+ triggersFeatureEnabled: (runtime: IAgentRuntime) => boolean;
70
+ buildTriggerConfig: (params: {
71
+ draft: NormalizedTriggerDraft;
72
+ triggerId: UUID;
73
+ previous?: TriggerConfig;
74
+ }) => TriggerConfig;
75
+ buildTriggerMetadata: (params: {
76
+ existingMetadata?: TriggerTaskMetadata;
77
+ trigger: TriggerConfig;
78
+ nowMs: number;
79
+ }) => TriggerTaskMetadata | null;
80
+ normalizeTriggerDraft: (params: {
81
+ input: TriggerDraftInput;
82
+ fallback: NormalizeTriggerDraftFallback;
83
+ }) => { draft?: NormalizedTriggerDraft; error?: string };
84
+ DISABLED_TRIGGER_INTERVAL_MS: number;
85
+ TRIGGER_TASK_NAME: string;
86
+ TRIGGER_TASK_TAGS: string[];
87
+ }
88
+
89
+ /**
90
+ * Heuristic: does this trigger's instruction text describe a web-search job?
91
+ * Catches phrasings like "search the web for X", "find online", "look up on
92
+ * Google", "trends today", "latest news on Y" without being so permissive
93
+ * that any mention of "find" triggers a false positive.
94
+ */
95
+ function instructionLikelyNeedsWebSearch(text: string): boolean {
96
+ if (typeof text !== "string" || !text.trim()) return false;
97
+ const t = text.toLowerCase();
98
+ if (/\b(search|find|look\s*up|google|crawl|scrape)\b.{0,40}\b(web|online|internet|net|google)\b/.test(t))
99
+ return true;
100
+ if (/\b(search\s+for|google\s+for|web\s+search|internet\s+search)\b/.test(t))
101
+ return true;
102
+ if (/\b(latest|today's|recent|trending|top)\b.{0,40}\b(news|trends?|articles?|stories|updates|releases?|developments?)\b/.test(t))
103
+ return true;
104
+ if (/\bwhat'?s\s+(new|happening|trending)\b/.test(t)) return true;
105
+ return false;
106
+ }
107
+
108
+ function tavilyKeyConfigured(): boolean {
109
+ const v = process.env.TAVILY_API_KEY;
110
+ return typeof v === "string" && v.trim().length > 0;
111
+ }
112
+
113
+ function trim(value: string): string {
114
+ return value.trim().replace(/\s+/g, " ");
115
+ }
116
+
117
+ function parseTriggerKind(value: unknown): TriggerKind | undefined {
118
+ if (value === "text" || value === "workflow") return value;
119
+ return undefined;
120
+ }
121
+
122
+ type ParsedTriggerKind =
123
+ | { ok: true; kind: TriggerKind }
124
+ | { ok: false; error: string };
125
+
126
+ function parseTriggerKindStrict(value: unknown): ParsedTriggerKind | undefined {
127
+ if (value === undefined) return undefined;
128
+ if (value === "text" || value === "workflow")
129
+ return { ok: true, kind: value };
130
+ return { ok: false, error: "kind must be 'text' or 'workflow'" };
131
+ }
132
+
133
+ function parseNonEmptyString(value: unknown): string | undefined {
134
+ if (typeof value !== "string") return undefined;
135
+ const trimmed = value.trim();
136
+ return trimmed.length > 0 ? trimmed : undefined;
137
+ }
138
+
139
+ function parseEventPayload(value: unknown): Record<string, unknown> {
140
+ return value && typeof value === "object" && !Array.isArray(value)
141
+ ? (value as Record<string, unknown>)
142
+ : {};
143
+ }
144
+
145
+ function normalizeTriggerPath(pathname: string): {
146
+ normalizedPathname: string;
147
+ usingHeartbeatsAlias: boolean;
148
+ } {
149
+ if (pathname === "/api/heartbeats") {
150
+ return {
151
+ normalizedPathname: "/api/triggers",
152
+ usingHeartbeatsAlias: true,
153
+ };
154
+ }
155
+ if (pathname.startsWith("/api/heartbeats/")) {
156
+ return {
157
+ normalizedPathname: pathname.replace("/api/heartbeats", "/api/triggers"),
158
+ usingHeartbeatsAlias: true,
159
+ };
160
+ }
161
+ return {
162
+ normalizedPathname: pathname,
163
+ usingHeartbeatsAlias: false,
164
+ };
165
+ }
166
+
167
+ async function findTask(
168
+ runtime: IAgentRuntime,
169
+ id: string,
170
+ listTriggerTasks: (runtime: IAgentRuntime) => Promise<Task[]>,
171
+ readTriggerConfig: (task: Task) => TriggerConfig | null,
172
+ ): Promise<Task | null> {
173
+ const tasks = await listTriggerTasks(runtime);
174
+ return (
175
+ tasks.find((task) => {
176
+ const trigger = readTriggerConfig(task);
177
+ return trigger?.triggerId === id || task.id === id;
178
+ }) ?? null
179
+ );
180
+ }
181
+
182
+ export async function handleTriggerRoutes(
183
+ ctx: TriggerRouteContext,
184
+ ): Promise<boolean> {
185
+ const {
186
+ method,
187
+ pathname,
188
+ req,
189
+ res,
190
+ runtime,
191
+ readJsonBody,
192
+ json,
193
+ error,
194
+ executeTriggerTask,
195
+ getTriggerHealthSnapshot,
196
+ getTriggerLimit,
197
+ listTriggerTasks,
198
+ readTriggerConfig,
199
+ readTriggerRuns,
200
+ taskToTriggerSummary,
201
+ triggersFeatureEnabled,
202
+ buildTriggerConfig,
203
+ buildTriggerMetadata,
204
+ normalizeTriggerDraft,
205
+ DISABLED_TRIGGER_INTERVAL_MS,
206
+ TRIGGER_TASK_NAME,
207
+ TRIGGER_TASK_TAGS,
208
+ } = ctx;
209
+
210
+ const { normalizedPathname, usingHeartbeatsAlias } =
211
+ normalizeTriggerPath(pathname);
212
+ const listResponse = (triggers: TriggerSummary[], status = 200): void => {
213
+ json(
214
+ res,
215
+ usingHeartbeatsAlias ? { triggers, heartbeats: triggers } : { triggers },
216
+ status,
217
+ );
218
+ };
219
+ const itemResponse = (summary: TriggerSummary, status = 200): void => {
220
+ json(
221
+ res,
222
+ usingHeartbeatsAlias
223
+ ? { trigger: summary, heartbeat: summary }
224
+ : { trigger: summary },
225
+ status,
226
+ );
227
+ };
228
+
229
+ if (
230
+ !normalizedPathname.startsWith("/api/triggers") &&
231
+ !pathname.startsWith("/api/heartbeats")
232
+ )
233
+ return false;
234
+ if (!runtime) {
235
+ error(res, "Agent is not running", 503);
236
+ return true;
237
+ }
238
+ if (
239
+ !triggersFeatureEnabled(runtime) &&
240
+ normalizedPathname !== "/api/triggers/health"
241
+ ) {
242
+ error(res, "Triggers are disabled by configuration", 503);
243
+ return true;
244
+ }
245
+
246
+ if (method === "GET" && normalizedPathname === "/api/triggers/health") {
247
+ json(res, await getTriggerHealthSnapshot(runtime));
248
+ return true;
249
+ }
250
+
251
+ if (method === "GET" && normalizedPathname === "/api/triggers") {
252
+ const tasks = await listTriggerTasks(runtime);
253
+ const triggers = tasks
254
+ .map(taskToTriggerSummary)
255
+ .filter((summary): summary is TriggerSummary => summary !== null)
256
+ .sort((a, b) =>
257
+ String(a.displayName ?? "").localeCompare(String(b.displayName ?? "")),
258
+ );
259
+ listResponse(triggers);
260
+ return true;
261
+ }
262
+
263
+ if (method === "POST" && normalizedPathname === "/api/triggers") {
264
+ const body = await readJsonBody<Record<string, unknown>>(req, res);
265
+ if (!body) return true;
266
+
267
+ // Pre-flight: refuse to schedule a web-search-shaped trigger when the
268
+ // search backend isn't configured. Otherwise the user wouldn't discover
269
+ // the missing key until first fire (hours/days later for a daily cron).
270
+ if (
271
+ typeof body.instructions === "string" &&
272
+ instructionLikelyNeedsWebSearch(body.instructions) &&
273
+ !tavilyKeyConfigured() &&
274
+ process.env.TOKAGENT_ALLOW_UNCONFIGURED_WEB_SEARCH !== "1"
275
+ ) {
276
+ error(
277
+ res,
278
+ "This trigger looks like it needs to search the web, but " +
279
+ "TAVILY_API_KEY is not configured. The agent cannot fulfill " +
280
+ "web-search requests without it.\n\n" +
281
+ "To fix:\n" +
282
+ " 1. Get a free key at https://app.tavily.com/sign-in " +
283
+ "(1,000 searches/month, no credit card).\n" +
284
+ " 2. In the app, open Settings → Plugins → web-fetch, paste " +
285
+ "the key, and Save. The runtime restarts automatically.\n" +
286
+ " 3. Re-schedule this trigger.\n\n" +
287
+ "If you intend to add the key later and want to schedule the " +
288
+ "trigger now anyway, set TOKAGENT_ALLOW_UNCONFIGURED_WEB_SEARCH=1 " +
289
+ "in your environment and try again.",
290
+ 400,
291
+ );
292
+ return true;
293
+ }
294
+
295
+ const creator =
296
+ typeof body.createdBy === "string"
297
+ ? trim(body.createdBy) || "api"
298
+ : "api";
299
+ const kindParsed = parseTriggerKindStrict(body.kind);
300
+ if (kindParsed !== undefined && kindParsed.ok === false) {
301
+ error(res, kindParsed.error, 400);
302
+ return true;
303
+ }
304
+ const kind: TriggerKind | undefined = kindParsed?.ok
305
+ ? kindParsed.kind
306
+ : undefined;
307
+ const workflowId = parseNonEmptyString(body.workflowId);
308
+ const workflowName = parseNonEmptyString(body.workflowName);
309
+ if (kind === "workflow" && !workflowId) {
310
+ error(res, "workflowId is required when kind is 'workflow'", 400);
311
+ return true;
312
+ }
313
+ const inputDraft: TriggerDraftInput = {
314
+ displayName:
315
+ typeof body.displayName === "string" ? body.displayName : undefined,
316
+ instructions:
317
+ typeof body.instructions === "string" ? body.instructions : undefined,
318
+ triggerType:
319
+ typeof body.triggerType === "string"
320
+ ? (body.triggerType as TriggerType)
321
+ : undefined,
322
+ wakeMode:
323
+ typeof body.wakeMode === "string"
324
+ ? (body.wakeMode as TriggerWakeMode)
325
+ : undefined,
326
+ enabled: !!(body.enabled ?? true),
327
+ createdBy: creator,
328
+ timezone: typeof body.timezone === "string" ? body.timezone : undefined,
329
+ intervalMs:
330
+ typeof body.intervalMs === "number" ? body.intervalMs : undefined,
331
+ scheduledAtIso:
332
+ typeof body.scheduledAtIso === "string"
333
+ ? body.scheduledAtIso
334
+ : undefined,
335
+ cronExpression:
336
+ typeof body.cronExpression === "string"
337
+ ? body.cronExpression
338
+ : undefined,
339
+ eventKind:
340
+ typeof body.eventKind === "string" ? body.eventKind : undefined,
341
+ maxRuns: typeof body.maxRuns === "number" ? body.maxRuns : undefined,
342
+ kind,
343
+ workflowId,
344
+ workflowName,
345
+ };
346
+ const normalized = normalizeTriggerDraft({
347
+ input: inputDraft,
348
+ fallback: {
349
+ displayName:
350
+ typeof body.displayName === "string" && trim(body.displayName)
351
+ ? trim(body.displayName)
352
+ : "New Trigger",
353
+ instructions:
354
+ typeof body.instructions === "string" ? trim(body.instructions) : "",
355
+ triggerType:
356
+ typeof body.triggerType === "string"
357
+ ? (body.triggerType as TriggerType)
358
+ : "interval",
359
+ wakeMode:
360
+ typeof body.wakeMode === "string"
361
+ ? (body.wakeMode as TriggerWakeMode)
362
+ : "inject_now",
363
+ enabled: body.enabled === undefined ? true : body.enabled === true,
364
+ createdBy: creator,
365
+ },
366
+ });
367
+ if (!normalized.draft) {
368
+ error(res, normalized.error ?? "Invalid trigger request", 400);
369
+ return true;
370
+ }
371
+
372
+ const existingTasks = await listTriggerTasks(runtime);
373
+ const activeCount = existingTasks.filter((task) => {
374
+ const trigger = readTriggerConfig(task);
375
+ return trigger?.enabled && trigger.createdBy === creator;
376
+ }).length;
377
+ const limit = getTriggerLimit(runtime);
378
+ if (activeCount >= limit) {
379
+ error(res, `Active trigger limit reached (${limit})`, 429);
380
+ return true;
381
+ }
382
+
383
+ const triggerId = stringToUuid(crypto.randomUUID());
384
+ const trigger = buildTriggerConfig({ draft: normalized.draft, triggerId });
385
+
386
+ const duplicate = existingTasks.find((task) => {
387
+ const existingTrigger = readTriggerConfig(task);
388
+ return (
389
+ existingTrigger?.enabled &&
390
+ existingTrigger.dedupeKey &&
391
+ existingTrigger.dedupeKey === trigger.dedupeKey
392
+ );
393
+ });
394
+ if (duplicate?.id) {
395
+ error(res, "Equivalent trigger already exists", 409);
396
+ return true;
397
+ }
398
+
399
+ const nowMs = Date.now();
400
+ const metadata = trigger.enabled
401
+ ? buildTriggerMetadata({ trigger, nowMs })
402
+ : ({
403
+ updatedAt: nowMs,
404
+ updateInterval: DISABLED_TRIGGER_INTERVAL_MS,
405
+ trigger: {
406
+ ...trigger,
407
+ nextRunAtMs: nowMs + DISABLED_TRIGGER_INTERVAL_MS,
408
+ },
409
+ } as TriggerTaskMetadata);
410
+ if (!metadata) {
411
+ error(res, "Unable to compute trigger schedule", 400);
412
+ return true;
413
+ }
414
+
415
+ const roomId = (
416
+ runtime.getService("AUTONOMY") as { getAutonomousRoomId?(): UUID } | null
417
+ )?.getAutonomousRoomId?.();
418
+ const taskId = await runtime.createTask({
419
+ name: TRIGGER_TASK_NAME,
420
+ description: trigger.displayName,
421
+ roomId,
422
+ tags: [...TRIGGER_TASK_TAGS],
423
+ metadata: metadata as Task["metadata"],
424
+ });
425
+ const created = await runtime.getTask(taskId);
426
+ const summary = created ? taskToTriggerSummary(created) : null;
427
+ if (!summary) {
428
+ error(res, "Trigger created but summary could not be generated", 500);
429
+ return true;
430
+ }
431
+ itemResponse(summary, 201);
432
+ return true;
433
+ }
434
+
435
+ const runsMatch = /^\/api\/triggers\/([^/]+)\/runs$/.exec(normalizedPathname);
436
+ if (method === "GET" && runsMatch) {
437
+ const task = await findTask(
438
+ runtime,
439
+ decodeURIComponent(runsMatch[1]),
440
+ listTriggerTasks,
441
+ readTriggerConfig,
442
+ );
443
+ if (!task) {
444
+ error(res, "Trigger not found", 404);
445
+ return true;
446
+ }
447
+ json(res, { runs: readTriggerRuns(task) });
448
+ return true;
449
+ }
450
+
451
+ // GET /api/triggers/:triggerId/runs/:runId/output
452
+ // Returns the agent's output for a specific run by cross-referencing
453
+ // autonomy-room memories created during the run's execution window.
454
+ // Lazy lookup so the runs list itself stays fast — the UI calls this
455
+ // only when a row is expanded.
456
+ const runOutputMatch = /^\/api\/triggers\/([^/]+)\/runs\/([^/]+)\/output$/.exec(
457
+ normalizedPathname,
458
+ );
459
+ if (method === "GET" && runOutputMatch) {
460
+ const triggerIdParam = decodeURIComponent(runOutputMatch[1] ?? "");
461
+ const runIdParam = decodeURIComponent(runOutputMatch[2] ?? "");
462
+ const task = await findTask(
463
+ runtime,
464
+ triggerIdParam,
465
+ listTriggerTasks,
466
+ readTriggerConfig,
467
+ );
468
+ if (!task) {
469
+ error(res, "Trigger not found", 404);
470
+ return true;
471
+ }
472
+ const runs = readTriggerRuns(task);
473
+ const run = runs.find((r) => r.triggerRunId === runIdParam);
474
+ if (!run) {
475
+ error(res, "Run not found", 404);
476
+ return true;
477
+ }
478
+ // Resolve the autonomy room id where instructions are dispatched.
479
+ type AutonomyServiceLike = {
480
+ getAutonomousRoomId?: () => UUID | undefined;
481
+ getTargetRoomId?: () => UUID | undefined;
482
+ };
483
+ const autonomyService =
484
+ runtime.getService<AutonomyServiceLike>("AUTONOMY") ??
485
+ runtime.getService<AutonomyServiceLike>("autonomy");
486
+ const roomId =
487
+ autonomyService?.getAutonomousRoomId?.() ??
488
+ autonomyService?.getTargetRoomId?.();
489
+ if (!roomId) {
490
+ json(res, {
491
+ output: null,
492
+ status: "no_autonomy_room",
493
+ message:
494
+ "Autonomy room is not configured — outputs from past runs cannot be resolved.",
495
+ });
496
+ return true;
497
+ }
498
+ // Window: from run start to 5 minutes after — captures the autonomy
499
+ // loop's response even if it took a while to produce.
500
+ const start = run.startedAt;
501
+ const end = run.startedAt + 5 * 60 * 1000;
502
+ type MemoryLike = {
503
+ id?: UUID;
504
+ entityId?: UUID;
505
+ createdAt?: number;
506
+ content?: {
507
+ text?: unknown;
508
+ source?: unknown;
509
+ metadata?: { isAutonomousInstruction?: unknown } & Record<string, unknown>;
510
+ };
511
+ };
512
+ const adapter = runtime as unknown as {
513
+ getMemories?: (params: {
514
+ tableName: string;
515
+ roomId: UUID;
516
+ start?: number;
517
+ end?: number;
518
+ count?: number;
519
+ }) => Promise<MemoryLike[]>;
520
+ };
521
+ // Agent responses from the autonomy loop are persisted to the
522
+ // "memories" table (see AutonomyService.processAutonomousMessage),
523
+ // while user-facing chat replies and the dispatched trigger instruction
524
+ // live in the "messages" table. Trigger outputs end up in BOTH
525
+ // depending on the autonomy mode in play — query both and merge so
526
+ // either path is captured.
527
+ let messages: MemoryLike[] = [];
528
+ if (typeof adapter.getMemories === "function") {
529
+ try {
530
+ const [fromMemories, fromMessages] = await Promise.all([
531
+ adapter
532
+ .getMemories({
533
+ tableName: "memories",
534
+ roomId,
535
+ start,
536
+ end,
537
+ count: 50,
538
+ })
539
+ .catch(() => [] as MemoryLike[]),
540
+ adapter
541
+ .getMemories({
542
+ tableName: "messages",
543
+ roomId,
544
+ start,
545
+ end,
546
+ count: 50,
547
+ })
548
+ .catch(() => [] as MemoryLike[]),
549
+ ]);
550
+ // Merge + dedupe by id (memories occasionally exist in both with
551
+ // matching ids when a chat reply is mirrored to the memories table).
552
+ const seen = new Set<string>();
553
+ for (const m of [...fromMemories, ...fromMessages]) {
554
+ const id = String(m.id ?? "");
555
+ if (id && seen.has(id)) continue;
556
+ if (id) seen.add(id);
557
+ messages.push(m);
558
+ }
559
+ } catch (err) {
560
+ // Fail soft — return null output rather than 500.
561
+ json(res, {
562
+ output: null,
563
+ status: "lookup_failed",
564
+ message: err instanceof Error ? err.message : String(err),
565
+ });
566
+ return true;
567
+ }
568
+ }
569
+ // Filter to agent-authored messages produced by the trigger pipeline.
570
+ // The trigger callback persists EVERY content with source="trigger-runtime"
571
+ // and metadata.type="trigger-response" (with runStage="action-result" or
572
+ // "final-response"). The original dispatched instruction has source
573
+ // "trigger-runtime" too but `isAutonomousInstruction: true` — exclude
574
+ // that one path.
575
+ const isDispatchedInstruction = (m: MemoryLike): boolean => {
576
+ const meta = m.content?.metadata as Record<string, unknown> | undefined;
577
+ return meta?.isAutonomousInstruction === true;
578
+ };
579
+ const isAutonomousPrompt = (m: MemoryLike): boolean => {
580
+ const metaType = (m.content?.metadata as Record<string, unknown> | undefined)
581
+ ?.type;
582
+ return metaType === "autonomous-prompt";
583
+ };
584
+ const candidate = messages
585
+ .filter(
586
+ (m) =>
587
+ m.entityId === runtime.agentId &&
588
+ !isDispatchedInstruction(m) &&
589
+ !isAutonomousPrompt(m) &&
590
+ typeof m.content?.text === "string" &&
591
+ (m.content.text as string).trim().length > 0,
592
+ )
593
+ .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
594
+ if (candidate.length === 0) {
595
+ // Run finished but nothing matched. Surface a peek of what WAS in
596
+ // the window so we can see whether the agent is writing to a path
597
+ // the filter doesn't recognize (different entityId, different
598
+ // metadata shape, etc).
599
+ const peek = messages.slice(0, 10).map((m) => ({
600
+ entityIdMatches: m.entityId === runtime.agentId,
601
+ source: typeof m.content?.source === "string" ? m.content.source : null,
602
+ metadataType:
603
+ (m.content?.metadata as Record<string, unknown> | undefined)?.type ??
604
+ null,
605
+ textPreview:
606
+ typeof m.content?.text === "string"
607
+ ? m.content.text.slice(0, 80)
608
+ : null,
609
+ createdAt: m.createdAt ?? null,
610
+ }));
611
+ json(res, {
612
+ output: null,
613
+ status:
614
+ run.status === "skipped"
615
+ ? "skipped"
616
+ : Date.now() - run.startedAt < 120_000
617
+ ? "still_processing"
618
+ : "no_output",
619
+ diagnostics: {
620
+ memoriesInWindow: messages.length,
621
+ agentId: runtime.agentId,
622
+ roomId,
623
+ windowStart: start,
624
+ windowEnd: end,
625
+ peek,
626
+ },
627
+ });
628
+ return true;
629
+ }
630
+ // Split into action results and the final response so the UI can
631
+ // render the actual tool outputs (Tavily JSON, fetched pages, etc.)
632
+ // separately from the LLM's natural-language reply — vital when the
633
+ // LLM paraphrases or refuses despite the action succeeding.
634
+ type Segment = { stage: "action" | "final"; action?: string; text: string };
635
+ const segments: Segment[] = candidate.map((m) => {
636
+ const meta = m.content?.metadata as Record<string, unknown> | undefined;
637
+ const runStage = meta?.runStage === "action-result" ? "action" : "final";
638
+ const action =
639
+ typeof meta?.actionName === "string"
640
+ ? (meta.actionName as string)
641
+ : undefined;
642
+ return {
643
+ stage: runStage,
644
+ action,
645
+ text: String(m.content?.text ?? ""),
646
+ };
647
+ });
648
+ // Format a combined text view (back-compat for clients reading
649
+ // `output.text`) — action results first, then the final summary, so
650
+ // raw tool output stays visible above the LLM narration.
651
+ const MAX_BYTES = 4096;
652
+ const combinedParts: string[] = [];
653
+ for (const s of segments) {
654
+ if (s.stage === "action") {
655
+ const header = s.action ? `[${s.action}]` : "[action]";
656
+ combinedParts.push(`${header}\n${s.text}`);
657
+ } else {
658
+ combinedParts.push(`[agent]\n${s.text}`);
659
+ }
660
+ }
661
+ const combined = combinedParts.join("\n\n---\n\n");
662
+ const byteLength = Buffer.byteLength(combined, "utf8");
663
+ const text =
664
+ byteLength > MAX_BYTES
665
+ ? `${combined.slice(0, MAX_BYTES)}\n…[truncated — original ${byteLength} bytes]`
666
+ : combined;
667
+ json(res, {
668
+ output: { text, truncated: byteLength > MAX_BYTES },
669
+ status: "ready",
670
+ messageCount: candidate.length,
671
+ segments,
672
+ });
673
+ return true;
674
+ }
675
+
676
+ const execMatch = /^\/api\/triggers\/([^/]+)\/execute$/.exec(
677
+ normalizedPathname,
678
+ );
679
+ if (method === "POST" && execMatch) {
680
+ const task = await findTask(
681
+ runtime,
682
+ decodeURIComponent(execMatch[1]),
683
+ listTriggerTasks,
684
+ readTriggerConfig,
685
+ );
686
+ if (!task) {
687
+ error(res, "Trigger not found", 404);
688
+ return true;
689
+ }
690
+ const result: TriggerExecutionResult = await executeTriggerTask(
691
+ runtime,
692
+ task,
693
+ {
694
+ source: "manual",
695
+ force: true,
696
+ },
697
+ );
698
+ const refreshed = task.id ? await runtime.getTask(task.id) : null;
699
+ const summary = refreshed
700
+ ? taskToTriggerSummary(refreshed)
701
+ : (result.trigger ?? null);
702
+ json(
703
+ res,
704
+ usingHeartbeatsAlias
705
+ ? { ok: true, result, trigger: summary, heartbeat: summary }
706
+ : { ok: true, result, trigger: summary },
707
+ );
708
+ return true;
709
+ }
710
+
711
+ const eventMatch = /^\/api\/triggers\/events\/([^/]+)$/.exec(
712
+ normalizedPathname,
713
+ );
714
+ if (method === "POST" && eventMatch) {
715
+ const eventKind = decodeURIComponent(eventMatch[1] ?? "").trim();
716
+ if (!eventKind) {
717
+ error(res, "event kind is required", 400);
718
+ return true;
719
+ }
720
+
721
+ const body = await readJsonBody<Record<string, unknown>>(req, res);
722
+ if (!body) return true;
723
+ const payload = parseEventPayload(body.payload ?? body);
724
+ const tasks = await listTriggerTasks(runtime);
725
+ const matchingTasks = tasks.filter((task) => {
726
+ const trigger = readTriggerConfig(task);
727
+ return (
728
+ trigger?.enabled === true &&
729
+ trigger.triggerType === "event" &&
730
+ trigger.eventKind === eventKind
731
+ );
732
+ });
733
+ const results = [];
734
+ for (const task of matchingTasks) {
735
+ const result = await executeTriggerTask(runtime, task, {
736
+ source: "event",
737
+ event: { kind: eventKind, payload },
738
+ });
739
+ const refreshed = task.id ? await runtime.getTask(task.id) : null;
740
+ results.push({
741
+ taskId: task.id,
742
+ result,
743
+ trigger: refreshed
744
+ ? taskToTriggerSummary(refreshed)
745
+ : (result.trigger ?? null),
746
+ });
747
+ }
748
+ json(res, {
749
+ ok: true,
750
+ eventKind,
751
+ matched: matchingTasks.length,
752
+ results,
753
+ });
754
+ return true;
755
+ }
756
+
757
+ const itemMatch = /^\/api\/triggers\/([^/]+)$/.exec(normalizedPathname);
758
+ if (!itemMatch) return false;
759
+ const triggerId = decodeURIComponent(itemMatch[1]);
760
+
761
+ if (method === "GET") {
762
+ const task = await findTask(
763
+ runtime,
764
+ triggerId,
765
+ listTriggerTasks,
766
+ readTriggerConfig,
767
+ );
768
+ if (!task) {
769
+ error(res, "Trigger not found", 404);
770
+ return true;
771
+ }
772
+ const summary = taskToTriggerSummary(task);
773
+ if (!summary) {
774
+ error(res, "Trigger metadata is invalid", 500);
775
+ return true;
776
+ }
777
+ itemResponse(summary);
778
+ return true;
779
+ }
780
+
781
+ if (method === "DELETE") {
782
+ const task = await findTask(
783
+ runtime,
784
+ triggerId,
785
+ listTriggerTasks,
786
+ readTriggerConfig,
787
+ );
788
+ if (!task?.id) {
789
+ error(res, "Trigger not found", 404);
790
+ return true;
791
+ }
792
+ await runtime.deleteTask(task.id);
793
+ json(res, { ok: true });
794
+ return true;
795
+ }
796
+
797
+ if (method === "PUT") {
798
+ const task = await findTask(
799
+ runtime,
800
+ triggerId,
801
+ listTriggerTasks,
802
+ readTriggerConfig,
803
+ );
804
+ if (!task?.id) {
805
+ error(res, "Trigger not found", 404);
806
+ return true;
807
+ }
808
+ const current = readTriggerConfig(task);
809
+ if (!current) {
810
+ error(res, "Trigger metadata is invalid", 500);
811
+ return true;
812
+ }
813
+
814
+ const body = await readJsonBody<Record<string, unknown>>(req, res);
815
+ if (!body) return true;
816
+
817
+ const kindParsed = parseTriggerKindStrict(body.kind);
818
+ if (kindParsed !== undefined && kindParsed.ok === false) {
819
+ error(res, kindParsed.error, 400);
820
+ return true;
821
+ }
822
+ const parsedKind: TriggerKind | undefined = kindParsed?.ok
823
+ ? kindParsed.kind
824
+ : undefined;
825
+ const nextKind: TriggerKind | undefined =
826
+ parsedKind ?? parseTriggerKind(current.kind);
827
+ const nextWorkflowId =
828
+ parseNonEmptyString(body.workflowId) ?? current.workflowId;
829
+ const nextWorkflowName =
830
+ parseNonEmptyString(body.workflowName) ?? current.workflowName;
831
+ if (nextKind === "workflow" && !nextWorkflowId) {
832
+ error(res, "workflowId is required when kind is 'workflow'", 400);
833
+ return true;
834
+ }
835
+
836
+ const mergedInput: TriggerDraftInput = {
837
+ displayName:
838
+ typeof body.displayName === "string" ? body.displayName : undefined,
839
+ instructions:
840
+ typeof body.instructions === "string" ? body.instructions : undefined,
841
+ triggerType:
842
+ typeof body.triggerType === "string"
843
+ ? (body.triggerType as TriggerType)
844
+ : undefined,
845
+ wakeMode:
846
+ typeof body.wakeMode === "string"
847
+ ? (body.wakeMode as TriggerWakeMode)
848
+ : undefined,
849
+ enabled:
850
+ body.enabled === undefined ? current.enabled : body.enabled === true,
851
+ createdBy: current.createdBy,
852
+ timezone: typeof body.timezone === "string" ? body.timezone : undefined,
853
+ intervalMs:
854
+ typeof body.intervalMs === "number"
855
+ ? body.intervalMs
856
+ : current.intervalMs,
857
+ scheduledAtIso:
858
+ typeof body.scheduledAtIso === "string"
859
+ ? body.scheduledAtIso
860
+ : current.scheduledAtIso,
861
+ cronExpression:
862
+ typeof body.cronExpression === "string"
863
+ ? body.cronExpression
864
+ : current.cronExpression,
865
+ eventKind:
866
+ typeof body.eventKind === "string" ? body.eventKind : current.eventKind,
867
+ maxRuns:
868
+ typeof body.maxRuns === "number" ? body.maxRuns : current.maxRuns,
869
+ kind: nextKind,
870
+ workflowId: nextWorkflowId,
871
+ workflowName: nextWorkflowName,
872
+ };
873
+ const normalized = normalizeTriggerDraft({
874
+ input: mergedInput,
875
+ fallback: {
876
+ displayName: current.displayName,
877
+ instructions: current.instructions,
878
+ triggerType: current.triggerType,
879
+ wakeMode: current.wakeMode,
880
+ enabled:
881
+ body.enabled === undefined ? current.enabled : body.enabled === true,
882
+ createdBy: current.createdBy,
883
+ },
884
+ });
885
+ if (!normalized.draft) {
886
+ error(res, normalized.error ?? "Invalid update", 400);
887
+ return true;
888
+ }
889
+
890
+ const nextTrigger = buildTriggerConfig({
891
+ draft: normalized.draft,
892
+ triggerId: current.triggerId,
893
+ previous: current,
894
+ });
895
+ const existingMeta = (task.metadata ?? {}) as TriggerTaskMetadata;
896
+ const existingRuns = readTriggerRuns(task);
897
+
898
+ let nextMeta: TriggerTaskMetadata;
899
+ if (!nextTrigger.enabled) {
900
+ nextMeta = {
901
+ ...existingMeta,
902
+ updatedAt: Date.now(),
903
+ updateInterval: DISABLED_TRIGGER_INTERVAL_MS,
904
+ trigger: {
905
+ ...nextTrigger,
906
+ nextRunAtMs: Date.now() + DISABLED_TRIGGER_INTERVAL_MS,
907
+ },
908
+ triggerRuns: existingRuns,
909
+ };
910
+ } else {
911
+ const built = buildTriggerMetadata({
912
+ existingMetadata: existingMeta,
913
+ trigger: nextTrigger,
914
+ nowMs: Date.now(),
915
+ });
916
+ if (!built) {
917
+ error(res, "Unable to compute trigger schedule", 400);
918
+ return true;
919
+ }
920
+ nextMeta = built;
921
+ }
922
+
923
+ await runtime.updateTask(task.id, {
924
+ description: nextTrigger.displayName,
925
+ metadata: nextMeta as Task["metadata"],
926
+ });
927
+ const refreshed = await runtime.getTask(task.id);
928
+ if (!refreshed) {
929
+ error(res, "Trigger updated but no longer available", 500);
930
+ return true;
931
+ }
932
+ const summary = taskToTriggerSummary(refreshed);
933
+ if (!summary) {
934
+ error(res, "Trigger metadata is invalid", 500);
935
+ return true;
936
+ }
937
+ itemResponse(summary);
938
+ return true;
939
+ }
940
+
941
+ return false;
942
+ }