Files
wp-agentic-writer/includes/class-memanto-context-enhancer.php
Dwindi Ramadhana 619d36d3c8 feat: MEMANTO integration — persistent memory for cross-session context (Phases 1-4)
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.
2026-06-08 12:42:04 +07:00

755 lines
22 KiB
PHP

<?php
/**
* MEMANTO Context Enhancer
*
* Orchestrates when and what to remember/recall from MEMANTO.
* Hooks into the existing Context Service to provide memory
* enrichment without replacing any existing behavior.
*
* @package WP_Agentic_Writer
* @since 0.3.0
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_Memanto_Context_Enhancer
*
* Every public method checks is_active() first and returns gracefully
* if MEMANTO is not available. The plugin never breaks.
*/
class WP_Agentic_Writer_Memanto_Context_Enhancer
{
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Memanto_Context_Enhancer
*/
private static $instance = null;
/**
* MEMANTO client reference.
*
* @var WP_Agentic_Writer_Memanto_Client
*/
private $client;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Memanto_Context_Enhancer
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*
* Registers all WordPress action/filter hooks so that the sidebar
* handlers only need a single do_action() call at each trigger point.
*/
private function __construct()
{
$this->client = WP_Agentic_Writer_Memanto_Client::get_instance();
// Session lifecycle.
add_action(
"wpaw_memanto_session_start",
[$this, "on_session_start"],
10,
3,
);
add_action(
"wpaw_memanto_session_end",
[$this, "on_session_end"],
10,
2,
);
// Write-through: remember on meaningful events.
add_action(
"wpaw_memanto_user_message",
[$this, "on_user_message"],
10,
3,
);
add_action(
"wpaw_memanto_plan_generated",
[$this, "on_plan_generated"],
10,
2,
);
add_action(
"wpaw_memanto_plan_approved",
[$this, "on_plan_approved"],
10,
2,
);
add_action(
"wpaw_memanto_plan_rejected",
[$this, "on_plan_rejected"],
10,
2,
);
add_action(
"wpaw_memanto_section_written",
[$this, "on_section_written"],
10,
3,
);
add_action(
"wpaw_memanto_block_refined",
[$this, "on_block_refined"],
10,
3,
);
add_action(
"wpaw_memanto_config_saved",
[$this, "on_config_saved"],
10,
2,
);
}
// =========================================================================
// Session Lifecycle
// =========================================================================
/**
* Called when a conversation session starts.
* Ensures agents exist and recalls previous session state.
*
* @param string $session_id Session ID.
* @param int $post_id Post ID.
* @param int $user_id WordPress user ID.
*/
public function on_session_start($session_id, $post_id, $user_id)
{
if (!$this->client->is_active()) {
return;
}
// Ensure user agent exists.
if ($user_id > 0) {
$this->client->ensure_agent(
$this->client->get_user_agent_id($user_id),
);
}
// Ensure post agent exists.
if ($post_id > 0) {
$this->client->ensure_agent(
$this->client->get_post_agent_id($post_id),
);
}
}
/**
* Called when a conversation session ends.
* Stores a session summary memory and deactivates the session.
*
* @param string $session_id Session ID.
* @param int $post_id Post ID.
*/
public function on_session_end($session_id, $post_id)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$post_agent = $this->client->get_post_agent_id($post_id);
// Store a session summary.
$this->client->remember(
$post_agent,
"Session ended: " . $session_id,
"context",
["session:" . $session_id, "post:" . $post_id],
"Session end",
);
// Deactivate MEMANTO session to trigger summary generation.
$this->client->deactivate_session($post_agent);
}
// =========================================================================
// Write-Through: Remember on Meaningful Events
// =========================================================================
/**
* Store a memory when user sends a chat message.
*
* @param string $session_id Session ID.
* @param string $content User message content.
* @param int $post_id Post ID.
*/
public function on_user_message($session_id, $content, $post_id)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$this->client->remember(
$this->client->get_post_agent_id($post_id),
"User instruction: " . wp_strip_all_tags($content),
"instruction",
["post:" . $post_id, "session:" . $session_id],
);
}
/**
* Store a memory when a plan is generated.
*
* @param int $post_id Post ID.
* @param array $plan Plan data.
*/
public function on_plan_generated($post_id, $plan)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$title = $plan["title"] ?? "Untitled";
$sections =
isset($plan["sections"]) && is_array($plan["sections"])
? count($plan["sections"])
: 0;
$this->client->remember(
$this->client->get_post_agent_id($post_id),
sprintf('Plan generated: "%s" with %d sections', $title, $sections),
"artifact",
["post:" . $post_id, "type:plan"],
"Plan: " . $title,
);
}
/**
* Store a memory when user approves a plan.
*
* @param int $post_id Post ID.
* @param array $plan Plan data.
*/
public function on_plan_approved($post_id, $plan)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$title = $plan["title"] ?? "Untitled";
$this->client->remember(
$this->client->get_post_agent_id($post_id),
sprintf('User approved plan: "%s"', $title),
"decision",
["post:" . $post_id, "type:plan"],
"Plan approved",
);
}
/**
* Store a memory when user rejects or requests plan changes.
*
* @param int $post_id Post ID.
* @param string $reason Rejection/revision reason.
*/
public function on_plan_rejected($post_id, $reason)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$this->client->remember(
$this->client->get_post_agent_id($post_id),
"User requested plan revision: " . wp_strip_all_tags($reason),
"error",
["post:" . $post_id, "type:plan"],
"Plan revision",
);
}
/**
* Store a memory when a section is written.
*
* @param int $post_id Post ID.
* @param string $section_id Section identifier.
* @param string $summary Brief section summary.
*/
public function on_section_written($post_id, $section_id, $summary)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$this->client->remember(
$this->client->get_post_agent_id($post_id),
"Section written (" .
$section_id .
"): " .
wp_strip_all_tags($summary),
"artifact",
["post:" . $post_id, "section:" . $section_id],
"Section: " . $section_id,
);
}
/**
* Store a memory when a block is refined.
*
* @param int $post_id Post ID.
* @param string $block_id Block identifier.
* @param string $instruction Refinement instruction.
*/
public function on_block_refined($post_id, $block_id, $instruction)
{
if (!$this->client->is_active() || $post_id <= 0) {
return;
}
$this->client->remember(
$this->client->get_post_agent_id($post_id),
"Block refined (" .
$block_id .
"): " .
wp_strip_all_tags($instruction),
"instruction",
["post:" . $post_id, "block:" . $block_id],
);
}
/**
* Store a memory when post config is saved.
*
* @param int $post_id Post ID.
* @param array $config Post config data.
*/
public function on_config_saved($post_id, $config)
{
if (!$this->client->is_active()) {
return;
}
$config_summary = sprintf(
"tone=%s, audience=%s, length=%s, language=%s",
$config["tone"] ?? "default",
$config["audience"] ?? "general",
$config["article_length"] ?? "medium",
$config["language"] ?? "auto",
);
// Store to post agent.
if ($post_id > 0) {
$this->client->remember(
$this->client->get_post_agent_id($post_id),
"Article config: " . $config_summary,
"preference",
["post:" . $post_id],
"Post config",
);
}
// Store to user agent (cross-post preferences).
$user_id = get_current_user_id();
if ($user_id > 0) {
$this->client->remember(
$this->client->get_user_agent_id($user_id),
"User preference: " . $config_summary,
"preference",
["user:" . $user_id],
"Writing preferences",
);
}
}
// =========================================================================
// Recall: Retrieve Memories for Context Enrichment
// =========================================================================
/**
* Recall relevant memories to enrich AI prompt context.
*
* @param int $post_id Post ID.
* @param int $user_id WordPress user ID.
* @param string $current_message User's current message (for semantic search).
* @return array Recalled memory items, each with 'type', 'content', 'title'.
*/
public function recall_for_context(
$post_id,
$user_id,
$current_message = "",
) {
if (!$this->client->is_active()) {
return [];
}
$memories = [];
$seen = [];
// 1. Recent post memories.
if ($post_id > 0) {
$post_agent = $this->client->get_post_agent_id($post_id);
$recent = $this->client->recall_recent($post_agent, 10);
foreach ($this->normalize_memories($recent) as $item) {
$hash = md5($item["content"]);
if (!isset($seen[$hash])) {
$seen[$hash] = true;
$memories[] = $item;
}
}
// 2. Semantic recall based on current message.
if (!empty($current_message)) {
$semantic = $this->client->recall(
$post_agent,
$current_message,
[],
5,
);
foreach ($this->normalize_memories($semantic) as $item) {
$hash = md5($item["content"]);
if (!isset($seen[$hash])) {
$seen[$hash] = true;
$memories[] = $item;
}
}
}
}
// 3. User preferences (cross-post).
if ($user_id > 0) {
$user_prefs = $this->client->recall(
$this->client->get_user_agent_id($user_id),
"writing preferences tone audience language",
["preference"],
5,
);
foreach ($this->normalize_memories($user_prefs) as $item) {
$hash = md5($item["content"]);
if (!isset($seen[$hash])) {
$seen[$hash] = true;
$memories[] = $item;
}
}
}
return $memories;
}
/**
* Restore session state from MEMANTO when reopening a post.
*
* Returns a structured restore payload that the frontend can use to:
* - Display a "Restored from memory" badge
* - Build a restored-session system message for the AI
* - Pre-fill config from prior preferences
*
* @since 0.4.0
* @param int $post_id Post ID being reopened.
* @param int $user_id Current user ID.
* @return array { restored: bool, memories: array, preferences: array, summary: string }
*/
public function restore_session($post_id, $user_id)
{
$empty = [
"restored" => false,
"memories" => [],
"preferences" => [],
"summary" => "",
];
if (!$this->client->is_active() || $post_id <= 0) {
return $empty;
}
// Recall recent post memories (no current message — restore is about history).
$post_agent = $this->client->get_post_agent_id($post_id);
$recent = $this->client->recall_recent($post_agent, 15);
$memories = $this->normalize_memories($recent);
// Recall user preferences (for cross-post config carry-over).
$preferences = [];
if ($user_id > 0) {
$user_prefs = $this->client->recall(
$this->client->get_user_agent_id($user_id),
"writing preferences tone audience language length",
["preference"],
5,
);
$preferences = $this->normalize_memories($user_prefs);
}
if (empty($memories) && empty($preferences)) {
return $empty;
}
// Build a human-readable summary for the restore badge tooltip.
$summary = $this->build_restore_summary($memories, $preferences);
return [
"restored" => true,
"memories" => $memories,
"preferences" => $preferences,
"summary" => $summary,
];
}
/**
* Build a compact summary string of restored memories.
*
* @param array $memories Restored memories.
* @param array $preferences Restored preferences.
* @return string Summary text.
*/
private function build_restore_summary($memories, $preferences)
{
$parts = [];
if (!empty($memories)) {
$parts[] = sprintf(
/* translators: %d: number of memories */
_n(
"%d memory",
"%d memories",
count($memories),
"wp-agentic-writer",
),
count($memories),
);
}
if (!empty($preferences)) {
$parts[] = sprintf(
/* translators: %d: number of preferences */
_n(
"%d preference",
"%d preferences",
count($preferences),
"wp-agentic-writer",
),
count($preferences),
);
}
return implode(", ", $parts);
}
/**
* Get user's writing preferences recalled from MEMANTO.
*
* Used when creating a new post to pre-fill the post config
* with the user's habitual tone, audience, etc.
*
* @since 0.4.0
* @param int $user_id WordPress user ID.
* @return array { restored: bool, config: array }
*/
public function get_user_preferences_for_new_post($user_id)
{
$empty = ["restored" => false, "config" => []];
if (!$this->client->is_active() || $user_id <= 0) {
return $empty;
}
$user_prefs = $this->client->recall(
$this->client->get_user_agent_id($user_id),
"writing preferences tone audience language length",
["preference"],
3,
);
$prefs = $this->normalize_memories($user_prefs);
if (empty($prefs)) {
return $empty;
}
// Extract config fields from preference content.
// Memory format: "User preference: tone=professional, audience=experts, length=long, language=en"
$config = $this->extract_config_from_preferences($prefs);
if (empty($config)) {
return $empty;
}
return ["restored" => true, "config" => $config];
}
/**
* Parse preference memory contents and extract config fields.
*
* @param array $prefs Normalized preference memories.
* @return array Extracted config: { tone, audience, article_length, language }.
*/
private function extract_config_from_preferences($prefs)
{
$config = [];
// Combine all preference content into one blob for parsing.
$blob = "";
foreach ($prefs as $pref) {
$blob .= " " . ($pref["content"] ?? "");
}
// Match key=value patterns.
if (preg_match('/tone\s*=\s*([^,\n]+)/i', $blob, $m)) {
$val = trim($m[1]);
if ("" !== $val && "default" !== strtolower($val)) {
$config["tone"] = $val;
}
}
if (preg_match('/audience\s*=\s*([^,\n]+)/i', $blob, $m)) {
$val = trim($m[1]);
if ("" !== $val && "general" !== strtolower($val)) {
$config["audience"] = $val;
}
}
if (preg_match('/(?:article_)?length\s*=\s*([^,\n]+)/i', $blob, $m)) {
$val = trim($m[1]);
if ("" !== $val && "medium" !== strtolower($val)) {
$config["article_length"] = $val;
}
}
if (preg_match('/language\s*=\s*([^,\n]+)/i', $blob, $m)) {
$val = trim($m[1]);
if ("" !== $val && "auto" !== strtolower($val)) {
$config["language"] = $val;
}
}
return $config;
}
/**
* Build a "restored session" system message for the AI.
*
* This message summarizes prior post work so the AI can resume
* mid-article without re-asking the user for context.
*
* @since 0.4.0
* @param array $restore_payload Result from restore_session().
* @return string System message content (empty if nothing to restore).
*/
public function build_session_restore_message($restore_payload)
{
if (empty($restore_payload["restored"])) {
return "";
}
$memories = $restore_payload["memories"] ?? [];
$preferences = $restore_payload["preferences"] ?? [];
if (empty($memories) && empty($preferences)) {
return "";
}
$lines = ["SESSION RESTORED FROM MEMORY"];
$lines[] =
"The user has returned to this post. Below is context from prior sessions.";
$lines[] =
"Use this to continue where the conversation left off without re-asking.";
// Summarize prior post work.
if (!empty($memories)) {
$lines[] = "";
$lines[] = "## Prior session activity";
$grouped = [];
foreach ($memories as $memory) {
$type = $memory["type"] ?? "context";
if (!isset($grouped[$type])) {
$grouped[$type] = [];
}
$grouped[$type][] = $memory;
}
// Render in priority order: plan > decision > instruction > artifact > error > context.
$priority = [
"artifact",
"decision",
"instruction",
"error",
"learning",
"context",
"preference",
];
foreach ($priority as $type) {
if (empty($grouped[$type])) {
continue;
}
$type_label = ucfirst($type) . "s";
$lines[] = "### {$type_label}";
foreach ($grouped[$type] as $m) {
$content = trim($m["content"] ?? "");
if ("" === $content) {
continue;
}
$title = !empty($m["title"])
? " [" . trim($m["title"]) . "]"
: "";
$lines[] = "- {$content}{$title}";
}
}
}
// Append user preferences (compact).
if (!empty($preferences)) {
$lines[] = "";
$lines[] = "## User writing preferences";
foreach ($preferences as $pref) {
$content = trim($pref["content"] ?? "");
if ("" !== $content) {
$lines[] = "- {$content}";
}
}
}
return implode("\n", $lines);
}
/**
* Normalize raw MEMANTO response into a uniform array.
*
* @param array $raw Raw response from recall/recall_recent.
* @return array Normalized items: { type, content, title }.
*/
private function normalize_memories($raw)
{
if (!is_array($raw)) {
return [];
}
// Handle different possible response shapes.
$items = $raw;
// If response has a 'memories' or 'results' key, unwrap.
if (isset($raw["memories"]) && is_array($raw["memories"])) {
$items = $raw["memories"];
} elseif (isset($raw["results"]) && is_array($raw["results"])) {
$items = $raw["results"];
}
$normalized = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$normalized[] = [
"type" => $item["type"] ?? "context",
"content" => $item["content"] ?? ($item["text"] ?? ""),
"title" => $item["title"] ?? "",
];
}
return $normalized;
}
}