@dainprotocol/service-sdk 2.0.87 → 2.0.88

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.
@@ -32,6 +32,21 @@ function debugWarn(...args) {
32
32
  console.warn(...args);
33
33
  }
34
34
  }
35
+ function getFirstForwardedValue(value) {
36
+ if (!value)
37
+ return undefined;
38
+ const first = value.split(",")[0]?.trim();
39
+ return first || undefined;
40
+ }
41
+ function normalizeForwardedPrefix(prefix) {
42
+ if (!prefix)
43
+ return undefined;
44
+ const trimmed = prefix.trim();
45
+ if (!trimmed || trimmed === "/")
46
+ return undefined;
47
+ const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
48
+ return normalized.replace(/\/+$/, "");
49
+ }
35
50
  /**
36
51
  * Safely parse JSON body from request, returning empty object on failure.
37
52
  */
@@ -63,23 +78,30 @@ function requireScope(requiredScope) {
63
78
  // Helper to sign and stream SSE events
64
79
  function signedStreamSSE(c, privateKey, config, handler) {
65
80
  return (0, streaming_1.streamSSE)(c, async (stream) => {
81
+ const requestSignal = c.req.raw?.signal;
82
+ const isAborted = () => stream.aborted || stream.closed || requestSignal?.aborted === true;
66
83
  const signedStream = {
67
84
  writeSSE: async (event) => {
85
+ if (isAborted())
86
+ return;
68
87
  const timestamp = Date.now().toString();
69
88
  const message = `${event.data}:${timestamp}`;
70
89
  const messageHash = (0, sha256_1.sha256)(message);
71
90
  const signatureBytes = ed25519_1.ed25519.sign(messageHash, privateKey);
72
91
  const signature = (0, utils_1.bytesToHex)(signatureBytes);
73
92
  // Fast path for non-critical events (progress/UI updates)
74
- const isCriticalEvent = event.event === 'result' || event.event === 'process-created';
93
+ const isCriticalEvent = event.event === 'result' ||
94
+ event.event === 'process-created' ||
95
+ event.event === 'datasource-update';
75
96
  const dataWithSignature = isCriticalEvent
76
97
  ? JSON.stringify({
77
98
  data: JSON.parse(event.data),
78
99
  _signature: { signature, timestamp, agentId: config.identity.agentId, orgId: config.identity.orgId, address: config.identity.publicKey }
79
100
  })
80
101
  : `{"data":${event.data},"_signature":{"signature":"${signature}","timestamp":"${timestamp}","agentId":"${config.identity.agentId}","orgId":"${config.identity.orgId}","address":"${config.identity.publicKey}"}}`;
81
- return stream.writeSSE({ event: event.event, data: dataWithSignature, id: event.id });
82
- }
102
+ await stream.writeSSE({ event: event.event, data: dataWithSignature, id: event.id });
103
+ },
104
+ isAborted,
83
105
  };
84
106
  await handler(signedStream);
85
107
  });
@@ -159,11 +181,7 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
159
181
  const isHITLPath = hitlPath && c.req.path === hitlPath;
160
182
  if (c.req.path.startsWith("/oauth2/callback/") ||
161
183
  c.req.path.startsWith("/addons") ||
162
- c.req.path.startsWith("/getAllToolsAsJsonSchema") ||
163
- c.req.path.startsWith("/getSkills") ||
164
- c.req.path.startsWith("/getWebhookTriggers") ||
165
184
  c.req.path.startsWith("/metadata") ||
166
- c.req.path.startsWith("/recommendations") ||
167
185
  c.req.path.startsWith("/ping") ||
168
186
  isWebhookPath ||
169
187
  isHITLPath) {
@@ -177,8 +195,9 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
177
195
  debugLog(`[Auth Middleware] JWT present: ${!!jwtToken}, API Key present: ${!!apiKey}`);
178
196
  if (jwtToken) {
179
197
  // JWT Authentication (Users)
180
- // Auto-detect JWT audience from Host header (works with localhost, tunnels, production)
181
- const host = c.req.header("x-forwarded-host") || c.req.header("host");
198
+ // Auto-detect JWT audience from forwarded headers (works with localhost, path-based tunnels, production)
199
+ const host = getFirstForwardedValue(c.req.header("x-forwarded-host")) ||
200
+ getFirstForwardedValue(c.req.header("host"));
182
201
  if (!host) {
183
202
  throw new http_exception_1.HTTPException(400, { message: "Unable to determine service URL. Host header is required." });
184
203
  }
@@ -186,22 +205,33 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
186
205
  if (host.includes(' ') || host.includes('\n') || host.includes('\r')) {
187
206
  throw new http_exception_1.HTTPException(400, { message: "Invalid Host header format" });
188
207
  }
189
- const xForwardedProto = c.req.header("x-forwarded-proto");
190
- const protocol = xForwardedProto || (host.includes("localhost") ? "http" : "https");
191
- const jwtAudience = `${protocol}://${host}`;
208
+ const xForwardedProto = getFirstForwardedValue(c.req.header("x-forwarded-proto"));
209
+ const protocol = xForwardedProto === "http" || xForwardedProto === "https"
210
+ ? xForwardedProto
211
+ : (host.includes("localhost") || host.startsWith("127.") ? "http" : "https");
212
+ const forwardedPrefix = normalizeForwardedPrefix(getFirstForwardedValue(c.req.header("x-forwarded-prefix")));
213
+ const jwtAudiences = [`${protocol}://${host}`];
214
+ if (forwardedPrefix) {
215
+ jwtAudiences.push(`${protocol}://${host}${forwardedPrefix}`);
216
+ }
192
217
  const publicKeyOrUrl = config.jwtPublicKey || config.dainIdUrl || "https://id.dain.org";
193
218
  const issuer = config.jwtIssuer || "dainid-oauth";
194
219
  const result = await (0, auth_1.verifyJWT)(jwtToken, publicKeyOrUrl, {
195
220
  issuer: issuer,
196
- audience: jwtAudience,
221
+ audience: jwtAudiences,
197
222
  });
198
223
  if (!result.valid) {
199
224
  throw new http_exception_1.HTTPException(401, { message: `JWT verification failed: ${result.error}` });
200
225
  }
201
226
  // Defense in depth: double-check audience claim
202
- if (result.payload.aud !== jwtAudience) {
227
+ const tokenAudiences = Array.isArray(result.payload?.aud)
228
+ ? result.payload.aud
229
+ : [result.payload?.aud];
230
+ const expectedAudienceSet = new Set(jwtAudiences);
231
+ const audienceMatched = tokenAudiences.some((aud) => aud && expectedAudienceSet.has(aud));
232
+ if (!audienceMatched) {
203
233
  throw new http_exception_1.HTTPException(403, {
204
- message: `JWT audience mismatch. Expected: ${jwtAudience}, Got: ${result.payload.aud}`
234
+ message: `JWT audience mismatch. Expected one of: ${jwtAudiences.join(", ")}, Got: ${tokenAudiences.filter(Boolean).join(", ")}`
205
235
  });
206
236
  }
207
237
  c.set('authMethod', 'jwt');
@@ -279,9 +309,49 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
279
309
  app.get("/metadata", (c) => {
280
310
  // Compute service-level capability: does ANY tool support user actions (HITL)?
281
311
  const supportsUserActions = tools.some((tool) => tool.supportsUserActions === true);
312
+ const requestedContract = c.req.header("x-butterfly-contract") || c.req.header("X-Butterfly-Contract");
313
+ const sdkMajor = Number.parseInt(String(package_json_1.default.version).split(".")[0] || "0", 10);
314
+ const contractVersion = Number.isFinite(sdkMajor) && sdkMajor > 0 ? `${sdkMajor}.0.0` : "0.0.0";
315
+ const capabilities = {
316
+ tools: true,
317
+ contexts: true,
318
+ widgets: true,
319
+ datasources: true,
320
+ streamingTools: true,
321
+ streamingDatasources: true,
322
+ datasourcePolicy: true,
323
+ widgetPolicy: true,
324
+ toolSafety: true,
325
+ };
326
+ const compatibility = (() => {
327
+ if (!requestedContract)
328
+ return undefined;
329
+ const requestedMajor = Number.parseInt(String(requestedContract).split(".")[0] || "0", 10);
330
+ if (!Number.isFinite(requestedMajor) || requestedMajor <= 0) {
331
+ return {
332
+ requested: requestedContract,
333
+ contractVersion,
334
+ ok: false,
335
+ reason: "Invalid requested contract version",
336
+ };
337
+ }
338
+ const ok = requestedMajor === sdkMajor;
339
+ return ok
340
+ ? { requested: requestedContract, contractVersion, ok: true }
341
+ : {
342
+ requested: requestedContract,
343
+ contractVersion,
344
+ ok: false,
345
+ reason: `Requested major ${requestedMajor} does not match service major ${sdkMajor}`,
346
+ };
347
+ })();
282
348
  return c.json({
283
349
  ...metadata,
284
350
  supportsUserActions,
351
+ dainSdkVersion: package_json_1.default.version,
352
+ contractVersion,
353
+ capabilities,
354
+ ...(compatibility ? { compatibility } : {}),
285
355
  });
286
356
  });
287
357
  // Tools list endpoint
@@ -300,6 +370,10 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
300
370
  outputSchema,
301
371
  interface: tool.interface,
302
372
  suggestConfirmation: tool.suggestConfirmation,
373
+ sideEffectClass: tool.sideEffectClass,
374
+ supportsParallel: tool.supportsParallel,
375
+ idempotencyScope: tool.idempotencyScope,
376
+ maxConcurrencyHint: tool.maxConcurrencyHint,
303
377
  supportsUserActions: tool.supportsUserActions,
304
378
  };
305
379
  });
@@ -400,14 +474,26 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
400
474
  oauth2Client,
401
475
  app
402
476
  });
