$messages * @param array>|null $abilities * @return array{text?:string,calls?:array>,compaction?:array{summary:string,keep:int},reconnect?:bool,error?:string} */ public static function turn(int $userId, array $messages, string $summary = '', ?array $abilities = null): array { $siteKey = (string) Options::get('site_key'); if ($siteKey === '') { return ['error' => __('Connect this site to Sarcio first.', 'sarcio')]; } $token = Account::accessToken($userId); if (!isset($token['access'])) { return $token; } $summary = mb_substr($summary, 0, Transcript::MAX_SUMMARY_CHARS); $compaction = null; if (count($messages) > Transcript::MAX_MESSAGES) { [$old, $keep] = Transcript::split($messages); $summarised = self::summarise($userId, $token['access'], $siteKey, $summary, $old); if ($summarised !== null) { $summary = $summarised; $messages = $keep; $compaction = ['keep' => count($keep), 'summary' => $summary]; } } $body = (string) wp_json_encode(array_filter([ 'abilities' => $abilities ?? Catalog::forCurrentUser(), 'context' => SiteAbilities::context(), 'messages' => Transcript::prepare($messages, $summary !== ''), 'site' => $siteKey, 'summary' => $summary !== '' ? $summary : null, ], static fn ($value): bool => $value !== null)); $response = self::send($userId, $token['access'], '/api/assistant/turn', $body); if (isset($response['reconnect'])) { return $response; } $result = self::interpret($response); if ($compaction !== null && !isset($result['error'])) { $result['compaction'] = $compaction; } return $result; } /** * Asks Sarcio for a plain-words summary of the oldest part of the * conversation, folded into the summary so far. Null when it could not be * had, in which case the turn trims the old way. * * @param array $messages */ private static function summarise(int $userId, string $access, string $siteKey, string $summary, array $messages): ?string { $prepared = Transcript::prepare($messages, true); if ($prepared === []) { return null; } $body = (string) wp_json_encode(array_filter([ 'messages' => $prepared, 'site' => $siteKey, 'summary' => $summary !== '' ? $summary : null, ], static fn ($value): bool => $value !== null)); $response = self::send($userId, $access, '/api/assistant/summary', $body); if (isset($response['reconnect']) || !in_array($response['status'] ?? 0, [200, 201], true)) { return null; } $text = is_array($response['body']) && is_string($response['body']['summary'] ?? null) ? trim($response['body']['summary']) : ''; return $text === '' ? null : mb_substr($text, 0, Transcript::MAX_SUMMARY_CHARS); } /** * Posts as the member, refreshing the sign-in once on a 401. * * @return array{status:int,body:mixed}|array{reconnect:true} */ private static function send(int $userId, string $access, string $path, string $body): array { $response = self::post($access, $path, $body); if (($response['status'] ?? 0) === 401) { $token = Account::accessToken($userId, true); if (!isset($token['access'])) { return ['reconnect' => true]; } $response = self::post($token['access'], $path, $body); if (($response['status'] ?? 0) === 401) { Account::forget($userId); return ['reconnect' => true]; } } return $response; } /** @return array{status:int,body:mixed} */ private static function post(string $access, string $path, string $body): array { $response = wp_remote_post(Options::apiUrl() . $path, [ 'body' => $body, 'headers' => [ 'Authorization' => 'Bearer ' . $access, 'Content-Type' => 'application/json', ], 'redirection' => 0, 'timeout' => self::TIMEOUT_SEC, ]); if (is_wp_error($response)) { return ['body' => null, 'status' => 0]; } return [ 'body' => json_decode((string) wp_remote_retrieve_body($response), true), 'status' => (int) wp_remote_retrieve_response_code($response), ]; } /** * @param array{status:int,body:mixed} $response * @return array{text?:string,calls?:array>,error?:string} */ private static function interpret(array $response): array { $status = $response['status']; $body = is_array($response['body']) ? $response['body'] : []; $message = is_string($body['message'] ?? null) ? sanitize_text_field($body['message']) : ''; if ($status === 200 || $status === 201) { $calls = []; foreach ((array) ($body['calls'] ?? []) as $call) { if (is_array($call) && is_string($call['ability'] ?? null)) { $calls[] = [ 'ability' => sanitize_text_field($call['ability']), 'args' => is_array($call['args'] ?? null) && $call['args'] !== [] ? $call['args'] : new \stdClass(), 'id' => is_string($call['id'] ?? null) ? sanitize_text_field(mb_substr($call['id'], 0, 40)) : '', 'why' => is_string($call['why'] ?? null) ? sanitize_text_field(mb_substr($call['why'], 0, 500)) : '', ]; } } return ['calls' => $calls, 'text' => is_string($body['text'] ?? null) ? mb_substr($body['text'], 0, Transcript::MAX_TEXT_CHARS) : '']; } return ['error' => match ($status) { 0 => __('Could not reach Sarcio. Check your connection and try again.', 'sarcio'), 402 => $message !== '' ? $message : __('This workspace has used its AI credits for the month. Add credits or wait for the next month.', 'sarcio'), 403 => $message !== '' ? $message : __('Sarcio did not allow that for this site.', 'sarcio'), 413 => __('This conversation is too long. Start a new one.', 'sarcio'), 429 => __('Too many requests at once. Wait a moment and try again.', 'sarcio'), 503 => __('AI is not turned on for this Sarcio workspace.', 'sarcio'), 422 => $message !== '' ? $message : __('The assistant could not answer that. Try saying it another way.', 'sarcio'), default => __('Sarcio could not answer right now. Try again in a moment.', 'sarcio'), }, 'status' => $status]; } }