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" =>
'
" . $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}{$tag}>", "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" => "' .
$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"; } }