diff --git a/inc/Core/Database/Chat/Chat.php b/inc/Core/Database/Chat/Chat.php index d9c08572e..133d685b3 100644 --- a/inc/Core/Database/Chat/Chat.php +++ b/inc/Core/Database/Chat/Chat.php @@ -486,6 +486,75 @@ public function get_session( string $session_id ): ?array { return $session; } + /** + * List transcript sessions for a workspace/user pair. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace owning the sessions. + * @param int $user_id WordPress user ID owning the sessions. + * @param array $args Optional filters/pagination. + * @return array> Session rows. + */ + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + global $wpdb; + + $table_name = self::get_prefixed_table_name(); + $include_messages = (bool) ( $args['include_messages'] ?? true ); + $limit = max( 1, min( 100, (int) ( $args['limit'] ?? 20 ) ) ); + $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); + $where = array( + 'workspace_type = %s', + 'workspace_id = %s', + 'user_id = %d', + ); + $query_args = array( + $table_name, + $workspace->workspace_type, + $workspace->workspace_id, + $user_id, + ); + + if ( is_string( $args['context'] ?? null ) && '' !== $args['context'] ) { + $where[] = 'mode = %s'; + $query_args[] = $args['context']; + } + + if ( is_string( $args['agent_slug'] ?? null ) && '' !== $args['agent_slug'] ) { + try { + $identity = self::resolve_agent_identity_for_session( $args['agent_slug'] ); + } catch ( \InvalidArgumentException $e ) { + unset( $e ); + return array(); + } + + $where[] = 'agent_id = %d'; + $query_args[] = $identity['agent_id']; + } + + $select = $include_messages ? '*' : 'session_id, workspace_type, workspace_id, user_id, agent_id, title, metadata, provider, model, provider_response_id, mode, created_at, updated_at, last_read_at, expires_at'; + $sql = 'SELECT ' . $select . ' FROM %i WHERE ' . implode( ' AND ', $where ) . ' ORDER BY updated_at DESC LIMIT %d OFFSET %d'; + + $query_args[] = $limit; + $query_args[] = $offset; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $sessions = $wpdb->get_results( $wpdb->prepare( $sql, ...$query_args ), ARRAY_A ); + + if ( ! $sessions ) { + return array(); + } + + foreach ( $sessions as &$session ) { + if ( $include_messages ) { + $session['messages'] = self::normalize_messages( json_decode( $session['messages'] ?? '[]', true ) ?? array() ); + } + $session['metadata'] = json_decode( $session['metadata'] ?? '[]', true ) ?? array(); + $session['agent_slug'] = self::resolve_agent_slug_from_session_row( $session ); + } + unset( $session ); + + return $sessions; + } + /** * Resolve the generic transcript agent slug from a stored session row. * diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 115f36e18..ee13243f6 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -136,67 +136,75 @@ function () { 0 ); -// Shared layer — site-wide context, visible to all agents. -// Composable: content assembled from sections registered against SectionRegistry -// (see inc/migrations/site-md.php). `editable` is forced to false by composable=true. -MemoryFileRegistry::register( 'SITE.md', 10, array( - 'layer' => MemoryFileRegistry::LAYER_SHARED, - 'protected' => true, - 'composable' => true, - 'modes' => array( MemoryFileRegistry::MODE_ALL ), - 'label' => 'Site Context', - 'description' => 'Auto-generated site context. Composable — extend via SectionRegistry.', -) ); -MemoryFileRegistry::register( 'RULES.md', 15, array( - 'layer' => MemoryFileRegistry::LAYER_SHARED, - 'protected' => true, - 'editable' => 'manage_options', - 'modes' => array( MemoryFileRegistry::MODE_ALL ), - 'label' => 'Site Rules', - 'description' => 'Behavioral constraints that apply to every agent. Admin-editable.', -) ); - -// Agent layer — identity and knowledge, scoped to a single agent. -// Injected in interactive modes only (chat, pipeline). Excluded from -// system mode so autonomous maintenance tasks (e.g. daily memory -// compaction) are not primed with the agent's identity while operating -// on these files. -MemoryFileRegistry::register( 'SOUL.md', 20, array( - 'layer' => MemoryFileRegistry::LAYER_AGENT, - 'protected' => true, - 'modes' => array( 'chat', 'pipeline' ), - 'label' => 'Agent Identity', - 'description' => 'Agent identity, voice, rules. Injected in interactive modes only.', -) ); -MemoryFileRegistry::register( 'MEMORY.md', 30, array( - 'layer' => MemoryFileRegistry::LAYER_AGENT, - 'protected' => true, - 'modes' => array( 'chat', 'pipeline' ), - 'label' => 'Agent Memory', - 'description' => 'Accumulated knowledge. Injected in interactive modes only.', -) ); - -// User layer — human preferences, network-scoped on multisite. -// Only injected in interactive modes where a human is present. -// Pipelines can still opt in via pipeline memory file selection. -MemoryFileRegistry::register( 'USER.md', 25, array( - 'layer' => MemoryFileRegistry::LAYER_USER, - 'protected' => true, - 'modes' => array( 'chat', 'editor' ), - 'label' => 'User Profile', - 'description' => 'Information about the human the agent works with. Injected in chat and editor modes only.', -) ); +function datamachine_register_default_memory_files(): void { + // Shared layer — site-wide context, visible to all agents. + // Composable: content assembled from sections registered against SectionRegistry + // (see inc/migrations/site-md.php). `editable` is forced to false by composable=true. + MemoryFileRegistry::register( 'SITE.md', 10, array( + 'layer' => MemoryFileRegistry::LAYER_SHARED, + 'protected' => true, + 'composable' => true, + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + 'label' => 'Site Context', + 'description' => 'Auto-generated site context. Composable — extend via SectionRegistry.', + ) ); + MemoryFileRegistry::register( 'RULES.md', 15, array( + 'layer' => MemoryFileRegistry::LAYER_SHARED, + 'protected' => true, + 'editable' => 'manage_options', + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + 'label' => 'Site Rules', + 'description' => 'Behavioral constraints that apply to every agent. Admin-editable.', + ) ); + + // Agent layer — identity and knowledge, scoped to a single agent. + // Injected in interactive modes only (chat, pipeline). Excluded from + // system mode so autonomous maintenance tasks (e.g. daily memory + // compaction) are not primed with the agent's identity while operating + // on these files. + MemoryFileRegistry::register( 'SOUL.md', 20, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'protected' => true, + 'modes' => array( 'chat', 'pipeline' ), + 'label' => 'Agent Identity', + 'description' => 'Agent identity, voice, rules. Injected in interactive modes only.', + ) ); + MemoryFileRegistry::register( 'MEMORY.md', 30, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'protected' => true, + 'modes' => array( 'chat', 'pipeline' ), + 'label' => 'Agent Memory', + 'description' => 'Accumulated knowledge. Injected in interactive modes only.', + ) ); + + // User layer — human preferences, network-scoped on multisite. + // Only injected in interactive modes where a human is present. + // Pipelines can still opt in via pipeline memory file selection. + MemoryFileRegistry::register( 'USER.md', 25, array( + 'layer' => MemoryFileRegistry::LAYER_USER, + 'protected' => true, + 'modes' => array( 'chat', 'editor' ), + 'label' => 'User Profile', + 'description' => 'Information about the human the agent works with. Injected in chat and editor modes only.', + ) ); + + // Network layer — multisite topology, only meaningful on multisite installs. + // Composable: content assembled from sections registered against SectionRegistry. + MemoryFileRegistry::register( 'NETWORK.md', 5, array( + 'layer' => MemoryFileRegistry::LAYER_NETWORK, + 'protected' => true, + 'composable' => true, + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + 'label' => 'Network Context', + 'description' => 'Auto-generated multisite network topology. Composable — extend via SectionRegistry.', + ) ); +} -// Network layer — multisite topology, only meaningful on multisite installs. -// Composable: content assembled from sections registered against SectionRegistry. -MemoryFileRegistry::register( 'NETWORK.md', 5, array( - 'layer' => MemoryFileRegistry::LAYER_NETWORK, - 'protected' => true, - 'composable' => true, - 'modes' => array( MemoryFileRegistry::MODE_ALL ), - 'label' => 'Network Context', - 'description' => 'Auto-generated multisite network topology. Composable — extend via SectionRegistry.', -) ); +if ( did_action( 'plugins_loaded' ) ) { + datamachine_register_default_memory_files(); +} else { + add_action( 'plugins_loaded', 'datamachine_register_default_memory_files', 0 ); +} // Composable file auto-regeneration — rebuilds AGENTS.md, SITE.md, NETWORK.md, and any other // composable files on plugin (de)activation plus any hooks plugins register via diff --git a/tests/Unit/Core/Database/Chat/InMemoryConversationStore.php b/tests/Unit/Core/Database/Chat/InMemoryConversationStore.php index ca41b47f7..b2405e65f 100644 --- a/tests/Unit/Core/Database/Chat/InMemoryConversationStore.php +++ b/tests/Unit/Core/Database/Chat/InMemoryConversationStore.php @@ -133,6 +133,35 @@ public function get_session( string $session_id ): ?array { return $session; } + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + $include_messages = (bool) ( $args['include_messages'] ?? true ); + $limit = max( 1, min( 100, (int) ( $args['limit'] ?? 20 ) ) ); + $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); + $rows = array(); + + foreach ( $this->sessions as $session ) { + if ( $session['workspace_type'] !== $workspace->workspace_type || $session['workspace_id'] !== $workspace->workspace_id || (int) $session['user_id'] !== $user_id ) { + continue; + } + if ( is_string( $args['context'] ?? null ) && '' !== $args['context'] && $session['context'] !== $args['context'] ) { + continue; + } + if ( is_string( $args['agent_slug'] ?? null ) && '' !== $args['agent_slug'] && ( $session['agent_slug'] ?? '' ) !== sanitize_title( $args['agent_slug'] ) ) { + continue; + } + + $row = $session; + if ( ! $include_messages ) { + unset( $row['messages'] ); + } + $rows[] = $row; + } + + usort( $rows, static fn( $a, $b ) => strcmp( $b['updated_at'], $a['updated_at'] ) ); + + return array_slice( $rows, $offset, $limit ); + } + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { if ( ! isset( $this->sessions[ $session_id ] ) ) { return false; diff --git a/tests/ai-request-metadata-guardrails-smoke.php b/tests/ai-request-metadata-guardrails-smoke.php index a9a180119..0a311b09b 100644 --- a/tests/ai-request-metadata-guardrails-smoke.php +++ b/tests/ai-request-metadata-guardrails-smoke.php @@ -134,6 +134,7 @@ public function create_session( ...$args ): string { } public function get_session( string $session_id ): ?array { return null; } + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { return array(); } public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { unset( $provider_response_id ); $this->updated[ $session_id ] = compact( 'messages', 'metadata', 'provider', 'model' );