0) { $depth--; if (0 === $depth && null !== $start) { $candidates[] = substr($string, $start, $i - $start + 1); $start = null; } } } usort($candidates, function ($a, $b) { return strlen($b) - strlen($a); }); return $candidates; } /** * Extract JSON from a streaming chunk. * * @since 0.3.0 * @param string $chunk Raw chunk from stream. * @return array|null Decoded JSON or null. */ public static function extract_json_from_stream_chunk($chunk) { return self::extract_json($chunk); } // ========================================================================= // Plan Extraction & Normalization Methods // ========================================================================= /** * Extract plan from model response using JSON or markdown fallback. * * @since 0.2.2 * @param string $content Raw model response. * @param string $fallback_title Fallback title/topic. * @param array $previous_plan Previous plan for context. * @return array|null Normalized plan or null. */ public static function extract_plan_from_response( $content, $fallback_title = "", $previous_plan = [], ) { $json = self::extract_json($content); $normalized_json_plan = self::normalize_extracted_plan_json( $json, $fallback_title, ); if (!empty($normalized_json_plan["sections"])) { return $normalized_json_plan; } $markdown_plan = self::build_plan_from_markdown_outline( $content, $fallback_title, $previous_plan, ); if (!empty($markdown_plan["sections"])) { return $markdown_plan; } return null; } /** * Normalize common model outline JSON variants into the required plan schema. * * @since 0.2.3 * @param mixed $json Decoded model JSON. * @param string $fallback_title Fallback title/topic. * @return array|null */ public static function normalize_extracted_plan_json($json, $fallback_title = "") { if (!is_array($json)) { return null; } // Some models return the sections array directly. if (array_is_list($json)) { $json = [ "title" => $fallback_title, "sections" => $json, ]; } // Some models nest the outline under a descriptive top-level key. foreach ( ["plan", "outline", "article_plan", "articlePlan", "data"] as $key ) { if ( empty($json["sections"]) && isset($json[$key]) && is_array($json[$key]) ) { $nested = self::normalize_extracted_plan_json( $json[$key], $fallback_title, ); if (!empty($nested["sections"])) { return $nested; } } } $section_keys = [ "sections", "outline", "items", "chapters", "headings", "bagian", ]; $sections = []; foreach ($section_keys as $key) { if (!empty($json[$key]) && is_array($json[$key])) { $sections = $json[$key]; break; } } if (empty($sections)) { return null; } $title = $json["title"] ?? ($json["judul"] ?? ($json["headline"] ?? $fallback_title)); $title = self::clean_outline_heading($title); if ("" === $title) { $title = __("Article Outline", "wp-agentic-writer"); } $normalized_sections = []; foreach ($sections as $index => $section) { if (is_string($section)) { $section = ["heading" => $section]; } if (!is_array($section)) { continue; } $heading = $section["heading"] ?? ($section["title"] ?? ($section["judul"] ?? ($section["name"] ?? ($section["h2"] ?? sprintf("Section %d", $index + 1))))); $heading = self::clean_outline_heading($heading); if ("" === $heading) { continue; } $content_items = $section["content"] ?? ($section["description"] ?? ($section["summary"] ?? ($section["points"] ?? ($section["bullets"] ?? [])))); $content = self::normalize_plan_section_content_items( $content_items, ); if (empty($content)) { $content[] = [ "type" => "paragraph", "content" => $heading, ]; } $normalized_sections[] = [ "id" => sanitize_key($section["id"] ?? ""), "status" => sanitize_key($section["status"] ?? "pending"), "type" => sanitize_key($section["type"] ?? "section"), "heading" => $heading, "content" => $content, ]; } if (empty($normalized_sections)) { return null; } $meta = isset($json["meta"]) && is_array($json["meta"]) ? $json["meta"] : []; return [ "title" => $title, "meta" => wp_parse_args($meta, [ "reading_time" => "5 min", "difficulty" => "intermediate", "cost_estimate" => 0.7, ]), "sections" => $normalized_sections, ]; } /** * Normalize varied model section content into plan content items. * * @since 0.2.3 * @param mixed $items Section content candidate. * @return array */ public static function normalize_plan_section_content_items($items) { if (is_string($items)) { $items = [$items]; } if (!is_array($items)) { return []; } $normalized = []; foreach ($items as $item) { if (is_string($item)) { $text = trim(wp_strip_all_tags($item)); if ("" !== $text) { $normalized[] = [ "type" => "paragraph", "content" => $text, ]; } continue; } if (!is_array($item)) { continue; } $text = $item["content"] ?? ($item["text"] ?? ($item["description"] ?? ($item["point"] ?? ""))); $text = trim(wp_strip_all_tags((string) $text)); if ("" === $text) { continue; } $normalized[] = [ "type" => sanitize_key($item["type"] ?? "paragraph"), "content" => $text, ]; } return $normalized; } /** * Build a plan schema from markdown/numbered outline output. * * @since 0.2.2 * @param string $content Markdown content. * @param string $fallback_title Fallback title. * @param array $previous_plan Previous plan for context. * @return array|null */ public static function build_plan_from_markdown_outline( $content, $fallback_title = "", $previous_plan = [], ) { $lines = preg_split('/\r\n|\r|\n/', (string) $content); if (!is_array($lines)) { return null; } $title = ""; $sections = []; $current = null; foreach ($lines as $raw_line) { $line = trim(wp_strip_all_tags((string) $raw_line)); if ("" === $line) { continue; } $line = preg_replace( '/^\s*(?:[-*]\s*)?\*\*(.*?)\*\*\s*$/', '$1', $line, ); $heading = ""; if (preg_match('/^#{1,2}\s+(.+)$/', $line, $matches)) { $text = self::clean_outline_heading($matches[1]); if ("" === $title) { $title = $text; continue; } $heading = $text; } elseif (preg_match('/^\d+[\.)]\s+(.+)$/', $line, $matches)) { $heading = self::clean_outline_heading($matches[1]); } elseif ( preg_match( '/^(?:section|bagian)\s+\d+\s*[:.-]\s*(.+)$/i', $line, $matches, ) ) { $heading = self::clean_outline_heading($matches[1]); } elseif ( "" === $title && !preg_match( "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i", $line, ) ) { $title = self::clean_outline_heading($line); continue; } if ( "" !== $heading && !preg_match( "/^(seo snapshot|sections?|outline|meta|focus keyword|secondary keywords?)\b/i", $heading, ) ) { if (null !== $current) { $sections[] = $current; } $current = [ "id" => wp_generate_uuid4(), "status" => "pending", "type" => "section", "heading" => $heading, "content" => [], ]; continue; } if (null !== $current) { $detail = preg_replace("/^[-*]\s+/", "", $line); if ( "" !== $detail && !preg_match( "/^(title|judul|meta|reading time|difficulty|cost estimate)\b/i", $detail, ) ) { $current["content"][] = [ "type" => "paragraph", "content" => $detail, ]; } } } if (null !== $current) { $sections[] = $current; } if (empty($sections)) { return null; } if ("" === $title) { $title = self::clean_outline_heading($fallback_title); } if ("" === $title && !empty($previous_plan["title"])) { $title = (string) $previous_plan["title"]; } if ("" === $title) { $title = __("Article Outline", "wp-agentic-writer"); } return [ "title" => $title, "meta" => [ "reading_time" => "5 min", "difficulty" => "intermediate", "cost_estimate" => 0.7, ], "sections" => $sections, ]; } /** * Clean markdown decoration from an outline heading. * * @since 0.2.2 * @param string $heading Heading text. * @return string */ public static function clean_outline_heading($heading) { $heading = trim((string) $heading); $heading = preg_replace('/^\s*["\'`]+|["\'`]+\s*$/', "", $heading); $heading = preg_replace("/\*\*(.*?)\*\*/", '$1', $heading); $heading = preg_replace("/\s+/", " ", $heading); return trim($heading); } // ========================================================================= // Block Manipulation Methods // ========================================================================= /** * Find block by client ID in parsed blocks array (recursive). * * @since 0.1.0 * @param array $blocks Parsed blocks array. * @param string $client_id Block client ID to find. * @return array|null Block data or null if not found. */ public static function find_block_by_client_id($blocks, $client_id) { foreach ($blocks as $block) { if ( isset($block["attrs"]["clientId"]) && $block["attrs"]["clientId"] === $client_id ) { return $block; } // Check inner blocks if ( isset($block["innerBlocks"]) && is_array($block["innerBlocks"]) ) { $found = self::find_block_by_client_id( $block["innerBlocks"], $client_id, ); if ($found) { return $found; } } } return null; } /** * Find block index in parsed blocks array. * * @since 0.1.0 * @param array $blocks Parsed blocks array. * @param string $client_id Block client ID to find. * @return int Block index or -1 if not found. */ public static function find_block_index($blocks, $client_id) { foreach ($blocks as $index => $block) { if ( isset($block["attrs"]["clientId"]) && $block["attrs"]["clientId"] === $client_id ) { return $index; } } return -1; } /** * Extract content from block data. * * @since 0.1.0 * @param array $block Block data. * @return string Block content. */ public static function extract_block_content($block) { if (isset($block["attrs"]["content"])) { return $block["attrs"]["content"]; } if (isset($block["innerHTML"])) { return wp_strip_all_tags($block["innerHTML"]); } return ""; } /** * Extract heading from block. * * @since 0.1.0 * @param array $block Block data. * @return array|null Heading data or null if not a heading. */ public static function extract_heading_from_block($block) { if ( "core/heading" === $block["blockName"] && isset($block["attrs"]["content"]) ) { return [ "heading" => $block["attrs"]["content"], ]; } return null; } /** * Extract content from block attributes based on block type. * * @since 0.1.0 * @param string $block_type Block type (e.g., 'core/paragraph'). * @param array $attrs Block attributes. * @return string Extracted content. */ public static function extract_block_content_from_attrs($block_type, $attrs) { switch ($block_type) { case "core/paragraph": return isset($attrs["content"]) ? $attrs["content"] : ""; case "core/heading": return isset($attrs["content"]) ? $attrs["content"] : ""; case "core/list": if (isset($attrs["values"]) && is_array($attrs["values"])) { return implode("\n", $attrs["values"]); } return ""; case "core/code": return isset($attrs["content"]) ? $attrs["content"] : ""; case "core/image": if (isset($attrs["url"]) && isset($attrs["alt"])) { return "![" . $attrs["alt"] . "](" . $attrs["url"] . ")"; } return ""; default: if (isset($attrs["content"])) { return $attrs["content"]; } if (isset($attrs["value"])) { return $attrs["value"]; } return ""; } } /** * Serialize block object for consistent handling. * * @since 0.1.0 * @param array $block Block data. * @return array Serialized block with clientId. */ public static function serialize_block($block) { if (!isset($block["attrs"]["clientId"])) { $block["attrs"]["clientId"] = isset($block["clientId"]) ? $block["clientId"] : uniqid(); } return $block; } /** * Get blocks from post content. * * @since 0.1.0 * @param int|string $post_id_or_content Post ID or post content string. * @return array Array of block objects. */ public static function select_blocks($post_id_or_content) { if (is_numeric($post_id_or_content)) { $post_id = (int) $post_id_or_content; $post = get_post($post_id); if (!$post) { return []; } $blocks = parse_blocks($post->post_content); } else { $blocks = parse_blocks((string) $post_id_or_content); } return array_filter($blocks, function ($block) { return !empty($block["blockName"]); }); } // ========================================================================= // Refinement Methods // ========================================================================= /** * Clean refined content by removing conversational text. * * @since 0.1.0 * @param string $content Content to clean. * @return string Cleaned content. */ public static function clean_refined_content($content) { $conversational_prefixes = [ 'Certainly! Here\'s', 'Here\'s', "The refined content", "Here is the", "Below is the", "Okay, here", "Sure, here", ]; foreach ($conversational_prefixes as $prefix) { if (stripos($content, $prefix) === 0) { $content = substr($content, strlen($prefix)); $content = ltrim($content, ":\n\r "); } } // Remove markdown code blocks if present $content = preg_replace('/^```(?:text|markdown)?\n*/i', "", $content); $content = preg_replace('/```*$/i', "", $content); // Remove common analysis scaffolding $content = preg_replace("/^\s*Refined version\s*:\s*/im", "", $content); $content = preg_replace( '/^\s*Key refinements\s*:\s*$/im', "", $content, ); $content = preg_replace( '/^\s*(The refinement .*|Changes made .*|Explanation .*|Rationale .*)$/im', "", $content, ); // Strip meta markers sections $meta_markers = [ "Key refinements:", "Changes made:", "Explanation:", "Rationale:", ]; foreach ($meta_markers as $marker) { $pos = stripos($content, $marker); if (false !== $pos) { $content = substr($content, 0, $pos); } } $content = preg_replace("/^\s*content\s*:\s*/im", "", $content); $content = trim($content); return $content; } /** * Parse refined payload that may be wrapped in JSON. * * @since 0.1.0 * @param string $content Raw model content. * @return array Parsed payload with content and optional blockType. */ public static function parse_refined_payload($content) { $payload = [ "content" => $content, ]; if (!is_string($content)) { return $payload; } $trimmed = trim($content); if ("" === $trimmed) { return $payload; } if ($trimmed[0] !== "{" || substr($trimmed, -1) !== "}") { return $payload; } $decoded = json_decode($trimmed, true); if (!is_array($decoded)) { return $payload; } if (isset($decoded["content"]) && is_string($decoded["content"])) { $payload["content"] = $decoded["content"]; } $block_type = $decoded["blockType"] ?? ($decoded["type"] ?? null); if (is_string($block_type) && 0 === strpos($block_type, "core/")) { $payload["blockType"] = $block_type; } return $payload; } /** * Detect assistant/meta chatter that should never be inserted as block content. * * @since 0.1.0 * @param string $content Refined content candidate. * @param string $block_type Resolved block type. * @return bool */ public static function is_contaminated_refinement_output( $content, $block_type = "core/paragraph", ) { $text = trim(wp_strip_all_tags((string) $content)); if ("" === $text) { return true; } $meta_patterns = [ "/\b(i apologize|could you please|would you like me|please share|no specific content was provided)\b/i", "/\b(refined version|key refinements|changes made|rationale|note:\s*since)\b/i", '/\b(i have kept the heading|if you\'d like me to refine this)\b/i', ]; foreach ($meta_patterns as $pattern) { if (preg_match($pattern, $text)) { return true; } } // Headings should be concise and single-line. if ("core/heading" === $block_type) { if (strlen($text) > 180 || substr_count($text, "\n") > 0) { return true; } } return false; } /** * Build a compact section-scoped context window around a block. * * @since 0.1.0 * @param array $all_blocks All serialized editor blocks. * @param int $block_index Current block index. * @param int $max_snippets Max context snippets. * @return string */ public static function build_section_context_for_block( $all_blocks, $block_index, $max_snippets = 4, ) { if ( !is_array($all_blocks) || $block_index < 0 || !isset($all_blocks[$block_index]) ) { return ""; } $start = $block_index; for ($i = $block_index - 1; $i >= 0; $i--) { $name = $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? ""); if ("core/heading" === $name) { $start = $i; break; } $start = $i; } $end = $block_index; for ($i = $block_index + 1; $i < count($all_blocks); $i++) { $name = $all_blocks[$i]["name"] ?? ($all_blocks[$i]["blockName"] ?? ""); if ("core/heading" === $name) { break; } $end = $i; } $snippets = []; for ($i = $start; $i <= $end; $i++) { $block = $all_blocks[$i]; $name = $block["name"] ?? ($block["blockName"] ?? ""); if ( !in_array( $name, [ "core/heading", "core/paragraph", "core/list", "core/quote", ], true, ) ) { continue; } $attrs = $block["attributes"] ?? ($block["attrs"] ?? []); $text = trim( wp_strip_all_tags( self::extract_block_content_from_attrs($name, $attrs), ), ); if ("" === $text) { continue; } if (strlen($text) > 180) { $text = substr($text, 0, 180) . "..."; } $snippets[] = "- " . $text; if (count($snippets) >= $max_snippets) { break; } } return implode("\n", $snippets); } /** * Create block structure for refined content. * * @since 0.1.0 * @param string $block_id Block client ID. * @param string $block_type Block type. * @param string $content Refined content. * @return array Block structure. */ public static function create_block_structure($block_id, $block_type, $content) { if ( preg_match( '/^!\[(.*?)\]\(([^\s)]+)(?:\s+"[^"]*")?\)\s*$/', trim($content), $matches, ) ) { $alt = trim($matches[1]); $url = trim($matches[2]); $escaped_alt = esc_attr($alt); $escaped_url = esc_url($url); return [ "blockName" => "core/image", "attrs" => [ "id" => 0, "url" => $escaped_url, "alt" => $alt, "caption" => "", "sizeSlug" => "large", "linkDestination" => "none", ], "innerHTML" => '
' .
                    $escaped_alt .
                    '
