@vellumai/assistant 0.11.5 → 0.11.6-staging.1
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/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -58,18 +58,85 @@ export type LiveVoiceStepName =
|
|
|
58
58
|
export type LiveVoiceSessionOutcome = "completed" | "failed";
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
* protocol error code when a failure is what closed it.
|
|
61
|
+
* How far a session that produced NO turn actually got.
|
|
63
62
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
63
|
+
* A quarter of live-voice sessions end without a single turn, at a median of a
|
|
64
|
+
* few seconds. That is the closest thing the telemetry has to a "voice didn't
|
|
65
|
+
* work for me" rate, and a bare connect/close row cannot separate a denied
|
|
66
|
+
* microphone from a muted one from someone deliberately backing out. Those have
|
|
67
|
+
* completely different fixes, so the reason is what makes the rate actionable.
|
|
68
|
+
*
|
|
69
|
+
* The four values are ordered by how far the session got, and each one narrows
|
|
70
|
+
* the cause to a different layer:
|
|
71
|
+
*
|
|
72
|
+
* - `no_ready` never reached `active`: died in the credential preflight or
|
|
73
|
+
* the transport, before the user could have said anything.
|
|
74
|
+
* - `no_audio` reached `active` but not one audio chunk ever arrived, so the
|
|
75
|
+
* microphone never opened. Permission denial looks like this.
|
|
76
|
+
* - `no_speech` audio arrived but the detector never classified any of it as
|
|
77
|
+
* speech: a muted or dead mic, or a genuinely silent user.
|
|
78
|
+
* - `no_turn` speech was detected but no turn was ever dispatched, so the
|
|
79
|
+
* utterance was abandoned, aborted, or failed to arm.
|
|
80
|
+
*/
|
|
81
|
+
export type LiveVoiceSilenceReason =
|
|
82
|
+
| "no_ready"
|
|
83
|
+
| "no_audio"
|
|
84
|
+
| "no_speech"
|
|
85
|
+
| "no_turn";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Classify a zero-turn session from what the session observed. Call only when
|
|
89
|
+
* the session really produced no turn: every branch here asserts silence, so a
|
|
90
|
+
* session that did dispatch a turn would be mislabelled by the last one.
|
|
91
|
+
*/
|
|
92
|
+
export function liveVoiceSilenceReason(signals: {
|
|
93
|
+
reachedActive: boolean;
|
|
94
|
+
receivedAudio: boolean;
|
|
95
|
+
detectedSpeech: boolean;
|
|
96
|
+
}): LiveVoiceSilenceReason {
|
|
97
|
+
if (!signals.reachedActive) {
|
|
98
|
+
return "no_ready";
|
|
99
|
+
}
|
|
100
|
+
if (!signals.receivedAudio) {
|
|
101
|
+
return "no_audio";
|
|
102
|
+
}
|
|
103
|
+
if (!signals.detectedSpeech) {
|
|
104
|
+
return "no_speech";
|
|
105
|
+
}
|
|
106
|
+
return "no_turn";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `screen` dimension for an ended session: how it closed, plus a detail
|
|
111
|
+
* half. That detail is the protocol error code when a failure closed it, or the
|
|
112
|
+
* silence classification when the session produced no turn at all.
|
|
113
|
+
*
|
|
114
|
+
* Every part is the daemon's own vocabulary verbatim
|
|
115
|
+
* (`LiveVoiceSessionCloseReason`, `LiveVoiceProtocolErrorCode`,
|
|
116
|
+
* {@link LiveVoiceSilenceReason}) rather than a mapping, so a rename on any of
|
|
117
|
+
* them surfaces here as a compile error instead of a silently-empty dashboard
|
|
118
|
+
* facet. The longest combination this can produce is well inside the wire
|
|
119
|
+
* field's 64-char bound.
|
|
120
|
+
*
|
|
121
|
+
* A failure code wins over a silence reason when both apply: a session that
|
|
122
|
+
* died on an error is explained by the error, and the silence is a consequence
|
|
123
|
+
* of it rather than a separate finding.
|
|
124
|
+
*
|
|
125
|
+
* The silence value carries a `silent_` prefix on purpose. It shares the detail
|
|
126
|
+
* slot with failure codes, and the admin dashboard currently renders that slot
|
|
127
|
+
* in a column labelled "failure code", so the value has to read correctly even
|
|
128
|
+
* where the column header is wrong, until that panel learns the difference.
|
|
69
129
|
*/
|
|
70
130
|
export function liveVoiceEndScreen(
|
|
71
131
|
reason: LiveVoiceSessionCloseReason,
|
|
72
132
|
failureCode?: LiveVoiceProtocolErrorCode | null,
|
|
133
|
+
silenceReason?: LiveVoiceSilenceReason | null,
|
|
73
134
|
): string {
|
|
74
|
-
|
|
135
|
+
if (failureCode) {
|
|
136
|
+
return `ended_${reason}:${failureCode}`;
|
|
137
|
+
}
|
|
138
|
+
if (silenceReason) {
|
|
139
|
+
return `ended_${reason}:silent_${silenceReason}`;
|
|
140
|
+
}
|
|
141
|
+
return `ended_${reason}`;
|
|
75
142
|
}
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* the plugin-facing {@link ../../plugin-api/store-credential.storeCredential}
|
|
11
11
|
* both compose it.
|
|
12
12
|
*
|
|
13
|
-
* The transcript scrub
|
|
14
|
-
* point of use.
|
|
15
|
-
* conversation database and registry
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* The transcript scrub, connection sync, and provider rotation refresh are
|
|
14
|
+
* imported lazily, at the point of use. They pull heavy graphs behind them
|
|
15
|
+
* (the scrub reaches the conversation database and registry, the sync reaches
|
|
16
|
+
* the OAuth store, and the refresh reaches the provider and conversation
|
|
17
|
+
* graphs), and this module sits under `@vellumai/plugin-api`, so static edges
|
|
18
|
+
* would drag that machinery into every plugin-api consumer for a write most of
|
|
19
|
+
* them never perform.
|
|
19
20
|
*
|
|
20
21
|
* Callers own their own transport-level argument validation and error mapping;
|
|
21
22
|
* this module throws {@link InvalidCredentialInputError} for a value it will
|
|
@@ -164,5 +165,16 @@ export async function storeCredentialValue(
|
|
|
164
165
|
await import("../../oauth/manual-token-connection.js");
|
|
165
166
|
await syncManualTokenConnection(service);
|
|
166
167
|
|
|
168
|
+
try {
|
|
169
|
+
const { refreshProvidersForRotatedCredential } =
|
|
170
|
+
await import("../../providers/inference/credential-rotation.js");
|
|
171
|
+
await refreshProvidersForRotatedCredential(service, field);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.warn(
|
|
174
|
+
{ err, service, field },
|
|
175
|
+
"Credential stored, but provider rotation refresh failed",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
return { credentialId: metadata.credentialId, service, field };
|
|
168
180
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractFirecrawlCompatSearchResults,
|
|
5
|
+
resolveProviderApiUrl,
|
|
6
|
+
searchResultSnippet,
|
|
7
|
+
} from "../firecrawl-compat.js";
|
|
8
|
+
|
|
9
|
+
describe("resolveProviderApiUrl", () => {
|
|
10
|
+
test("uses the default base when apiBase is empty", () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveProviderApiUrl("", "/v1/search", "https://api.fastcrw.com"),
|
|
13
|
+
).toBe("https://api.fastcrw.com/v1/search");
|
|
14
|
+
expect(
|
|
15
|
+
resolveProviderApiUrl(undefined, "/v1/scrape", "https://api.fastcrw.com"),
|
|
16
|
+
).toBe("https://api.fastcrw.com/v1/scrape");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("strips trailing slashes from a custom base", () => {
|
|
20
|
+
expect(
|
|
21
|
+
resolveProviderApiUrl(
|
|
22
|
+
"http://localhost:3000///",
|
|
23
|
+
"/v1/search",
|
|
24
|
+
"https://api.fastcrw.com",
|
|
25
|
+
),
|
|
26
|
+
).toBe("http://localhost:3000/v1/search");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("extractFirecrawlCompatSearchResults", () => {
|
|
31
|
+
test("reads grouped data.web", () => {
|
|
32
|
+
expect(
|
|
33
|
+
extractFirecrawlCompatSearchResults({
|
|
34
|
+
data: { web: [{ title: "A", url: "https://a.test" }] },
|
|
35
|
+
}),
|
|
36
|
+
).toEqual([{ title: "A", url: "https://a.test" }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reads data.results as a flat array", () => {
|
|
40
|
+
expect(
|
|
41
|
+
extractFirecrawlCompatSearchResults({
|
|
42
|
+
data: { results: [{ title: "B", url: "https://b.test" }] },
|
|
43
|
+
}),
|
|
44
|
+
).toEqual([{ title: "B", url: "https://b.test" }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("reads self-hosted data.results.web when sources group by type", () => {
|
|
48
|
+
expect(
|
|
49
|
+
extractFirecrawlCompatSearchResults({
|
|
50
|
+
data: {
|
|
51
|
+
results: {
|
|
52
|
+
web: [{ title: "Self-host", url: "https://local.test" }],
|
|
53
|
+
news: [{ title: "News", url: "https://news.test" }],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
).toEqual([{ title: "Self-host", url: "https://local.test" }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("reads a flat data array", () => {
|
|
61
|
+
expect(
|
|
62
|
+
extractFirecrawlCompatSearchResults({
|
|
63
|
+
data: [{ title: "C", url: "https://c.test" }],
|
|
64
|
+
}),
|
|
65
|
+
).toEqual([{ title: "C", url: "https://c.test" }]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("searchResultSnippet", () => {
|
|
70
|
+
test("prefers description then snippet", () => {
|
|
71
|
+
expect(
|
|
72
|
+
searchResultSnippet({ description: "desc", snippet: "snip" }),
|
|
73
|
+
).toBe("desc");
|
|
74
|
+
expect(searchResultSnippet({ snippet: "snip" })).toBe("snip");
|
|
75
|
+
expect(searchResultSnippet({})).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { setConfig } from "../../../__tests__/helpers/set-config.js";
|
|
4
|
+
|
|
5
|
+
let mockFastcrwSecureKey: string | undefined;
|
|
6
|
+
|
|
7
|
+
function seedWebFetch(provider: string, apiBase?: string): void {
|
|
8
|
+
const entry: Record<string, string> = { provider };
|
|
9
|
+
if (apiBase !== undefined) {
|
|
10
|
+
entry.apiBase = apiBase;
|
|
11
|
+
}
|
|
12
|
+
setConfig("services", { "web-fetch": entry });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mock.module("../../../security/secure-keys.js", () => ({
|
|
16
|
+
getProviderKeyAsync: async (provider: string) =>
|
|
17
|
+
provider === "fastcrw" ? mockFastcrwSecureKey : undefined,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const realLogger = await import("../../../util/logger.js");
|
|
21
|
+
mock.module("../../../util/logger.js", () => ({
|
|
22
|
+
...realLogger,
|
|
23
|
+
getLogger: () =>
|
|
24
|
+
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
mock.module("../../../permissions/types.js", () => ({
|
|
28
|
+
RiskLevel: { Low: "low", Medium: "medium", High: "high" },
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
let mockResolveAddresses: string[] = [];
|
|
32
|
+
const realUrlSafety = await import("../url-safety.js");
|
|
33
|
+
mock.module("../url-safety.js", () => ({
|
|
34
|
+
...realUrlSafety,
|
|
35
|
+
resolveHostAddresses: async () => mockResolveAddresses,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const { executeFastcrwScrape, webFetchTool } = await import("../web-fetch.js");
|
|
39
|
+
|
|
40
|
+
function scrapeResponse(body: unknown, status = 200): Response {
|
|
41
|
+
return new Response(JSON.stringify(body), {
|
|
42
|
+
status,
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("executeFastcrwScrape", () => {
|
|
48
|
+
let originalFetch: typeof globalThis.fetch;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
originalFetch = globalThis.fetch;
|
|
52
|
+
mockFastcrwSecureKey = undefined;
|
|
53
|
+
mockResolveAddresses = ["93.184.216.34"];
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
globalThis.fetch = originalFetch;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("hits the cloud default /v1/scrape endpoint", async () => {
|
|
60
|
+
let capturedUrl = "";
|
|
61
|
+
let capturedHeaders: any = null;
|
|
62
|
+
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
|
63
|
+
capturedUrl = url;
|
|
64
|
+
capturedHeaders = new Headers(init?.headers);
|
|
65
|
+
return scrapeResponse({
|
|
66
|
+
success: true,
|
|
67
|
+
data: {
|
|
68
|
+
markdown: "# Hello\n\nFrom fastCRW.",
|
|
69
|
+
metadata: {
|
|
70
|
+
title: "Example",
|
|
71
|
+
url: "https://example.com/",
|
|
72
|
+
statusCode: 200,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}) as typeof fetch;
|
|
77
|
+
|
|
78
|
+
const result = await executeFastcrwScrape(
|
|
79
|
+
{ url: "https://example.com" },
|
|
80
|
+
{ apiKey: "crw_live_test" },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(result.isError).toBe(false);
|
|
84
|
+
expect(capturedUrl).toBe("https://api.fastcrw.com/v1/scrape");
|
|
85
|
+
expect(capturedHeaders?.get("authorization")).toBe("Bearer crw_live_test");
|
|
86
|
+
expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
|
|
87
|
+
expect(result.content).toContain("From fastCRW.");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("uses a custom API base from config", async () => {
|
|
91
|
+
seedWebFetch("fastcrw", "http://localhost:3000/");
|
|
92
|
+
let capturedUrl = "";
|
|
93
|
+
globalThis.fetch = (async (url: string) => {
|
|
94
|
+
capturedUrl = url;
|
|
95
|
+
return scrapeResponse({
|
|
96
|
+
success: true,
|
|
97
|
+
data: { markdown: "local", metadata: { statusCode: 200 } },
|
|
98
|
+
});
|
|
99
|
+
}) as typeof fetch;
|
|
100
|
+
|
|
101
|
+
const result = await executeFastcrwScrape(
|
|
102
|
+
{ url: "https://example.com" },
|
|
103
|
+
{ apiKey: "" },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(result.isError).toBe(false);
|
|
107
|
+
expect(capturedUrl).toBe("http://localhost:3000/v1/scrape");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("webFetchTool fastCRW dispatch", () => {
|
|
112
|
+
let originalFetch: typeof globalThis.fetch;
|
|
113
|
+
|
|
114
|
+
const execute = (input: Record<string, unknown>, ctx: any = {}) =>
|
|
115
|
+
webFetchTool.execute(input, ctx);
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
originalFetch = globalThis.fetch;
|
|
119
|
+
mockFastcrwSecureKey = "crw_live_test";
|
|
120
|
+
mockResolveAddresses = ["93.184.216.34"];
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
globalThis.fetch = originalFetch;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("routes to fastCRW when provider=fastcrw and a key is set", async () => {
|
|
127
|
+
seedWebFetch("fastcrw");
|
|
128
|
+
let fastcrwHit = false;
|
|
129
|
+
globalThis.fetch = (async (url: string) => {
|
|
130
|
+
if (String(url).includes("api.fastcrw.com")) {
|
|
131
|
+
fastcrwHit = true;
|
|
132
|
+
}
|
|
133
|
+
return scrapeResponse({
|
|
134
|
+
success: true,
|
|
135
|
+
data: {
|
|
136
|
+
markdown: "ok",
|
|
137
|
+
metadata: { statusCode: 200, url: "https://example.com/" },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}) as typeof fetch;
|
|
141
|
+
|
|
142
|
+
const result = await execute({ url: "https://example.com" });
|
|
143
|
+
expect(fastcrwHit).toBe(true);
|
|
144
|
+
expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("routes to fastCRW without a key when a custom API base is set", async () => {
|
|
148
|
+
seedWebFetch("fastcrw", "http://127.0.0.1:3000");
|
|
149
|
+
mockFastcrwSecureKey = undefined;
|
|
150
|
+
let capturedUrl = "";
|
|
151
|
+
let capturedHeaders: any = null;
|
|
152
|
+
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
|
153
|
+
capturedUrl = String(url);
|
|
154
|
+
capturedHeaders = new Headers(init?.headers);
|
|
155
|
+
return scrapeResponse({
|
|
156
|
+
success: true,
|
|
157
|
+
data: {
|
|
158
|
+
markdown: "self-host",
|
|
159
|
+
metadata: { statusCode: 200, url: "https://example.com/" },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}) as typeof fetch;
|
|
163
|
+
|
|
164
|
+
const result = await execute({ url: "https://example.com" });
|
|
165
|
+
expect(capturedUrl).toBe("http://127.0.0.1:3000/v1/scrape");
|
|
166
|
+
expect(capturedHeaders?.get("authorization")).toBeNull();
|
|
167
|
+
expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -9,6 +9,7 @@ let mockPerplexitySecureKey: string | undefined;
|
|
|
9
9
|
let mockTavilySecureKey: string | undefined;
|
|
10
10
|
let mockFirecrawlSecureKey: string | undefined;
|
|
11
11
|
let mockKeenableSecureKey: string | undefined;
|
|
12
|
+
let mockFastcrwSecureKey: string | undefined;
|
|
12
13
|
let mockManagedSearchProxyResult: any;
|
|
13
14
|
let mockManagedSearchAvailable = true;
|
|
14
15
|
let mockManagedSearchProxyCalls: Array<{
|
|
@@ -21,9 +22,18 @@ let mockManagedSearchProxyCalls: Array<{
|
|
|
21
22
|
* Seed the web-search service into the workspace config. Pass `undefined`
|
|
22
23
|
* for mode to write a post-migration-132 config carrying only `provider`.
|
|
23
24
|
*/
|
|
24
|
-
function seedWebSearch(
|
|
25
|
+
function seedWebSearch(
|
|
26
|
+
mode: string | undefined,
|
|
27
|
+
provider: string,
|
|
28
|
+
apiBase?: string,
|
|
29
|
+
): void {
|
|
30
|
+
const entry: Record<string, string> =
|
|
31
|
+
mode === undefined ? { provider } : { mode, provider };
|
|
32
|
+
if (apiBase !== undefined) {
|
|
33
|
+
entry.apiBase = apiBase;
|
|
34
|
+
}
|
|
25
35
|
setConfig("services", {
|
|
26
|
-
"web-search":
|
|
36
|
+
"web-search": entry,
|
|
27
37
|
});
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -44,6 +54,9 @@ mock.module("../../../security/secure-keys.js", () => ({
|
|
|
44
54
|
if (provider === "keenable") {
|
|
45
55
|
return mockKeenableSecureKey;
|
|
46
56
|
}
|
|
57
|
+
if (provider === "fastcrw") {
|
|
58
|
+
return mockFastcrwSecureKey;
|
|
59
|
+
}
|
|
47
60
|
return undefined;
|
|
48
61
|
},
|
|
49
62
|
}));
|
|
@@ -87,6 +100,7 @@ describe("web_search tool", () => {
|
|
|
87
100
|
mockTavilySecureKey = undefined;
|
|
88
101
|
mockFirecrawlSecureKey = undefined;
|
|
89
102
|
mockKeenableSecureKey = undefined;
|
|
103
|
+
mockFastcrwSecureKey = undefined;
|
|
90
104
|
mockManagedSearchProxyCalls = [];
|
|
91
105
|
mockManagedSearchAvailable = true;
|
|
92
106
|
mockManagedSearchProxyResult = {
|
|
@@ -1047,6 +1061,87 @@ describe("web_search tool", () => {
|
|
|
1047
1061
|
expect(callCount).toBe(4);
|
|
1048
1062
|
});
|
|
1049
1063
|
|
|
1064
|
+
// ---- fastCRW provider ---------------------------------------------------
|
|
1065
|
+
|
|
1066
|
+
test("executes fastCRW search against the cloud default base", async () => {
|
|
1067
|
+
seedWebSearch("your-own", "fastcrw");
|
|
1068
|
+
mockFastcrwSecureKey = "crw_live_test";
|
|
1069
|
+
let capturedUrl = "";
|
|
1070
|
+
globalThis.fetch = (async (url: string) => {
|
|
1071
|
+
capturedUrl = url;
|
|
1072
|
+
return new Response(
|
|
1073
|
+
JSON.stringify({
|
|
1074
|
+
success: true,
|
|
1075
|
+
data: {
|
|
1076
|
+
web: [
|
|
1077
|
+
{
|
|
1078
|
+
title: "fastCRW Result",
|
|
1079
|
+
url: "https://example.com/crw",
|
|
1080
|
+
description: "From fastCRW",
|
|
1081
|
+
},
|
|
1082
|
+
],
|
|
1083
|
+
},
|
|
1084
|
+
}),
|
|
1085
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1086
|
+
);
|
|
1087
|
+
}) as any;
|
|
1088
|
+
|
|
1089
|
+
const result = await execute({ query: "rust scraper" });
|
|
1090
|
+
expect(result.isError).toBe(false);
|
|
1091
|
+
expect(capturedUrl).toBe("https://api.fastcrw.com/v1/search");
|
|
1092
|
+
expect(result.content).toContain("fastCRW Result");
|
|
1093
|
+
expect(result.activityMetadata?.webSearch?.provider).toBe("fastcrw");
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test("fastCRW uses a custom API base and accepts flat data arrays", async () => {
|
|
1097
|
+
seedWebSearch("your-own", "fastcrw", "http://localhost:3000/");
|
|
1098
|
+
mockFastcrwSecureKey = "crw_live_test";
|
|
1099
|
+
let capturedUrl = "";
|
|
1100
|
+
globalThis.fetch = (async (url: string) => {
|
|
1101
|
+
capturedUrl = url;
|
|
1102
|
+
return new Response(
|
|
1103
|
+
JSON.stringify({
|
|
1104
|
+
success: true,
|
|
1105
|
+
data: [
|
|
1106
|
+
{
|
|
1107
|
+
title: "Flat Result",
|
|
1108
|
+
url: "https://example.com/flat",
|
|
1109
|
+
snippet: "Portable flat shape",
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
}),
|
|
1113
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1114
|
+
);
|
|
1115
|
+
}) as any;
|
|
1116
|
+
|
|
1117
|
+
const result = await execute({ query: "flat" });
|
|
1118
|
+
expect(result.isError).toBe(false);
|
|
1119
|
+
expect(capturedUrl).toBe("http://localhost:3000/v1/search");
|
|
1120
|
+
expect(result.content).toContain("Flat Result");
|
|
1121
|
+
expect(result.content).toContain("Portable flat shape");
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test("fastCRW self-host with custom base runs without an API key", async () => {
|
|
1125
|
+
seedWebSearch("your-own", "fastcrw", "http://127.0.0.1:3000");
|
|
1126
|
+
mockFastcrwSecureKey = undefined;
|
|
1127
|
+
let capturedHeaders: any = null;
|
|
1128
|
+
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
|
1129
|
+
capturedHeaders = new Headers(init?.headers);
|
|
1130
|
+
return new Response(
|
|
1131
|
+
JSON.stringify({
|
|
1132
|
+
success: true,
|
|
1133
|
+
data: { results: [{ title: "Local", url: "https://local.test" }] },
|
|
1134
|
+
}),
|
|
1135
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1136
|
+
);
|
|
1137
|
+
}) as any;
|
|
1138
|
+
|
|
1139
|
+
const result = await execute({ query: "local" });
|
|
1140
|
+
expect(result.isError).toBe(false);
|
|
1141
|
+
expect(result.content).toContain("Local");
|
|
1142
|
+
expect(capturedHeaders?.get("authorization")).toBeNull();
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1050
1145
|
// ---- Provider fallback --------------------------------------------------
|
|
1051
1146
|
|
|
1052
1147
|
test("falls back from perplexity to brave when perplexity has no key", async () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for Firecrawl-compatible scrape/search HTTP APIs
|
|
3
|
+
* (Firecrawl hosted `/v2/*` and fastCRW `/v1/*`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FASTCRW_DEFAULT_API_BASE = "https://api.fastcrw.com";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Join an API origin with a path. Empty / whitespace `apiBase` uses
|
|
10
|
+
* `defaultBase`. Trailing slashes on the base are stripped.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveProviderApiUrl(
|
|
13
|
+
apiBase: string | undefined,
|
|
14
|
+
path: string,
|
|
15
|
+
defaultBase: string,
|
|
16
|
+
): string {
|
|
17
|
+
const trimmed = apiBase?.trim() ?? "";
|
|
18
|
+
const origin = (trimmed.length > 0 ? trimmed : defaultBase).replace(
|
|
19
|
+
/\/+$/,
|
|
20
|
+
"",
|
|
21
|
+
);
|
|
22
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
23
|
+
return `${origin}${normalizedPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FirecrawlCompatSearchResult {
|
|
27
|
+
title?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
snippet?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
category?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Portable read of Firecrawl / fastCRW search payloads:
|
|
36
|
+
* - grouped: `data.web` (Firecrawl `/v2/search`, hosted CRW with sources)
|
|
37
|
+
* - self-hosted with sources: `data.results.web` (object keyed by source)
|
|
38
|
+
* - self-hosted flat: `data.results` (array)
|
|
39
|
+
* - hosted flat: `data` is the array itself
|
|
40
|
+
*/
|
|
41
|
+
export function extractFirecrawlCompatSearchResults(
|
|
42
|
+
data: unknown,
|
|
43
|
+
): FirecrawlCompatSearchResult[] {
|
|
44
|
+
if (!data || typeof data !== "object") {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const root = data as Record<string, unknown>;
|
|
48
|
+
const payload = root.data;
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(payload)) {
|
|
51
|
+
return payload as FirecrawlCompatSearchResult[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (payload && typeof payload === "object") {
|
|
55
|
+
const nested = payload as Record<string, unknown>;
|
|
56
|
+
if (Array.isArray(nested.web)) {
|
|
57
|
+
return nested.web as FirecrawlCompatSearchResult[];
|
|
58
|
+
}
|
|
59
|
+
// Self-hosted CRW with sources:["web"] nests rows under data.results.web
|
|
60
|
+
// (an object keyed by source), not data.results as a flat array.
|
|
61
|
+
if (
|
|
62
|
+
nested.results &&
|
|
63
|
+
typeof nested.results === "object" &&
|
|
64
|
+
!Array.isArray(nested.results)
|
|
65
|
+
) {
|
|
66
|
+
const resultsBySource = nested.results as Record<string, unknown>;
|
|
67
|
+
if (Array.isArray(resultsBySource.web)) {
|
|
68
|
+
return resultsBySource.web as FirecrawlCompatSearchResult[];
|
|
69
|
+
}
|
|
70
|
+
const firstSourceKey = Object.keys(resultsBySource).find((key) =>
|
|
71
|
+
Array.isArray(resultsBySource[key]),
|
|
72
|
+
);
|
|
73
|
+
if (firstSourceKey) {
|
|
74
|
+
return resultsBySource[firstSourceKey] as FirecrawlCompatSearchResult[];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(nested.results)) {
|
|
78
|
+
return nested.results as FirecrawlCompatSearchResult[];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function searchResultSnippet(
|
|
86
|
+
result: FirecrawlCompatSearchResult,
|
|
87
|
+
): string | undefined {
|
|
88
|
+
const text = result.description || result.snippet;
|
|
89
|
+
return text?.trim() || undefined;
|
|
90
|
+
}
|