403
- return {
477
+ const response = {
404
478
  id: widget.id,
405
479
  name: widget.name,
406
480
  description: widget.description,
407
481
  icon: widget.icon,
408
482
  size: widget.size || "sm",
483
+ refreshIntervalMs: widget.refreshIntervalMs,
409
484
  ...widgetData
410
485
  };
486
+ if (!("freshness" in response)) {
487
+ response.freshness = {
488
+ freshAt: Date.now(),
489
+ transport: "poll",
490
+ requestedPolicy: {
491
+ refreshIntervalMs: widget.refreshIntervalMs,
492
+ },
493
+ scope: "account",
494
+ };
495
+ }
496
+ return response;
411
497
  }));
412
498
  const validWidgets = widgetsFull.filter(w => w !== null);
413
499
  const processedResponse = await processPluginsForResponse(validWidgets, body, { extraData: { plugins: processedPluginData.plugins } });
@@ -448,8 +534,19 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
448
534
  description: widget.description,
449
535
  icon: widget.icon,
450
536
  size: widget.size || "sm",
537
+ refreshIntervalMs: widget.refreshIntervalMs,
451
538
  ...widgetData
452
539
  };
540
+ if (!("freshness" in response)) {
541
+ response.freshness = {
542
+ freshAt: Date.now(),
543
+ transport: "poll",
544
+ requestedPolicy: {
545
+ refreshIntervalMs: widget.refreshIntervalMs,
546
+ },
547
+ scope: "account",
548
+ };
549
+ }
453
550
  const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
