} * @phpstan-type State array{working: Doc|null, rev: int, outline: array{sections: list
, approved: bool}|null, written: array, proposal: Doc|null, pending: array{kind: string, key: string, heading: string}|null, template: string, post_type: string, stages: list, instructions: array>, fields: array, brief: string, notes: string, progress: array} * * @since 0.1.4 */ final class StudioState { public const MAX_SECTIONS = 30; public const MAX_POINTS = 12; public const MAX_REVISIONS = 300; public const MAX_NOTES = 8000; /** * @since 0.1.4 * @param list> $entries The visible transcript, in order. * @return State */ public static function fold(array $entries): array { $state = ['working' => null, 'rev' => 0, 'outline' => null, 'written' => [], 'proposal' => null, 'pending' => null, 'template' => '', 'post_type' => 'post', 'stages' => [], 'instructions' => [], 'fields' => [], 'brief' => '', 'notes' => '', 'progress' => []]; $staged = false; foreach ($entries as $entry) { $type = (string) ($entry['type'] ?? ''); if ($type === 'message' && ($entry['role'] ?? '') === 'assistant') { $text = Transcript::text($entry); if ($text !== '') { $state = self::answer($state, $text); } continue; } if ($type !== 'event') { continue; } switch ((string) ($entry['event'] ?? '')) { case 'studio': $state['template'] = (string) ($entry['template'] ?? ''); $state['post_type'] = self::postType($entry['post_type'] ?? null) ?? 'post'; if (is_array($entry['stages'] ?? null)) { $staged = true; $state['stages'] = Stages::resolve($entry['stages'], false); } $state['brief'] = mb_substr((string) ($entry['brief'] ?? ''), 0, 4000); $state['instructions'] = InstructionSet::cleanMap($entry['instructions'] ?? null); foreach (is_array($entry['fields'] ?? null) ? $entry['fields'] : [] as $id => $value) { if (is_string($id) && is_scalar($value)) { $state['fields'][$id] = mb_substr((string) $value, 0, 2000); } } break; case 'studio_instructions': $stage = (string) ($entry['stage'] ?? ''); if (in_array($stage, Stages::withSlots(), true)) { $state['instructions'][$stage] = InstructionSet::clean($entry['slots'] ?? null); } break; case 'studio_notes': $state['notes'] = mb_substr((string) ($entry['text'] ?? ''), 0, self::MAX_NOTES); break; case 'studio_target': $type = self::postType($entry['post_type'] ?? null); if ($type !== null) { $state['post_type'] = $type; } break; case 'studio_stage': $stage = (string) ($entry['stage'] ?? ''); $to = (string) ($entry['status'] ?? ''); if (in_array($stage, Stages::ids(), true) && in_array($to, ['done', 'skipped', 'open'], true)) { $state['progress'][$stage] = $to; } break; case 'studio_expect': $state['pending'] = ['kind' => (string) ($entry['kind'] ?? 'generate'), 'key' => (string) ($entry['key'] ?? ''), 'heading' => (string) ($entry['heading'] ?? '')]; break; case 'studio_revision': $state['working'] = self::document($entry); $state['rev']++; break; case 'studio_outline': $outline = self::cleanOutline((array) ($entry['sections'] ?? [])); if (is_array($outline)) { $state['outline'] = ['sections' => $outline, 'approved' => (bool) ($entry['approved'] ?? false)]; } break; case 'studio_resolve': if (($entry['action'] ?? '') === 'accept' && $state['proposal'] !== null) { $state['working'] = $state['proposal']; } $state['proposal'] = null; break; } } if (!$staged) { // A document from before stages: what it has decides what it shows. $state['stages'] = $state['outline'] !== null ? [Stages::PLAN, Stages::WRITE, Stages::HANDOFF] : [Stages::WRITE, Stages::HANDOFF]; $state['stages'] = Stages::resolve($state['stages'], false); } return $state; } /** * Where the document stands: the first enabled stage the person has not passed. The brief is always passed. * The plan is passed when its outline is approved; a handed-off document is past everything. * * @since 0.1.4 * @param State $state */ public static function current(array $state, bool $handedOff = false): string { if ($handedOff) { return Stages::HANDOFF; } foreach ($state['stages'] as $stage) { if ($stage === Stages::BRIEF || $stage === Stages::HANDOFF) { continue; } $mark = $state['progress'][$stage] ?? ''; if ($mark === 'done' || $mark === 'skipped') { continue; } if ($stage === Stages::PLAN && ($state['outline']['approved'] ?? false) && $mark !== 'open') { continue; } return $stage; } return Stages::HANDOFF; } /** * @param State $state * @return State */ private static function answer(array $state, string $text): array { $kind = $state['pending']['kind'] ?? 'generate'; $pending = $state['pending']; $state['pending'] = null; switch ($kind) { case 'research': $state['notes'] = mb_substr(trim($text), 0, self::MAX_NOTES); break; case 'rewrite': $proposal = StudioDocument::parse($text); $working = $state['working'] ?? ['title' => '', 'excerpt' => '', 'body' => '']; if (preg_match('/^#\s+\S/m', trim($text)) !== 1 || $proposal['title'] === '' || ($working['body'] !== '' && $proposal['body'] === '')) { $state['proposal'] = null; break; } if ($proposal['excerpt'] === '') { $proposal['excerpt'] = $working['excerpt']; } $state['proposal'] = $proposal; break; case 'metadata': $proposal = StudioDocument::parse($text); $working = $state['working'] ?? ['title' => '', 'excerpt' => '', 'body' => '']; $field = (string) ($pending['key'] ?? ''); $other = $field === 'title' ? 'excerpt' : 'title'; if (!in_array($field, ['title', 'excerpt'], true) || preg_match('/^#\s+\S/m', trim($text)) !== 1 || $proposal[$field] === '' || ($working[$other] === '' && $proposal[$other] === '')) { $state['proposal'] = null; break; } if ($working[$other] !== '') { $proposal[$other] = $working[$other]; } $proposal['body'] = $working['body']; $state['proposal'] = $proposal; break; case 'outline': $sections = self::parseOutline($text); if ($sections !== []) { $state['outline'] = ['sections' => $sections, 'approved' => false]; $state['written'] = []; $metadata = StudioDocument::parse($text); $working = $state['working'] ?? ['title' => '', 'excerpt' => '', 'body' => '']; foreach (['title', 'excerpt'] as $field) { if ($working[$field] === '' && $metadata[$field] !== '') { $working[$field] = $metadata[$field]; $state['working'] = $working; } } } break; case 'section': $heading = (string) ($pending['heading'] ?? ''); $working = $state['working'] ?? ['title' => '', 'excerpt' => '', 'body' => '']; $state['working'] = array_replace($working, ['body' => self::replaceSection($working['body'], $heading, $text)]); if (($pending['key'] ?? '') !== '') { $state['written'][(string) $pending['key']] = true; } break; default: $state['working'] = StudioDocument::parse($text); $state['proposal'] = null; } return $state; } /** * @param array $entry * @return array{title: string, excerpt: string, body: string} */ private static function document(array $entry): array { return ['title' => (string) ($entry['title'] ?? ''), 'excerpt' => (string) ($entry['excerpt'] ?? ''), 'body' => (string) ($entry['body'] ?? '')]; } private static function postType(mixed $type): ?string { return is_string($type) && preg_match('/^[a-z0-9_-]{1,20}$/D', $type) === 1 ? $type : null; } /** * An outline in Markdown: `## Heading` lines, each followed by `- point` bullets. Text before the first heading * and a `#` title line are ignored. * * @since 0.1.4 * @return list}> */ public static function parseOutline(string $markdown): array { $headings = []; $points = []; foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $markdown)) as $line) { if (preg_match('/^#{2,6}\s+(.+?)\s*#*\s*$/', $line, $m) === 1) { $headings[] = trim($m[1], " *\t"); $points[] = []; continue; } if ($headings !== [] && preg_match('/^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/', $line, $m) === 1) { $points[count($points) - 1][] = trim($m[1], " \t"); } } $sections = []; foreach (array_slice($headings, 0, self::MAX_SECTIONS, true) as $i => $heading) { $sections[] = ['key' => 's' . ($i + 1), 'heading' => $heading, 'points' => array_slice($points[$i], 0, self::MAX_POINTS)]; } return $sections; } /** * Validate an outline sent by the person: keys, headings and points are trimmed; empty headings are dropped. * Returns a message when it cannot be used. * * @since 0.1.4 * @param array $raw * @return list}>|string */ public static function cleanOutline(array $raw): array|string { $out = []; $seen = []; foreach ($raw as $section) { if (!is_array($section)) { continue; } $heading = trim(preg_replace('/\s+/', ' ', (string) ($section['heading'] ?? '')) ?? ''); if ($heading === '') { continue; } if (mb_strlen($heading) > 200) { return 'heading_too_long'; } $key = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($section['key'] ?? '')) ?? ''; if ($key === '' || isset($seen[$key])) { $key = 's' . (count($out) + 1); while (isset($seen[$key])) { $key .= 'x'; } } $seen[$key] = true; $points = []; foreach (is_array($section['points'] ?? null) ? $section['points'] : [] as $point) { $point = trim(preg_replace('/\s+/', ' ', is_scalar($point) ? (string) $point : '') ?? ''); if ($point !== '') { $points[] = mb_substr($point, 0, 300); } } $out[] = ['key' => substr($key, 0, 40), 'heading' => $heading, 'points' => array_slice($points, 0, self::MAX_POINTS)]; } if ($out === []) { return 'empty'; } return count($out) > self::MAX_SECTIONS ? 'too_many' : $out; } /** * Put `$text` under the `## $heading` line of `$body`, replacing what was there. The section runs to the next `##` * heading (or the end). A heading the person renamed or removed is not found, and the section is added at the end. * If the model repeated the heading, that line is dropped. * * @since 0.1.4 */ public static function replaceSection(string $body, string $heading, string $text): string { $text = trim($text); $text = trim((string) preg_replace('/^#{1,6}\s+' . preg_quote($heading, '/') . '\s*\n+/iu', '', $text)); $lines = $body === '' ? [] : explode("\n", str_replace(["\r\n", "\r"], "\n", $body)); $start = null; $end = count($lines); foreach ($lines as $i => $line) { if ($start === null) { if (preg_match('/^##\s+(.+?)\s*#*\s*$/u', $line, $m) === 1 && mb_strtolower(trim($m[1])) === mb_strtolower(trim($heading))) { $start = $i; } continue; } if (preg_match('/^##\s/', $line) === 1) { $end = $i; break; } } if ($start === null) { $block = '## ' . $heading . "\n\n" . $text; return trim($body) === '' ? $block : rtrim($body) . "\n\n" . $block; } $block = $lines[$start] . "\n\n" . $text; // Keep the heading as the person wrote it. $before = rtrim(implode("\n", array_slice($lines, 0, $start))); $after = ltrim(implode("\n", array_slice($lines, $end)), "\n"); return trim(($before !== '' ? $before . "\n\n" : '') . $block . ($after !== '' ? "\n\n" . $after : '')); } /** * @since 0.1.4 */ public static function words(string $text): int { return preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\'’-]*/u', $text) ?: 0; } }