min = $min; $this->max = $max; } /** * Add streamed text and take out whatever is ready to speak. * * @param string $text Newly streamed text. * @return string[] Zero or more pieces, in order. */ public function add( string $text ): array { $this->buffer .= $text; $pieces = []; while ( true ) { $piece = $this->take(); if ( null === $piece ) { break; } $pieces[] = $piece; } return $pieces; } /** * Look at what is waiting, without taking it. * * Used when the model stops to call a tool. The caller decides if the * text so far is long enough to speak. * * @return string */ public function peek(): string { return trim( $this->buffer ); } /** * Take everything that is left, finished or not. * * Called once the model has stopped writing. * * @return string May be empty. */ public function rest(): string { $rest = trim( $this->buffer ); $this->buffer = ''; return $rest; } /** * Throw away everything waiting, without speaking it. * * Used when an answer was a guess and is thrown away. * Why: the last sentence stays in the buffer until rest(). Without this, * the end of the deleted guess ("Want me to narrow that down?") was * spoken later in the turn. * rest() also empties the buffer, but this name says what we mean. * * @return void */ public function forget(): void { $this->buffer = ''; } /** * Pull one finished piece off the front of the buffer. * * @return string|null Null while there is nothing ready. */ private function take(): ?string { $limit = $this->settled(); if ( $limit < $this->min ) { return null; } $region = mb_substr( $this->buffer, 0, $limit ); // A full stop, ?, ! or Bangla danda (ред), followed by a space. // The space keeps "9.30" and "wp-admin.php" in one piece. It also // shows the sentence really ended, and the stream did not just pause // inside a number. A sentence end at the very end of the buffer waits. $pattern = '/^.{' . $this->min . ',}?[.!?\x{0964}](?=\s)/us'; if ( preg_match( $pattern, $region, $m ) ) { return $this->cut( mb_strlen( $m[0] ) ); } // No sentence end in sight. Cut at the last space instead. if ( $limit >= $this->max ) { $head = mb_substr( $this->buffer, 0, $this->max ); $space = mb_strrpos( $head, ' ' ); if ( false !== $space && $space >= $this->min ) { return $this->cut( $space ); } } return null; } /** * Remove the first $length characters from the buffer and return them. * * @param int $length Characters to take. * @return string */ private function cut( int $length ): string { $piece = mb_substr( $this->buffer, 0, $length ); $this->buffer = ltrim( mb_substr( $this->buffer, $length ) ); return trim( $piece ); } /** * How much of the buffer is safe to cut, in characters. * * Stops before any HTML tag or link that is not finished yet. * Why: a link like `Open page` comes in several chunks. * A cut inside it would make "Open page" be read out as a sentence. * * @return int */ private function settled(): int { $limit = mb_strlen( $this->buffer ); // An opening tag with no ">" yet. $angle = mb_strrpos( $this->buffer, '<' ); if ( false !== $angle && false === mb_strpos( mb_substr( $this->buffer, $angle ), '>' ) ) { $limit = min( $limit, $angle ); } // A link that has not been closed yet. $link = mb_strrpos( $this->buffer, 'buffer, $link ), '' ) ) { $limit = min( $limit, $link ); } return $limit; } }