454
551
  return c.json(processedResponse);
455
552
  });
@@ -479,6 +576,12 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
479
576
  name: datasource.name,
480
577
  description: datasource.description,
481
578
  type: datasource.type,
579
+ refreshIntervalMs: datasource.refreshIntervalMs,
580
+ maxStalenessMs: datasource.maxStalenessMs,
581
+ transport: datasource.transport,
582
+ priority: datasource.priority,
583
+ scope: datasource.scope,
584
+ dataClass: datasource.dataClass,
482
585
  inputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(datasource.input),
483
586
  };
484
587
  }
@@ -516,12 +619,32 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
516
619
  plugins: pluginsData,
517
620
  oauth2Client
518
621
  });
622
+ const requestedPolicy = {
623
+ refreshIntervalMs: datasource.refreshIntervalMs,
624
+ maxStalenessMs: datasource.maxStalenessMs,
625
+ transport: datasource.transport,
626
+ priority: datasource.priority,
627
+ scope: datasource.scope,
628
+ dataClass: datasource.dataClass,
629
+ };
519
630
  const response = {
520
631
  id: datasource.id,
521
632
  name: datasource.name,
522
633
  description: datasource.description,
523
634
  type: datasource.type,
635
+ refreshIntervalMs: datasource.refreshIntervalMs,
636
+ maxStalenessMs: datasource.maxStalenessMs,
637
+ transport: datasource.transport,
638
+ priority: datasource.priority,
639
+ scope: datasource.scope,
640
+ dataClass: datasource.dataClass,
524
641
  data,
642
+ freshness: {
643
+ freshAt: Date.now(),
644
+ transport: datasource.transport || "poll",
645
+ requestedPolicy,
646
+ scope: datasource.scope,
647
+ },
525
648
  };
