> $parts * @param array $extra * @return array */ public static function message(string $role, array $parts, array $extra = []): array { return ['v' => self::VERSION, 'type' => 'message', 'role' => $role, 'parts' => $parts] + $extra + ['ts' => self::now()]; } /** * @param array $data * @return array */ public static function event(string $event, array $data = []): array { return ['v' => self::VERSION, 'type' => 'event', 'event' => $event] + $data + ['ts' => self::now()]; } /** * The conversation the model sees: messages only, with any tool call that never got a result (a cancelled or * crashed step) removed, because providers reject a call without its answer. * * @param list> $entries * @return list>}> */ public static function conversation(array $entries): array { $messages = array_values(array_filter(self::visible($entries), static fn (array $e): bool => ($e['type'] ?? '') === 'message')); $answered = []; foreach ($messages as $message) { foreach ($message['parts'] as $part) { if (($part['type'] ?? '') === 'tool_result') { $answered[(string) $part['id']] = true; } } } $out = []; foreach ($messages as $message) { $parts = array_values(array_filter( $message['parts'], static fn (array $part): bool => in_array($part['type'] ?? '', ['text', 'tool_call', 'tool_result'], true) && (($part['type'] !== 'tool_call') || isset($answered[(string) $part['id']])) && ($part['type'] !== 'text' || trim((string) $part['text']) !== ''), )); if ($parts !== []) { $out[] = ['role' => (string) $message['role'], 'parts' => $parts]; } } return $out; } /** * The transcript as it now reads: each `rewind` drops the lines after its `to`, and is dropped itself. * * @param list> $entries In `seq` order. * @return list> */ public static function visible(array $entries): array { $kept = []; foreach ($entries as $entry) { if (($entry['type'] ?? '') === 'event' && ($entry['event'] ?? '') === 'rewind') { $to = (int) ($entry['to'] ?? 0); $kept = array_values(array_filter($kept, static fn (array $e): bool => (int) ($e['seq'] ?? 0) <= $to)); continue; } $kept[] = $entry; } return $kept; } /** * The last user message of the visible transcript, or null. * * @param list> $entries * @return array|null */ public static function lastUserMessage(array $entries): ?array { $visible = self::visible($entries); for ($i = count($visible) - 1; $i >= 0; $i--) { if (($visible[$i]['type'] ?? '') === 'message' && ($visible[$i]['role'] ?? '') === 'user') { return $visible[$i]; } } return null; } /** * The plain text of a message entry. * * @param array $entry */ public static function text(array $entry): string { $texts = []; foreach ($entry['parts'] ?? [] as $part) { if (($part['type'] ?? '') === 'text') { $texts[] = (string) $part['text']; } } return Redactor::mask(trim(implode("\n\n", $texts))); } /** * Remove key-shaped secrets before a transcript line reaches durable storage. Opaque provider continuation state * is excluded because the model adapter must return it unchanged. * * @since 0.1.0 * @param array $entry * @return array */ public static function redactSensitive(array $entry): array { $hasState = array_key_exists('state', $entry); $state = $entry['state'] ?? null; unset($entry['state']); $partStates = []; if (isset($entry['parts']) && is_array($entry['parts'])) { foreach ($entry['parts'] as $index => &$part) { if (is_array($part) && ($part['type'] ?? '') === 'tool_call' && array_key_exists('state', $part)) { $partStates[$index] = $part['state']; unset($part['state']); } } unset($part); } $entry = Redactor::maskDeep($entry); if (($entry['type'] ?? '') === 'message' && in_array($entry['role'] ?? '', ['user', 'assistant'], true) && isset($entry['parts']) && is_array($entry['parts'])) { foreach ($entry['parts'] as &$part) { if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) { // Human chat can contain a key from a provider whose format we do not know yet. Apply the // generic long-token matcher to this text only; site data can contain hashes and IDs. $part['text'] = Redactor::mask($part['text']); } } unset($part); } if ($hasState) { $entry['state'] = $state; } foreach ($partStates as $index => $partState) { if (isset($entry['parts'][$index]) && is_array($entry['parts'][$index])) { $entry['parts'][$index]['state'] = $partState; } } return $entry; } /** * A title from the first words of the first message (no model call, nothing extra sent anywhere). * * @since 0.1.0 */ public static function title(string $text): string { $text = Redactor::mask(trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($text)))); if (mb_strlen($text) <= 60) { return $text; } $cut = mb_substr($text, 0, 60); $space = mb_strrpos($cut, ' '); return rtrim($space !== false && $space > 30 ? mb_substr($cut, 0, $space) : $cut, " ,.;:-") . '…'; } /** * Entries as the browser and the export see them: only what a person or the app uses. The model adapter's * opaque `state`, the model-facing `content` of tool results, client ids, compaction summaries and the ids of * sub-agent sessions stay in the database. A tool result is sent as its status, its trace-card `ui` and, for * the person to inspect, the site data the assistant read (`detail`). * * @since 0.1.0 * @param list> $entries * @return list> */ public static function forClient(array $entries): array { return array_map(static function (array $entry): array { $entry = self::redactSensitive($entry); unset($entry['client_id']); if (isset($entry['parts']) && is_array($entry['parts'])) { $entry['parts'] = array_map([self::class, 'partForClient'], $entry['parts']); } if (($entry['type'] ?? '') === 'event') { unset($entry['summary'], $entry['usage'], $entry['child']); $entry = self::renderMessages($entry); } return $entry; }, $entries); } /** * Turn the stored messages of an event (an "undone" line, an approval's previews) into sentences in the viewer's * language. Older entries hold finished prose and pass through. * * @param array $entry * @return array */ private static function renderMessages(array $entry): array { if (is_string($entry['message'] ?? null)) { $entry['message'] = StoredMessage::render($entry['message']); } if (is_array($entry['calls'] ?? null)) { $entry['calls'] = array_map(static function ($call) { if (is_array($call) && is_string($call['preview'] ?? null)) { $call['preview'] = StoredMessage::render($call['preview']); } return $call; }, $entry['calls']); } return $entry; } /** * @since 0.1.0 */ public static function partForClient(mixed $part): mixed { if (!is_array($part)) { return $part; } unset($part['state']); if (($part['type'] ?? '') !== 'tool_result') { return $part; } $outcome = ToolOutcome::fromPart($part); $client = ['type' => 'tool_result', 'id' => (string) ($part['id'] ?? ''), 'name' => (string) ($part['name'] ?? ''), 'status' => $outcome->status]; if ($outcome->code !== '') { $client['code'] = $outcome->code; } if ($outcome->error !== '') { $client['error'] = $outcome->error; } $client['ui'] = $outcome->ui + ['summary' => $outcome->error]; if (is_string($client['ui']['summary'] ?? null)) { $client['ui']['summary'] = StoredMessage::render($client['ui']['summary']); } if ($outcome->ok()) { $client['detail'] = $outcome->data; } return $client; } /** * The entries as JSONL, one line each. * * @param list> $entries */ public static function jsonl(array $entries): string { $lines = array_map(static fn (array $entry): string => (string) wp_json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $entries); return $lines === [] ? '' : implode("\n", $lines) . "\n"; } private static function now(): string { return gmdate('Y-m-d\TH:i:s\Z'); } }