Files
wp-agentic-writer/includes/class-memanto-client.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

637 lines
18 KiB
PHP

<?php
/**
* MEMANTO Client
*
* PHP client for MEMANTO API v2 — external semantic memory service.
* Provides persistent, cross-session memory for the WP Agentic Writer plugin.
*
* @package WP_Agentic_Writer
* @since 0.3.0
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_Memanto_Client
*
* Communicates with a MEMANTO instance (powered by Moorcheh SDK).
* All calls are wrapped in error handling — failures never disrupt the plugin.
*/
class WP_Agentic_Writer_Memanto_Client
{
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Memanto_Client
*/
private static $instance = null;
/**
* MEMANTO base URL (e.g. https://abc123.context.wpagentic.dev).
*
* @var string
*/
private $base_url = "";
/**
* Cached health status.
*
* @var array|null { healthy: bool, checked_at: int } or null if not checked yet.
*/
private $health_cache = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Memanto_Client
*/
public static function get_instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct()
{
$settings = get_option("wp_agentic_writer_settings", []);
$this->base_url = untrailingslashit($settings["memanto_url"] ?? "");
}
// =========================================================================
// Configuration & Health
// =========================================================================
/**
* Whether MEMANTO is configured (URL + Moorcheh key set).
*
* @return bool
*/
public function is_configured()
{
return !empty($this->base_url) && !empty($this->get_moorcheh_key());
}
/**
* Whether MEMANTO is enabled in settings.
*
* @return bool
*/
public function is_enabled()
{
$settings = get_option("wp_agentic_writer_settings", []);
return !empty($settings["memanto_enabled"]);
}
/**
* Whether MEMANTO is enabled, configured, and reachable.
*
* @return bool
*/
public function is_active()
{
return $this->is_enabled() &&
$this->is_configured() &&
$this->is_healthy();
}
/**
* Check MEMANTO health endpoint. Result cached for 5 minutes.
*
* @return bool True if healthy.
*/
public function is_healthy()
{
if (!$this->is_configured()) {
return false;
}
// Use cached result if fresh (5 minutes).
if (null !== $this->health_cache) {
if (time() - $this->health_cache["checked_at"] < 300) {
return $this->health_cache["healthy"];
}
}
// Also check transient for cross-request caching.
$cached = get_transient("wpaw_memanto_health");
if (
false !== $cached &&
isset($cached["checked_at"]) &&
time() - $cached["checked_at"] < 300
) {
$this->health_cache = $cached;
return $cached["healthy"];
}
$response = $this->get("/health");
if (is_wp_error($response)) {
$this->health_cache = ["healthy" => false, "checked_at" => time()];
set_transient("wpaw_memanto_health", $this->health_cache, 300);
return false;
}
$healthy =
!empty($response["status"]) && "healthy" === $response["status"];
$this->health_cache = ["healthy" => $healthy, "checked_at" => time()];
set_transient("wpaw_memanto_health", $this->health_cache, 300);
return $healthy;
}
/**
* Force-refresh the health check (used by Test Connection button).
*
* @return array { healthy: bool, details: array|null }
*/
public function check_health_fresh()
{
if (!$this->is_configured()) {
return ["healthy" => false, "details" => null];
}
delete_transient("wpaw_memanto_health");
$this->health_cache = null;
$response = $this->get("/health");
if (is_wp_error($response)) {
return [
"healthy" => false,
"details" => ["error" => $response->get_error_message()],
];
}
$healthy =
!empty($response["status"]) && "healthy" === $response["status"];
$this->health_cache = ["healthy" => $healthy, "checked_at" => time()];
set_transient("wpaw_memanto_health", $this->health_cache, 300);
return ["healthy" => $healthy, "details" => $response];
}
// =========================================================================
// Agent Management
// =========================================================================
/**
* Ensure an agent exists. Creates if not found.
*
* @param string $agent_id Agent identifier (e.g. "wp-user-1" or "wp-post-42").
* @return bool True on success.
*/
public function ensure_agent($agent_id)
{
if (!$this->is_configured()) {
return false;
}
// Check if agent already exists.
$agent = $this->get("/api/v2/agents/{$agent_id}");
if (!is_wp_error($agent) && !empty($agent["agent_id"])) {
return true;
}
// Create agent.
$result = $this->post("/api/v2/agents", [
"agent_id" => $agent_id,
"pattern" => "support",
"description" => "WP Agentic Writer agent",
]);
return !is_wp_error($result);
}
// =========================================================================
// Session Management
// =========================================================================
/**
* Activate a session for an agent. Returns cached token if still valid.
*
* @param string $agent_id Agent identifier.
* @return string|false Session token or false on failure.
*/
public function activate_session($agent_id)
{
if (!$this->is_configured()) {
return false;
}
$transient_key = "wpaw_memanto_token_" . md5($agent_id);
$cached_token = get_transient($transient_key);
if (!empty($cached_token)) {
return $cached_token;
}
$response = $this->post(
"/api/v2/agents/{$agent_id}/activate",
[],
$agent_id,
);
if (is_wp_error($response) || empty($response["session_token"])) {
wpaw_debug_log("MEMANTO activate_session failed", [
"agent_id" => $agent_id,
]);
return false;
}
$token = $response["session_token"];
$expires_at = strtotime($response["expires_at"] ?? "+6 hours");
$ttl = max(60, $expires_at - time() - 300); // Expire 5 min before actual.
set_transient($transient_key, $token, $ttl);
return $token;
}
/**
* Deactivate a session for an agent.
*
* Clears the cached token and sends the deactivate request
* without injecting a session token (avoids re-activation loop).
*
* @param string $agent_id Agent identifier.
* @return bool True on success.
*/
public function deactivate_session($agent_id)
{
$transient_key = "wpaw_memanto_token_" . md5($agent_id);
delete_transient($transient_key);
if (!$this->is_configured()) {
return false;
}
$url = $this->base_url . "/api/v2/agents/{$agent_id}/deactivate";
$headers = $this->get_headers();
$response = wp_remote_post($url, [
"headers" => $headers,
"body" => wp_json_encode([]),
"timeout" => 10,
]);
if (is_wp_error($response)) {
wpaw_debug_log("MEMANTO deactivate_session failed", [
"agent_id" => $agent_id,
]);
return false;
}
return true;
}
// =========================================================================
// Memory Operations
// =========================================================================
/**
* Store a memory.
*
* @param string $agent_id Agent identifier.
* @param string $content Memory content (max 10000 chars).
* @param string $type Memory type (fact, preference, goal, decision, artifact, learning, event, instruction, relationship, context, observation, commitment, error).
* @param array $tags Optional tags.
* @param string $title Optional title (max 100 chars).
* @return bool True on success.
*/
public function remember(
$agent_id,
$content,
$type = "context",
$tags = [],
$title = "",
) {
if (!$this->is_active()) {
return false;
}
$body = [
"content" => mb_substr($content, 0, 10000),
"type" => $type,
"tags" => $tags,
"source" => "wp-agentic-writer",
];
if (!empty($title)) {
$body["title"] = mb_substr($title, 0, 100);
}
$response = $this->post(
"/api/v2/agents/{$agent_id}/remember",
$body,
$agent_id,
);
if (is_wp_error($response)) {
wpaw_debug_log("MEMANTO remember failed", [
"agent_id" => $agent_id,
"type" => $type,
"error" => $response->get_error_message(),
]);
return false;
}
return true;
}
/**
* Store multiple memories in batch (max 100).
*
* @param string $agent_id Agent identifier.
* @param array $memories Array of memory items. Each item: { content, type, tags, title }.
* @return bool True on success.
*/
public function batch_remember($agent_id, $memories)
{
if (!$this->is_active() || empty($memories)) {
return false;
}
$batch = [];
foreach (array_slice($memories, 0, 100) as $item) {
$entry = [
"content" => mb_substr($item["content"] ?? "", 0, 10000),
"type" => $item["type"] ?? "context",
"source" => "wp-agentic-writer",
];
if (!empty($item["title"])) {
$entry["title"] = mb_substr($item["title"], 0, 100);
}
if (!empty($item["tags"])) {
$entry["tags"] = $item["tags"];
}
$batch[] = $entry;
}
$response = $this->post(
"/api/v2/agents/{$agent_id}/batch-remember",
["memories" => $batch],
$agent_id,
);
if (is_wp_error($response)) {
wpaw_debug_log("MEMANTO batch_remember failed", [
"agent_id" => $agent_id,
"count" => count($batch),
"error" => $response->get_error_message(),
]);
return false;
}
return true;
}
/**
* Semantic search / recall memories.
*
* @param string $agent_id Agent identifier.
* @param string $query Search query.
* @param array $type_filter Optional memory type filter.
* @param int $limit Max results (default 10).
* @param float $min_similarity Minimum similarity score 0-1.
* @return array Recalled memories (empty on failure).
*/
public function recall(
$agent_id,
$query,
$type_filter = [],
$limit = 10,
$min_similarity = 0.3,
) {
if (!$this->is_active()) {
return [];
}
$body = [
"query" => $query,
"limit" => $limit,
"min_similarity" => $min_similarity,
];
if (!empty($type_filter)) {
$body["type"] = $type_filter;
}
$response = $this->post(
"/api/v2/agents/{$agent_id}/recall",
$body,
$agent_id,
);
if (is_wp_error($response)) {
wpaw_debug_log("MEMANTO recall failed", [
"agent_id" => $agent_id,
"query" => substr($query, 0, 100),
"error" => $response->get_error_message(),
]);
return [];
}
return is_array($response) ? $response : [];
}
/**
* Recall most recent memories.
*
* @param string $agent_id Agent identifier.
* @param int $limit Max results.
* @param array $type_filter Optional memory type filter.
* @return array Recent memories (empty on failure).
*/
public function recall_recent($agent_id, $limit = 10, $type_filter = [])
{
if (!$this->is_active()) {
return [];
}
$body = ["limit" => $limit];
if (!empty($type_filter)) {
$body["type"] = $type_filter;
}
$response = $this->post(
"/api/v2/agents/{$agent_id}/recall/recent",
$body,
$agent_id,
);
if (is_wp_error($response)) {
return [];
}
return is_array($response) ? $response : [];
}
// =========================================================================
// Agent ID Builders
// =========================================================================
/**
* Build user-level agent ID.
*
* @param int $user_id WordPress user ID.
* @return string Agent ID like "wp-user-1".
*/
public function get_user_agent_id($user_id)
{
return "wp-user-" . absint($user_id);
}
/**
* Build post-level agent ID.
*
* @param int $post_id WordPress post ID.
* @return string Agent ID like "wp-post-42".
*/
public function get_post_agent_id($post_id)
{
return "wp-post-" . absint($post_id);
}
// =========================================================================
// HTTP Helpers (private)
// =========================================================================
/**
* Get the Moorcheh API key from settings.
*
* @return string
*/
private function get_moorcheh_key()
{
$settings = get_option("wp_agentic_writer_settings", []);
return $settings["memanto_moorcheh_key"] ?? "";
}
/**
* GET request to MEMANTO API.
*
* @param string $path API path (e.g. "/health", "/api/v2/agents/{id}").
* @return array|WP_Error Decoded response or error.
*/
private function get($path)
{
$url = $this->base_url . $path;
$response = wp_remote_get($url, [
"headers" => $this->get_headers(),
"timeout" => 10,
]);
return $this->parse_response($response);
}
/**
* POST request to MEMANTO API.
*
* @param string $path API path.
* @param array $body Request body.
* @param string $agent_id Optional agent ID for session token injection.
* @return array|WP_Error Decoded response or error.
*/
private function post($path, $body = [], $agent_id = "")
{
$url = $this->base_url . $path;
$headers = $this->get_headers();
// Inject session token if we have an agent_id.
if (!empty($agent_id)) {
$token = $this->activate_session($agent_id);
if ($token) {
$headers["X-Session-Token"] = $token;
}
}
$response = wp_remote_post($url, [
"headers" => $headers,
"body" => wp_json_encode($body),
"timeout" => 10,
]);
// Handle expired token (401) — re-activate and retry once.
if (!is_wp_error($response)) {
$code = wp_remote_retrieve_response_code($response);
if (401 === $code && !empty($agent_id)) {
// Clear cached token and retry.
delete_transient("wpaw_memanto_token_" . md5($agent_id));
$token = $this->activate_session($agent_id);
if ($token) {
$headers["X-Session-Token"] = $token;
$response = wp_remote_post($url, [
"headers" => $headers,
"body" => wp_json_encode($body),
"timeout" => 10,
]);
}
}
}
return $this->parse_response($response);
}
/**
* Build common headers for MEMANTO API requests.
*
* @return array Headers.
*/
private function get_headers()
{
return [
"Content-Type" => "application/json",
"Accept" => "application/json",
"X-API-Key" => $this->get_moorcheh_key(),
];
}
/**
* Parse HTTP response from MEMANTO API.
*
* @param array|WP_Error $response wp_remote response.
* @return array|WP_Error Decoded body or error.
*/
private function parse_response($response)
{
if (is_wp_error($response)) {
return new WP_Error(
"memanto_connection_error",
$response->get_error_message(),
);
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if (401 === $code) {
// Let caller handle re-auth.
return new WP_Error(
"memanto_unauthorized",
"Session token expired",
);
}
if ($code >= 400) {
wpaw_debug_log("MEMANTO API error ({$code})", $body);
return new WP_Error(
"memanto_api_error",
sprintf(
"MEMANTO API error (%d): %s",
$code,
substr($body, 0, 200),
),
);
}
$decoded = json_decode($body, true);
return is_array($decoded) ? $decoded : [];
}
}