526
649
  const processedResponse = await processPluginsForResponse(response, { plugins: pluginsData }, { extraData: { plugins: pluginsData } });
527
650
  return c.json(processedResponse);
@@ -539,6 +662,149 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
539
662
  throw error;
540
663
  }
541
664
  });
665
+ // Stream datasource updates over SSE. This is primarily used for "fresh" UIs
666
+ // (positions/orders) where clients want stream-first updates with a poll fallback.
667
+ //
668
+ // Note: This does not require services to implement a separate streaming backend.
669
+ // The server re-runs the datasource handler on an interval and streams results.
670
+ app.post("/datasources/:datasourceId/stream", async (c) => {
671
+ const datasource = datasources.find((ds) => ds.id === c.req.param("datasourceId"));
672
+ if (!datasource) {
673
+ throw new http_exception_1.HTTPException(404, { message: "Datasource not found" });
674
+ }
675
+ const agentInfo = await getAgentInfo(c);
676
+ const rawBody = await safeParseBody(c);
677
+ const hasWrappedParams = rawBody &&
678
+ typeof rawBody === "object" &&
679
+ !Array.isArray(rawBody) &&
680
+ "params" in rawBody;
681
+ const requestedIntervalMsRaw = hasWrappedParams ? rawBody.intervalMs : undefined;
682
+ const requestParamsRaw = hasWrappedParams ? rawBody.params : rawBody;
683
+ let params = await processPluginsForRequest(requestParamsRaw && typeof requestParamsRaw === "object" ? requestParamsRaw : {}, agentInfo);
684
+ const pluginsData = (params.plugins && typeof params.plugins === "object"
685
+ ? params.plugins
686
+ : {});
687
+ delete params.plugins;
688
+ let parsedParams;
689
+ try {
690
+ parsedParams = datasource.input.parse(params);
691
+ }
692
+ catch (error) {
693
+ if (error instanceof zod_1.z.ZodError) {
694
+ const missingParams = error.issues
695
+ .map((issue) => issue.path.join("."))
696
+ .join(", ");
697
+ return c.json({
698
+ error: `Missing or invalid parameters: ${missingParams}`,
699
+ code: "INVALID_PARAMS"
700
+ }, 400);
701
+ }
702
+ throw error;
703
+ }
704
+ const pluginRequestContext = hasWrappedParams
705
+ ? {
706
+ params: parsedParams,
707
+ intervalMs: requestedIntervalMsRaw,
708
+ plugins: pluginsData,
709
+ }
710
+ : {
711
+ ...(typeof parsedParams === "object" && parsedParams !== null
712
+ ? parsedParams
713
+ : {}),
714
+ plugins: pluginsData,
715
+ };
716
+ const oauth2Client = app.oauth2?.getClient();
717
+ const requestedPolicy = {
718
+ refreshIntervalMs: datasource.refreshIntervalMs,
719
+ maxStalenessMs: datasource.maxStalenessMs,
720
+ transport: datasource.transport,
721
+ priority: datasource.priority,
722
+ scope: datasource.scope,
723
+ dataClass: datasource.dataClass,
724
+ };
725
+ const requestedIntervalMs = typeof requestedIntervalMsRaw === "number" && Number.isFinite(requestedIntervalMsRaw) && requestedIntervalMsRaw > 0
726
+ ? requestedIntervalMsRaw
727
+ : null;
728
+ const baseIntervalMs = requestedIntervalMs ??
729
+ (typeof datasource.refreshIntervalMs === "number" && datasource.refreshIntervalMs > 0
730
+ ? datasource.refreshIntervalMs
731
+ : 15_000);
732
+ const intervalMs = Math.max(1_000, Math.floor(baseIntervalMs));
733
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
734
+ debugLog(`[SSE] Datasource ${datasource.id} stream start (${intervalMs}ms interval)`);
735
+ return signedStreamSSE(c, privateKey, config, async (stream) => {
736
+ let eventId = 0;
737
+ const emitUpdate = async () => {
738
+ const data = await datasource.getDatasource(agentInfo, parsedParams, {
739
+ plugins: pluginsData,
740
+ oauth2Client,
741
+ });
742
+ const response = {
743
+ id: datasource.id,
744
+ name: datasource.name,
745
+ description: datasource.description,
746
+ type: datasource.type,
747
+ refreshIntervalMs: datasource.refreshIntervalMs,
748
+ maxStalenessMs: datasource.maxStalenessMs,
749
+ transport: datasource.transport,
750
+ priority: datasource.priority,
751
+ scope: datasource.scope,
752
+ dataClass: datasource.dataClass,
753
+ data,
754
+ freshness: {
755
+ freshAt: Date.now(),
756
+ transport: "stream",
757
+ requestedPolicy,
758
+ scope: datasource.scope,
759
+ },
760
+ };
761
+ const processedResponse = await processPluginsForResponse(response, pluginRequestContext, { extraData: { plugins: pluginsData } });
762
+ await stream.writeSSE({
763
+ event: "datasource-update",
764
+ data: JSON.stringify(processedResponse),
765
+ id: String(eventId++),
766
+ });
767
+ };
768
+ // Send initial snapshot immediately.
769
+ try {
770
+ await emitUpdate();
771
+ }
772
+ catch (error) {
773
+ console.error(`[SSE] Datasource ${datasource.id} initial update failed:`, error);
774
+ if (!stream.isAborted()) {
775
+ await stream.writeSSE({
776
+ event: "error",
777
+ data: JSON.stringify({ message: error?.message || "Datasource stream error" }),
778
+ });
779
+ }
780
+ return;
781
+ }
782
+ // Continue sending updates until client disconnects.
783
+ // Hono exposes the underlying Request via c.req.raw, which includes an AbortSignal.
784
+ const signal = c.req.raw?.signal;
785
+ while (!stream.isAborted() && !signal?.aborted) {
786
+ await sleep(intervalMs);
787
+ if (stream.isAborted() || signal?.aborted)
788
+ break;
789
+ try {
790
+ await emitUpdate();
791
+ }
792
+ catch (error) {
793
+ if (stream.isAborted() || signal?.aborted)
794
+ break;
795
+ console.error(`[SSE] Datasource ${datasource.id} update failed:`, error);
796
+ // Keep the stream alive; clients should rely on poll fallback if needed.
797
+ if (!stream.isAborted()) {
798
+ await stream.writeSSE({
799
+ event: "error",
800
+ data: JSON.stringify({ message: error?.message || "Datasource stream error" }),
801
+ });
802
+ }
803
+ }
804
+ }
805
+ debugLog(`[SSE] Datasource ${datasource.id} stream end`);
806
+ });
807
+ });
542
808
  function mapAgentInfo(agent) {
543
809
  return {
544
810
  id: agent.id,
@@ -615,6 +881,10 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
615
881
  outputSchema,
616
882
  interface: tool.interface,
617
883
  suggestConfirmation: tool.suggestConfirmation,
884
+ sideEffectClass: tool.sideEffectClass,
885
+ supportsParallel: tool.supportsParallel,
886
+ idempotencyScope: tool.idempotencyScope,
887
+ maxConcurrencyHint: tool.maxConcurrencyHint,
618
888
  supportsUserActions: tool.supportsUserActions,
619
889
  };
620
890
  }
