Files
wp-agentic-writer/includes/class-context-builder.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

753 lines
22 KiB
PHP

<?php
/**
* Context Builder
*
* Builds compact, backend-owned prompt context from saved sessions and posts.
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_Context_Builder
*
* OpenRouter and other providers are stateless gateways. This class keeps
* continuity in WordPress by selecting the relevant session/post context for
* each request without resending the full browser history.
*/
class WP_Agentic_Writer_Context_Builder
{
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Context_Builder
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Context_Builder
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Build a context package for a task.
*
* @param string $task Task name.
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @param array $request_params Request params.
* @return array Context package.
*/
public function build_for_task(
$task,
$session_id,
$post_id,
$request_params = [],
) {
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
$saved_context = !empty($session_id)
? $context_service->get_context($session_id, $post_id)
: [];
$session_context = $saved_context["context"] ?? [];
$messages = $saved_context["messages"] ?? [];
if (empty($messages)) {
$messages = $this->get_request_messages($request_params);
}
$token_policy = $this->get_token_policy($session_context);
$recent_messages = $this->prepare_recent_messages(
$messages,
$token_policy["max_recent_messages"],
);
$recent_messages = $this->remove_active_user_message(
$recent_messages,
$request_params["latestUserMessage"] ?? "",
);
$post_config = $this->resolve_post_config(
$saved_context,
$request_params,
);
$plan = $this->resolve_plan($saved_context, $request_params, $post_id);
$working_context = $this->build_working_context(
$task,
$session_context,
$recent_messages,
$plan,
$post_config,
$request_params,
$post_id,
);
// Check MEMANTO status for audit (lightweight: just checks is_active, no recall).
$memanto_active = WP_Agentic_Writer_Memanto_Client::get_instance()->is_active();
return [
"system_context" => "",
"working_context" => $working_context,
"active_content" => $this->get_active_content($request_params),
"research_context" => $this->build_research_context(
$session_context,
$request_params,
$token_policy["max_research_snippets"],
),
"audit" => [
"included_recent_messages" => count($recent_messages),
"included_research_items" => $this->count_research_items(
$session_context,
$request_params,
$token_policy["max_research_snippets"],
),
"estimated_input_tokens" => $this->estimate_tokens(
$working_context,
),
"used_full_history" => false,
"memanto_active" => $memanto_active,
],
];
}
/**
* Build a system message that can be inserted after the primary system prompt.
*
* @param string $task Task name.
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @param array $request_params Request params.
* @return array Context system message and audit metadata.
*/
public function build_system_message(
$task,
$session_id,
$post_id,
$request_params = [],
) {
$package = $this->build_for_task(
$task,
$session_id,
$post_id,
$request_params,
);
$content = trim(
$package["working_context"] .
"\n" .
$package["active_content"] .
"\n" .
$package["research_context"],
);
if ("" === $content) {
return [
"message" => null,
"audit" => $package["audit"],
];
}
return [
"message" => [
"role" => "system",
"content" => $content,
],
"audit" => $package["audit"],
];
}
/**
* Get task token policy.
*
* @param array $session_context Session context.
* @return array Token policy.
*/
private function get_token_policy($session_context)
{
$policy =
isset($session_context["token_policy"]) &&
is_array($session_context["token_policy"])
? $session_context["token_policy"]
: [];
return [
"max_recent_messages" => max(
2,
(int) ($policy["max_recent_messages"] ?? 6),
),
"max_summary_tokens" => max(
200,
(int) ($policy["max_summary_tokens"] ?? 600),
),
"max_research_snippets" => max(
0,
(int) ($policy["max_research_snippets"] ?? 5),
),
];
}
/**
* Get messages from request fallback.
*
* @param array $request_params Request params.
* @return array Messages.
*/
private function get_request_messages($request_params)
{
if (
!empty($request_params["messages"]) &&
is_array($request_params["messages"])
) {
return $request_params["messages"];
}
if (
!empty($request_params["chatHistory"]) &&
is_array($request_params["chatHistory"])
) {
return $request_params["chatHistory"];
}
return [];
}
/**
* Prepare compact recent messages.
*
* @param array $messages Messages.
* @param int $max_messages Max messages.
* @return array Recent messages.
*/
private function prepare_recent_messages($messages, $max_messages)
{
$prepared = [];
foreach ((array) $messages as $message) {
$role = isset($message["role"]) ? (string) $message["role"] : "";
if (!in_array($role, ["user", "assistant"], true)) {
continue;
}
$content = isset($message["content"])
? trim(wp_strip_all_tags((string) $message["content"]))
: "";
if ("" === $content) {
continue;
}
$prepared[] = [
"role" => $role,
"content" => $this->truncate_text($content, 900),
];
}
if (count($prepared) > $max_messages) {
$prepared = array_slice($prepared, -1 * $max_messages);
}
return $prepared;
}
/**
* Avoid echoing the active user turn inside the saved-context excerpt.
*
* @param array $messages Recent messages.
* @param string $active_user_message Active user message.
* @return array Messages without duplicate active turn.
*/
private function remove_active_user_message($messages, $active_user_message)
{
$active_user_message = trim(
wp_strip_all_tags((string) $active_user_message),
);
if ("" === $active_user_message || empty($messages)) {
return $messages;
}
for ($i = count($messages) - 1; $i >= 0; $i--) {
if ("user" !== ($messages[$i]["role"] ?? "")) {
continue;
}
$content = trim((string) ($messages[$i]["content"] ?? ""));
if ($content === $active_user_message) {
array_splice($messages, $i, 1);
}
break;
}
return $messages;
}
/**
* Resolve post config.
*
* @param array $saved_context Saved context package.
* @param array $request_params Request params.
* @return array Post config.
*/
private function resolve_post_config($saved_context, $request_params)
{
$config = $saved_context["post_config"] ?? [];
if (
!empty($request_params["postConfig"]) &&
is_array($request_params["postConfig"])
) {
$config = wp_parse_args($request_params["postConfig"], $config);
}
return is_array($config) ? $config : [];
}
/**
* Resolve current plan.
*
* @param array $saved_context Saved context package.
* @param array $request_params Request params.
* @param int $post_id Post ID.
* @return array|null Plan.
*/
private function resolve_plan($saved_context, $request_params, $post_id)
{
if (
!empty($saved_context["plan"]) &&
is_array($saved_context["plan"])
) {
return $saved_context["plan"];
}
if (
!empty($request_params["plan"]) &&
is_array($request_params["plan"])
) {
return $request_params["plan"];
}
if ($post_id > 0) {
$plan = get_post_meta($post_id, "_wpaw_plan", true);
if (is_array($plan)) {
return $plan;
}
}
return null;
}
/**
* Build compact working context.
*
* @param string $task Task name.
* @param array $session_context Session context.
* @param array $recent_messages Recent messages.
* @param array $plan Current plan.
* @param array $post_config Post config.
* @param array $request_params Request params.
* @param int $post_id Post ID.
* @return string Context text.
*/
private function build_working_context(
$task,
$session_context,
$recent_messages,
$plan,
$post_config,
$request_params,
$post_id = 0,
) {
$sections = [];
$sections[] =
"BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns.";
$sections[] = "Current task: " . sanitize_key($task);
// Global brand voice / context injection
$settings = get_option("wp_agentic_writer_settings", []);
$global_context = trim($settings["global_context"] ?? "");
if ("" !== $global_context) {
$sections[] =
"GLOBAL SITE CONTEXT (CRITICAL RULES):\n" . $global_context;
}
// MEMANTO persistent memory injection.
$memanto_context = $this->build_memanto_context(
$post_id,
$request_params["latestUserMessage"] ?? "",
);
if ("" !== $memanto_context) {
$sections[] = $memanto_context;
}
$summary = $session_context["working_summary"]["text"] ?? "";
if ("" !== trim((string) $summary)) {
$sections[] =
"Working summary:\n" .
$this->truncate_text((string) $summary, 1600);
}
$config_summary = $this->summarize_post_config($post_config);
if ("" !== $config_summary) {
$sections[] = "Article configuration:\n" . $config_summary;
}
$plan_summary = $this->summarize_plan($plan);
if ("" !== $plan_summary) {
$sections[] = "Current plan:\n" . $plan_summary;
}
$decision_summary = $this->summarize_context_items(
$session_context,
"decisions",
"Decisions",
);
if ("" !== $decision_summary) {
$sections[] = $decision_summary;
}
$rejection_summary = $this->summarize_context_items(
$session_context,
"rejections",
"Rejected directions",
);
if ("" !== $rejection_summary) {
$sections[] = $rejection_summary;
}
if (!empty($request_params["context"])) {
$sections[] =
"User supplied context:\n" .
$this->truncate_text((string) $request_params["context"], 1600);
}
if (!empty($recent_messages)) {
$lines = [];
foreach ($recent_messages as $message) {
$lines[] =
ucfirst($message["role"]) . ": " . $message["content"];
}
$sections[] =
"Recent saved conversation excerpts:\n" . implode("\n", $lines);
}
return implode("\n\n", array_filter($sections));
}
/**
* Summarize post config.
*
* @param array $post_config Post config.
* @return string Summary.
*/
private function summarize_post_config($post_config)
{
$lines = [];
$keys = [
"article_length" => "Article length",
"language" => "Language",
"tone" => "Tone",
"audience" => "Audience",
"experience_level" => "Experience level",
"seo_focus_keyword" => "SEO focus keyword",
"seo_secondary_keywords" => "SEO secondary keywords",
];
foreach ($keys as $key => $label) {
if (
isset($post_config[$key]) &&
"" !== trim((string) $post_config[$key])
) {
$lines[] =
"- " . $label . ": " . trim((string) $post_config[$key]);
}
}
if (isset($post_config["include_images"])) {
$lines[] =
"- Include images: " .
($post_config["include_images"] ? "yes" : "no");
}
if (isset($post_config["web_search"])) {
$lines[] =
"- Web search: " . ($post_config["web_search"] ? "yes" : "no");
}
return implode("\n", $lines);
}
/**
* Summarize current plan.
*
* @param array|null $plan Plan.
* @return string Summary.
*/
private function summarize_plan($plan)
{
if (empty($plan) || !is_array($plan)) {
return "";
}
$lines = [];
if (!empty($plan["title"])) {
$lines[] = "Title: " . $plan["title"];
}
if (!empty($plan["sections"]) && is_array($plan["sections"])) {
foreach ($plan["sections"] as $index => $section) {
$heading = $section["heading"] ?? ($section["title"] ?? "");
if ("" === trim((string) $heading)) {
continue;
}
$status = $section["status"] ?? "pending";
$lines[] = sprintf(
"%d. [%s] %s",
$index + 1,
$status,
$heading,
);
}
}
return implode("\n", $lines);
}
/**
* Summarize context array items.
*
* @param array $session_context Session context.
* @param string $key Context key.
* @param string $label Label.
* @return string Summary.
*/
private function summarize_context_items($session_context, $key, $label)
{
if (
empty($session_context[$key]) ||
!is_array($session_context[$key])
) {
return "";
}
$lines = [];
foreach (array_slice($session_context[$key], -8) as $item) {
$summary = $item["summary"] ?? "";
if ("" === trim((string) $summary)) {
continue;
}
$target = !empty($item["target"])
? "[" . $item["target"] . "] "
: "";
$lines[] = "- " . $target . $summary;
}
return empty($lines) ? "" : $label . ":\n" . implode("\n", $lines);
}
/**
* Get active content from request params.
*
* @param array $request_params Request params.
* @return string Active content context.
*/
private function get_active_content($request_params)
{
$candidates = [
"activeContent",
"blockContent",
"selectedText",
"sectionContent",
"articleContent",
];
$lines = [];
foreach ($candidates as $key) {
if (
!empty($request_params[$key]) &&
is_string($request_params[$key])
) {
$lines[] =
$key .
":\n" .
$this->truncate_text($request_params[$key], 2200);
}
}
return empty($lines)
? ""
: "ACTIVE CONTENT SLICE\n" . implode("\n\n", $lines);
}
/**
* Build research context.
*
* @param array $session_context Session context.
* @param array $request_params Request params.
* @param int $limit Max snippets.
* @return string Research context.
*/
private function build_research_context(
$session_context,
$request_params,
$limit,
) {
if ($limit <= 0) {
return "";
}
$items = [];
if (
!empty($session_context["research_notes"]) &&
is_array($session_context["research_notes"])
) {
$items = array_merge($items, $session_context["research_notes"]);
}
if (
!empty($request_params["researchNotes"]) &&
is_array($request_params["researchNotes"])
) {
$items = array_merge($items, $request_params["researchNotes"]);
}
$items = array_slice($items, -1 * $limit);
$lines = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$title = $item["title"] ?? ($item["source"] ?? "Research note");
$excerpt = $item["excerpt"] ?? ($item["notes"] ?? "");
if ("" === trim((string) $excerpt)) {
continue;
}
$lines[] =
"- " .
$title .
": " .
$this->truncate_text((string) $excerpt, 700);
}
return empty($lines)
? ""
: "RELEVANT RESEARCH\n" . implode("\n", $lines);
}
/**
* Count included research items.
*
* @param array $session_context Session context.
* @param array $request_params Request params.
* @param int $limit Max snippets.
* @return int Count.
*/
private function count_research_items(
$session_context,
$request_params,
$limit,
) {
if ($limit <= 0) {
return 0;
}
$count = 0;
if (
!empty($session_context["research_notes"]) &&
is_array($session_context["research_notes"])
) {
$count += count($session_context["research_notes"]);
}
if (
!empty($request_params["researchNotes"]) &&
is_array($request_params["researchNotes"])
) {
$count += count($request_params["researchNotes"]);
}
return min($limit, $count);
}
/**
* Build MEMANTO persistent memory context section.
*
* Calls recall_for_context() via the MEMANTO Context Enhancer
* and formats the returned memories into a prompt-ready section.
* Returns empty string when MEMANTO is inactive or no memories found.
*
* @param int $post_id Post ID.
* @param string $current_message User's current message for semantic search.
* @return string Formatted memory section or empty string.
*/
private function build_memanto_context($post_id, $current_message = "")
{
// Guard: skip entirely if MEMANTO is not active.
$memanto = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance();
$memories = $memanto->recall_for_context(
$post_id,
get_current_user_id(),
$current_message,
);
if (empty($memories) || !is_array($memories)) {
return "";
}
$lines = [];
foreach ($memories as $memory) {
$type = ucfirst($memory["type"] ?? "memory");
$content = trim((string) ($memory["content"] ?? ""));
if ("" === $content) {
continue;
}
$title = !empty($memory["title"])
? " [" . trim($memory["title"]) . "]"
: "";
$lines[] = "- ({$type}){$title} {$content}";
}
if (empty($lines)) {
return "";
}
return "PERSISTENT MEMORY (from MEMANTO)\n" .
"The following are memories recalled from prior sessions and interactions. " .
"Use them to maintain continuity, respect user preferences, and avoid repeating past mistakes.\n" .
implode("\n", $lines);
}
/**
* Truncate text safely.
*
* @param string $text Text.
* @param int $limit Character limit.
* @return string Truncated text.
*/
private function truncate_text($text, $limit)
{
$text = trim((string) $text);
if (strlen($text) <= $limit) {
return $text;
}
return substr($text, 0, $limit) . "...";
}
/**
* Estimate tokens from character length.
*
* @param string $text Text.
* @return int Estimated tokens.
*/
private function estimate_tokens($text)
{
return (int) ceil(strlen((string) $text) / 4);
}
}