', "clientId" => $block_id, ]; } if ("core/paragraph" === $block_type) { return [ "blockName" => "core/paragraph", "attrs" => ["content" => $content], "innerHTML" => "

" . $content . "

", "clientId" => $block_id, ]; } elseif ("core/heading" === $block_type) { $level = 2; if (preg_match("/^(#{1,6})\s/", $content)) { $count = strspn($content, "#"); $level = min($count, 6); $content = trim(substr($content, $count)); } $tag = "h" . $level; return [ "blockName" => "core/heading", "attrs" => [ "level" => $level, "content" => $content, ], "innerHTML" => "<{$tag}>{$content}", "clientId" => $block_id, ]; } elseif ("core/list" === $block_type) { $lines = explode("\n", $content); $lines = array_filter(array_map("trim", $lines)); $inner_blocks = []; foreach ($lines as $line) { $inner_blocks[] = [ "blockName" => "core/list-item", "attrs" => ["content" => $line], "innerHTML" => "
  • " . $line . "
  • ", ]; } return [ "blockName" => "core/list", "attrs" => ["ordered" => false], "innerBlocks" => $inner_blocks, "clientId" => $block_id, ]; } elseif ("core/code" === $block_type) { $language = "text"; $code_content = $content; if (preg_match("/^```(\w+)?\s*/", $content, $matches)) { if (!empty($matches[1])) { $language = $matches[1]; } $code_content = preg_replace( "/^```\w*\s*/", "", $code_content, ); $code_content = preg_replace('/```\s*$/', "", $code_content); $code_content = trim($code_content); } $escaped = htmlspecialchars($code_content, ENT_NOQUOTES, "UTF-8"); return [ "blockName" => "core/code", "attrs" => [ "language" => $language, "content" => $code_content, ], "innerHTML" => '
    ' .
                        $escaped .
                        "
    ", "clientId" => $block_id, ]; } // Fallback to paragraph return [ "blockName" => "core/paragraph", "attrs" => ["content" => $content], "innerHTML" => "

    " . $content . "

    ", "clientId" => $block_id, ]; } /** * Sanitize refinement edit plan with allowlist validation. * * @since 0.1.0 * @param array $plan_json Edit plan JSON. * @param array $allowed_block_ids Allowed block IDs. * @param array $context_blocks Context blocks for type resolution. * @return array Sanitized plan. */ public static function sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks, ) { $allowed_actions = [ "keep", "replace", "insert_after", "insert_before", "delete", "change_type", ]; $allowed_lookup = array_fill_keys($allowed_block_ids, true); $context_by_id = []; foreach ($context_blocks as $block) { $context_id = sanitize_text_field($block["clientId"] ?? ""); if ("" === $context_id) { continue; } $context_by_id[$context_id] = sanitize_text_field( $block["type"] ?? "core/paragraph", ); } $raw_actions = $plan_json["actions"] ?? []; if (!is_array($raw_actions)) { $raw_actions = []; } $sanitized_actions = []; foreach ($raw_actions as $action) { if (!is_array($action)) { continue; } $action_name = sanitize_key($action["action"] ?? ""); $block_id = sanitize_text_field($action["blockId"] ?? ""); if ( "" === $action_name || !in_array($action_name, $allowed_actions, true) ) { continue; } if ("" === $block_id || !isset($allowed_lookup[$block_id])) { continue; } $clean_action = [ "action" => $action_name, "blockId" => $block_id, ]; if ( in_array( $action_name, ["replace", "insert_after", "insert_before", "change_type"], true, ) ) { $fallback_type = $context_by_id[$block_id] ?? "core/paragraph"; $clean_action["blockType"] = sanitize_text_field( $action["blockType"] ?? $fallback_type, ); $clean_action["content"] = isset($action["content"]) ? wp_kses_post((string) $action["content"]) : ""; } $sanitized_actions[] = $clean_action; } return [ "summary" => sanitize_text_field($plan_json["summary"] ?? ""), "actions" => $sanitized_actions, ]; } /** * Parse refined blocks from content split by [Block N] markers. * * @since 0.1.0 * @param string $content Raw content. * @param int $expected_count Expected number of blocks. * @return array Array of block contents. */ public static function parse_refined_blocks($content, $expected_count = 0) { $blocks = []; $parts = preg_split("/\[Block\s*\d+\]/i", $content); array_shift($parts); foreach ($parts as $part) { $block_content = trim($part); if (!empty($block_content)) { $blocks[] = $block_content; } } if (empty($blocks) && !empty(trim($content))) { $blocks[] = trim($content); } return $blocks; } // ========================================================================= // SEO & Pattern Detection Methods // ========================================================================= /** * Scan for AI-ish patterns in content. * * @since 0.1.0 * @param string $raw_content Raw content to scan. * @return array Detection results with count and examples. */ public static function scan_ai_ish_patterns($raw_content) { $normalized = wp_strip_all_tags((string) $raw_content); $normalized = preg_replace("/\s+/", " ", $normalized); $normalized = trim((string) $normalized); if ("" === $normalized) { return [ "count" => 0, "examples" => [], ]; } $rules = [ [ "id" => "double_colon", "pattern" => "/[^\s]:\s*:[^\s]/u", "label" => "double colon punctuation", ], [ "id" => "ai_phrase_not_only_but", "pattern" => "/\bbukan sekadar\b|\bnot just\b/i", "label" => "formulaic contrast phrase", ], [ "id" => "ai_phrase_in_conclusion", "pattern" => "/\b(pada akhirnya|in conclusion|to summarize)\b/i", "label" => "template-like conclusion phrase", ], [ "id" => "meta_instruction_leak", "pattern" => "/\b(refined version|key refinements|changes made|rationale|could you please share)\b/i", "label" => "instructional/meta leakage", ], [ "id" => "dash_overuse", "pattern" => "/\s[—–-]\s/u", "label" => "dash-heavy sentence style", ], ]; $matches = []; $total = 0; foreach ($rules as $rule) { if ( preg_match_all( $rule["pattern"], $normalized, $found, PREG_OFFSET_CAPTURE, ) ) { $total += count($found[0]); if (count($matches) < 5) { foreach ($found[0] as $entry) { if (count($matches) >= 5) { break; } $matched_text = (string) ($entry[0] ?? ""); $offset = (int) ($entry[1] ?? 0); $context_start = max(0, $offset - 48); $context = function_exists("mb_substr") ? mb_substr($normalized, $context_start, 120) : substr($normalized, $context_start, 120); $matches[] = [ "type" => $rule["label"], "match" => trim($matched_text), "context" => trim($context), ]; } } } } return [ "count" => (int) $total, "examples" => $matches, ]; } /** * Extract unresolved image slots from post content. * * @since 0.1.0 * @param string $post_content Post content string. * @return array Array of unresolved image slot data. */ public static function extract_unresolved_image_slots($post_content) { $slots = []; $blocks = parse_blocks((string) $post_content); $walk = function ($items, $heading_context = "") use (&$walk, &$slots) { foreach ($items as $block) { $name = $block["blockName"] ?? ""; $attrs = $block["attrs"] ?? []; if ("core/heading" === $name) { $heading = ""; if (!empty($attrs["content"])) { $heading = trim( wp_strip_all_tags((string) $attrs["content"]), ); } elseif (!empty($block["innerHTML"])) { $heading = trim( wp_strip_all_tags((string) $block["innerHTML"]), ); } if ("" !== $heading) { $heading_context = $heading; } } if ("core/image" === $name) { $image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0; if ($image_id <= 0) { $slots[] = [ "section_title" => $heading_context, ]; } } if ( !empty($block["innerBlocks"]) && is_array($block["innerBlocks"]) ) { $walk($block["innerBlocks"], $heading_context); } } }; $walk($blocks, ""); return $slots; } // ========================================================================= // Memory & Context Methods // ========================================================================= /** * Build a short memory summary from the plan JSON. * * @since 0.1.0 * @param array $plan_json Plan data. * @return string Summary text. */ public static function build_memory_summary_from_plan($plan_json) { if (empty($plan_json) || !is_array($plan_json)) { return ""; } $title = $plan_json["title"] ?? ""; $headings = []; if ( !empty($plan_json["sections"]) && is_array($plan_json["sections"]) ) { foreach ($plan_json["sections"] as $section) { if (!empty($section["heading"])) { $headings[] = $section["heading"]; } } } $summary = ""; if ($title) { $summary .= "Title: {$title}\n"; } if (!empty($headings)) { $summary .= "Sections: " . implode(" | ", $headings); } return trim($summary); } /** * Build memory context string for prompts. * * @since 0.1.0 * @param int $post_id Post ID. * @return string Context string. */ public static function get_post_memory_context($post_id) { if ($post_id <= 0) { return ""; } $memory = get_post_meta($post_id, "_wpaw_memory", true); if (empty($memory) || !is_array($memory)) { return ""; } $lines = []; if (!empty($memory["summary"])) { $lines[] = "Summary: " . $memory["summary"]; } if (!empty($memory["last_prompt"])) { $lines[] = "Last prompt: " . $memory["last_prompt"]; } if (!empty($memory["last_intent"])) { $lines[] = "Last intent: " . $memory["last_intent"]; } if (empty($lines)) { return ""; } return "\n\n=== POST MEMORY ===\n" . implode("\n", $lines) . "\n=== END POST MEMORY ===\n"; } }