@@ -819,6 +1089,10 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
819
1089
  outputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.output),
820
1090
  interface: tool.interface,
821
1091
  suggestConfirmation: tool.suggestConfirmation,
1092
+ sideEffectClass: tool.sideEffectClass,
1093
+ supportsParallel: tool.supportsParallel,
1094
+ idempotencyScope: tool.idempotencyScope,
1095
+ maxConcurrencyHint: tool.maxConcurrencyHint,
822
1096
  };
823
1097
  return c.json(toolDetails);
824
1098
  }
@@ -846,6 +1120,11 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
846
1120
  pricing: tool.pricing,
847
1121
  inputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.input),
848
1122
  outputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.output),
1123
+ suggestConfirmation: tool.suggestConfirmation,
1124
+ sideEffectClass: tool.sideEffectClass,
1125
+ supportsParallel: tool.supportsParallel,
1126
+ idempotencyScope: tool.idempotencyScope,
1127
+ maxConcurrencyHint: tool.maxConcurrencyHint,
849
1128
  }
850
1129
  : {
851
1130
  id: toolId,
@@ -924,31 +1203,11 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
924
1203
  }
925
1204
  try {
926
1205
  await app.oauth2.handleCallback(code, state);
927
- return c.html(`
928
- <html>
929
- <body>
930
- <script>
931
- window.opener.postMessage({ type: 'oauth2-success', provider: '${provider}' }, '*');
932
- window.close();
933
- </script>
934
- <h1>Authentication successful! You can close this window.</h1>
935
- </body>
936
- </html>
937
- `);
1206
+ return c.html("<html><body><script>window.opener.postMessage({ type: 'oauth2-success', provider: '" + provider + "' }, '*');window.close();</script><h1>Authentication successful! You can close this window.</h1></body></html>");
938
1207
  }
