Phase 1: Core Client
- New class-memanto-client.php: Singleton PHP client for MEMANTO API v2
- Health check with 5-min transient caching
- Agent CRUD (ensure, activate, deactivate sessions)
- Memory operations (remember, batch_remember, recall, recall_recent)
- Auto re-activation on expired session tokens (401 retry)
Phase 2: Write-Through Memory Hooks
- New class-memanto-context-enhancer.php: Orchestrates remember/recall
- Fires on: user message, plan generated/approved/rejected,
section written, block refined, config saved, session start/end
- All hooks via do_action() — zero coupling to MEMANTO when disabled
Phase 3: Context Enrichment
- Context builder injects recalled memories into AI prompts
via build_memanto_context() in build_working_context()
- 3-recall strategy: recent post memories, semantic search, user preferences
- Deduplication by content hash
Phase 4: Cross-Session Restore
- New REST endpoints: /memanto/restore, /memanto/preferences
- restore_session() recalls 15 recent memories + user preferences on editor load
- build_session_restore_message() creates AI-ready system message
- get_user_preferences_for_new_post() extracts tone/audience/length/language
- Frontend: 🧠 Restored badge in status bar with memory count tooltip
- Preference carry-over: auto-fills post config from stored user preferences
- deactivate_session() called on session end (triggers MEMANTO summary)
- Badge clears on new conversation start
Settings UI:
- MEMANTO Context Keeper section with enable toggle, URL, API key, test connection
- Settings registered via class-settings-v2.php + tab-memanto.php view
Graceful degradation: all MEMANTO calls guarded by is_active(),
frontend catches silently, plugin works identically when disabled.
745 lines
22 KiB
PHP
745 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);
|
|
|
|
// 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);
|
|
}
|
|
}
|