939
1208
  catch (error) {
940
1209
  console.error("OAuth callback error:", error);
941
- return c.html(`
942
- <html>
943
- <body>
944
- <script>
945
- window.opener.postMessage({ type: 'oauth2-error', provider: '${provider}', error: '${error.message}' }, '*');
946
- window.close();
947
- </script>
948
- <h1>Authentication failed! You can close this window.</h1>
949
- </body>
950
- </html>
951
- `);
1210
+ return c.html("<html><body><script>window.opener.postMessage({ type: 'oauth2-error', provider: '" + provider + "', error: '" + error.message + "' }, '*');window.close();</script><h1>Authentication failed! You can close this window.</h1></body></html>");
952
1211
  }
953
1212
  });
954
1213
  // Make oauth2Handler available to tools
@@ -1175,64 +1434,231 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1175
1434
  });
1176
1435
  // Toolboxes list endpoint
1177
1436
  app.get("/toolboxes", (c) => c.json(toolboxes));
1178
- // Recommendations endpoint - returns cards for AI selection
1179
- app.get("/recommendations", (c) => {
1180
- const cards = [];
1437
+ function normalizeRecommendationText(value) {
1438
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1439
+ }
1440
+ function tokenizeRecommendationQuery(query) {
1441
+ const normalized = normalizeRecommendationText(query);
1442
+ if (!normalized)
1443
+ return [];
1444
+ return Array.from(new Set(normalized.split(/\s+/).filter((token) => token.length >= 2)));
1445
+ }
1446
+ function buildRecommendationIndex() {
1447
+ const index = new Map();
1181
1448
  for (const tool of tools) {
1182
- if (tool.recommendations?.length) {
1183
- for (const card of tool.recommendations) {
1184
- const hasDynamicSchema = !!card.inputSchema;
1185
- cards.push({
1186
- id: card.id,
1187
- tags: card.tags,
1188
- ui: hasDynamicSchema ? undefined : card.ui,
1189
- inputSchema: hasDynamicSchema ? (0, schemaStructure_1.zodToJsonSchema)(card.inputSchema) : undefined,
1190
- toolId: tool.id,
1191
- toolName: tool.name,
1192
- });
1449
+ if (!tool.recommendations?.length)
1450
+ continue;
1451
+ for (const card of tool.recommendations) {
1452
+ const scopedCardId = `${tool.id}:${card.id}`;
1453
+ if (index.has(scopedCardId)) {
1454
+ throw new Error(`Duplicate recommendation card ID "${scopedCardId}". Card IDs must be unique per tool.`);
1193
1455
  }
1456
+ const isDynamic = !!card.inputSchema;
1457
+ const inputSchema = isDynamic ? (0, schemaStructure_1.zodToJsonSchema)(card.inputSchema) : undefined;
1458
+ const searchText = normalizeRecommendationText([tool.id, tool.name, card.id, ...card.tags].join(" "));
1459
+ index.set(scopedCardId, {
1460
+ cardId: scopedCardId,
1461
+ localCardId: card.id,
1462
+ toolId: tool.id,
1463
+ toolName: tool.name,
1464
+ tags: card.tags,
1465
+ inputSchema,
1466
+ ui: isDynamic ? undefined : card.ui,
1467
+ isDynamic,
1468
+ card,
1469
+ searchText,
1470
+ });
1194
1471
  }
1195
1472
  }
1196
- return c.json({ cards, count: cards.length });
1197
- });
1198
- // Generate recommendation card UI
1199
- app.post("/recommendations/:id/generate", async (c) => {
1200
- const cardId = c.req.param("id");
1201
- const body = await c.req.json().catch(() => ({}));
1202
- const params = body.params ?? {};
1203
- const context = body.context ?? {};
1204
- let foundCard;
1205
- let foundTool;
1206
- for (const tool of tools) {
1207
- const card = tool.recommendations?.find((r) => r.id === cardId);
1208
- if (card) {
1209
- foundCard = card;
1210
- foundTool = tool;
1211
- break;
1473
+ return index;
1474
+ }
1475
+ function scoreRecommendationCard(card, tokens) {
1476
+ if (tokens.length === 0)
1477
+ return 0;
1478
+ let score = 0;
1479
+ const tagSet = new Set(card.tags.map((tag) => normalizeRecommendationText(tag)));
1480
+ const scopedId = normalizeRecommendationText(card.cardId);
1481
+ const toolName = normalizeRecommendationText(card.toolName);
1482
+ for (const token of tokens) {
1483
+ if (scopedId === token || card.localCardId.toLowerCase() === token) {
1484
+ score += 6;
1485
+ continue;
1486
+ }
1487
+ if (tagSet.has(token)) {
1488
+ score += 4;
1489
+ }
1490
+ if (toolName.includes(token)) {
1491
+ score += 3;
1492
+ }
1493
+ if (card.searchText.includes(token)) {
1494
+ score += 1;
1212
1495
  }
1213
1496
  }
1214
- if (!foundCard) {
1215
- return c.json({ error: `Card not found: ${cardId}` }, 404);
1216
- }
1217
- const baseResponse = { id: cardId, toolId: foundTool?.id, toolName: foundTool?.name };
1218
- if (!foundCard.inputSchema) {
1219
- return c.json({ ...baseResponse, ui: foundCard.ui });
1220
- }
1221
- const parseResult = foundCard.inputSchema.safeParse(params);
1222
- if (!parseResult.success) {
1223
- return c.json({ error: "Invalid parameters", details: parseResult.error.format() }, 400);
1224
- }
1225
- if (typeof foundCard.ui !== "function") {
1226
- return c.json({ error: `Card ${cardId} has inputSchema but ui is not a function` }, 500);
1227
- }
1228
- try {
1229
- const ui = await foundCard.ui(parseResult.data, context);
1230
- return c.json({ ...baseResponse, ui, params: parseResult.data });
1231
- }
1232
- catch (e) {
1233
- const message = e instanceof Error ? e.message : "Unknown error";
1234
- return c.json({ error: `Failed to generate UI: ${message}` }, 500);
1497
+ return score;
1498
+ }
1499
+ const recommendationCardsById = buildRecommendationIndex();
1500
+ const recommendationSearchSchema = zod_1.z.object({
1501
+ query: zod_1.z.string().optional(),
1502
+ limit: zod_1.z.number().int().min(1).max(50).optional().default(12),
1503
+ toolIds: zod_1.z.array(zod_1.z.string()).optional(),
1504
+ includeStaticUI: zod_1.z.boolean().optional().default(false),
1505
+ });
1506
+ app.post("/recommendations/search", async (c) => {
1507
+ const agentInfo = await getAgentInfo(c);
1508
+ const body = await safeParseBody(c);
1509
+ const processedPluginData = await processPluginsForRequest(body, agentInfo);
1510
+ const requestData = { ...processedPluginData };
1511
+ delete requestData.plugins;
1512
+ const parsed = recommendationSearchSchema.safeParse(requestData);
1513
+ if (!parsed.success) {
1514
+ return c.json({
1515
+ error: "Invalid recommendation search request",
1516
+ details: parsed.error.format(),
1517
+ }, 400);
1518
+ }
1519
+ const { query, limit, toolIds, includeStaticUI, } = parsed.data;
1520
+ const toolIdSet = toolIds?.length ? new Set(toolIds) : null;
1521
+ const filteredCards = Array.from(recommendationCardsById.values()).filter((card) => !toolIdSet || toolIdSet.has(card.toolId));
1522
+ const tokens = tokenizeRecommendationQuery(query ?? "");
1523
+ const ranked = filteredCards
1524
+ .map((card, index) => ({
1525
+ ...card,
1526
+ score: scoreRecommendationCard(card, tokens),
1527
+ index,
1528
+ }))
1529
+ .sort((a, b) => {
1530
+ if (tokens.length === 0)
1531
+ return a.index - b.index;
1532
+ if (b.score !== a.score)
1533
+ return b.score - a.score;
1534
+ if (a.toolName !== b.toolName)
1535
+ return a.toolName.localeCompare(b.toolName);
1536
+ return a.localCardId.localeCompare(b.localCardId);
1537
+ })
1538
+ .slice(0, limit);
1539
+ const cards = ranked.map((card) => ({
1540
+ cardId: card.cardId,
1541
+ localCardId: card.localCardId,
1542
+ toolId: card.toolId,
1543
+ toolName: card.toolName,
1544
+ tags: card.tags,
1545
+ score: card.score,
1546
+ inputSchema: card.inputSchema,
1547
+ ui: includeStaticUI && !card.isDynamic ? card.ui : undefined,
1548
+ isDynamic: card.isDynamic,
1549
+ }));
1550
+ const response = {
1551
+ cards,
1552
+ count: cards.length,
1553
+ query,
1554
+ };
1555
+ const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
1556
+ return c.json(processedResponse);
1557
+ });
1558
+ const recommendationRenderSchema = zod_1.z.object({
1559
+ cards: zod_1.z
1560
+ .array(zod_1.z.object({
1561
+ cardId: zod_1.z.string().min(1),
1562
+ params: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
1563
+ context: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
1564
+ }))
1565
+ .min(1)
1566
+ .max(8),
1567
+ continueOnError: zod_1.z.boolean().optional().default(true),
1568
+ });
1569
+ app.post("/recommendations/render", async (c) => {
1570
+ const agentInfo = await getAgentInfo(c);
1571
+ const body = await safeParseBody(c);
1572
+ const processedPluginData = await processPluginsForRequest(body, agentInfo);
1573
+ const requestData = { ...processedPluginData };
1574
+ delete requestData.plugins;
1575
+ const parsed = recommendationRenderSchema.safeParse(requestData);
1576
+ if (!parsed.success) {
1577
+ return c.json({
1578
+ error: "Invalid recommendation render request",
1579
+ details: parsed.error.format(),
1580
+ }, 400);
1581
+ }
1582
+ const { cards: requestedCards, continueOnError } = parsed.data;
1583
+ const renderedCards = [];
1584
+ const renderErrors = [];
1585
+ for (const requested of requestedCards) {
1586
+ const card = recommendationCardsById.get(requested.cardId);
1587
+ if (!card) {
1588
+ renderErrors.push({
1589
+ cardId: requested.cardId,
1590
+ code: "not_found",
1591
+ message: `Card not found: ${requested.cardId}`,
1592
+ });
1593
+ if (!continueOnError)
1594
+ break;
1595
+ continue;
1596
+ }
1597
+ if (!card.isDynamic) {
1598
+ renderedCards.push({
1599
+ cardId: card.cardId,
1600
+ localCardId: card.localCardId,
1601
+ toolId: card.toolId,
1602
+ toolName: card.toolName,
1603
+ tags: card.tags,
1604
+ ui: card.card.ui,
1605
+ });
1606
+ continue;
1607
+ }
1608
+ const paramsInput = requested.params ?? {};
1609
+ const parseResult = card.card.inputSchema.safeParse(paramsInput);
1610
+ if (!parseResult.success) {
1611
+ renderErrors.push({
1612
+ cardId: card.cardId,
1613
+ code: "invalid_params",
1614
+ message: "Invalid parameters",
1615
+ details: parseResult.error.format(),
1616
+ });
1617
+ if (!continueOnError)
1618
+ break;
1619
+ continue;
1620
+ }
1621
+ if (typeof card.card.ui !== "function") {
1622
+ renderErrors.push({
1623
+ cardId: card.cardId,
1624
+ code: "render_failed",
1625
+ message: `Card "${card.cardId}" is dynamic but has no UI generator function`,
1626
+ });
1627
+ if (!continueOnError)
1628
+ break;
1629
+ continue;
1630
+ }
1631
+ try {
1632
+ const ui = await card.card.ui(parseResult.data, requested.context ?? {});
1633
+ renderedCards.push({
1634
+ cardId: card.cardId,
1635
+ localCardId: card.localCardId,
1636
+ toolId: card.toolId,
1637
+ toolName: card.toolName,
1638
+ tags: card.tags,
1639
+ ui,
1640
+ params: parseResult.data,
1641
+ });
1642
+ }
1643
+ catch (error) {
1644
+ renderErrors.push({
1645
+ cardId: card.cardId,
1646
+ code: "render_failed",
1647
+ message: error instanceof Error ? error.message : "Failed to render recommendation card",
1648
+ });
1649
+ if (!continueOnError)
1650
+ break;
1651
+ }
1235
1652
  }
1653
+ const response = {
1654
+ cards: renderedCards,
1655
+ errors: renderErrors,
1656
+ renderedCount: renderedCards.length,
1657
+ requestedCount: requestedCards.length,
1658
+ };
1659
+ const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
1660
+ const statusCode = !continueOnError && renderErrors.length > 0 ? 400 : 200;
1661
+ return c.json(processedResponse, statusCode);
1236
1662
  });
1237
1663
  // Process request with plugins
1238
1664
  async function processPluginsForRequest(request, agentInfo) {