diff --git a/data-machine.php b/data-machine.php index 7094f3cb5..d1a567057 100644 --- a/data-machine.php +++ b/data-machine.php @@ -497,6 +497,9 @@ function datamachine_activate_for_site() { // Assign orphaned resources (agent_id IS NULL) to sole agent on single-agent installs (idempotent). datamachine_assign_orphaned_resources_to_sole_agent(); + // Migrate USER.md to network-scoped paths and create NETWORK.md on multisite (idempotent). + datamachine_migrate_user_md_to_network_scope(); + // Clean up legacy per-agent-type log level options (idempotent). foreach ( array( 'pipeline', 'chat', 'system' ) as $legacy_agent_type ) { delete_option( "datamachine_log_level_{$legacy_agent_type}" ); diff --git a/inc/Abilities/File/AgentFileAbilities.php b/inc/Abilities/File/AgentFileAbilities.php index 01b2da6fe..98148b2d3 100644 --- a/inc/Abilities/File/AgentFileAbilities.php +++ b/inc/Abilities/File/AgentFileAbilities.php @@ -626,7 +626,7 @@ private function resolveFilePath( DirectoryManager $dm, int $user_id, string $fi * Resolve a layer identifier to its directory path. * * @param DirectoryManager $dm Directory manager instance. - * @param string $layer Layer identifier ('shared', 'agent', 'user'). + * @param string $layer Layer identifier ('shared', 'agent', 'user', 'network'). * @param int $user_id Effective user ID. * @param int $agent_id Agent ID. * @return string Directory path. @@ -637,6 +637,8 @@ private function resolveLayerDirectory( DirectoryManager $dm, string $layer, int return $dm->get_shared_directory(); case MemoryFileRegistry::LAYER_USER: return $dm->get_user_directory( $user_id ); + case MemoryFileRegistry::LAYER_NETWORK: + return $dm->get_network_directory(); case MemoryFileRegistry::LAYER_AGENT: default: return $dm->resolve_agent_directory( array( diff --git a/inc/Cli/Commands/MemoryCommand.php b/inc/Cli/Commands/MemoryCommand.php index eb93b7cf2..146e6ddf8 100644 --- a/inc/Cli/Commands/MemoryCommand.php +++ b/inc/Cli/Commands/MemoryCommand.php @@ -894,8 +894,9 @@ public function paths( array $args, array $assoc_args ): void { $agent_dir = $directory_manager->get_agent_identity_directory_for_user( $effective_user_id ); } - $shared_dir = $directory_manager->get_shared_directory(); - $user_dir = $directory_manager->get_user_directory( $effective_user_id ); + $shared_dir = $directory_manager->get_shared_directory(); + $user_dir = $directory_manager->get_user_directory( $effective_user_id ); + $network_dir = $directory_manager->get_network_directory(); $site_root = untrailingslashit( ABSPATH ); $relative = \WP_CLI\Utils\get_flag_value( $assoc_args, 'relative', false ); @@ -922,15 +923,21 @@ public function paths( array $args, array $assoc_args ): void { 'layer' => 'user', 'directory' => $user_dir, ), + array( + 'file' => 'NETWORK.md', + 'layer' => 'network', + 'directory' => $network_dir, + ), ); $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'json' ); if ( 'json' === $format ) { $layers = array( - 'shared' => $shared_dir, - 'agent' => $agent_dir, - 'user' => $user_dir, + 'shared' => $shared_dir, + 'agent' => $agent_dir, + 'user' => $user_dir, + 'network' => $network_dir, ); $files = array(); diff --git a/inc/Core/FilesRepository/DirectoryManager.php b/inc/Core/FilesRepository/DirectoryManager.php index 8f9824c4a..908e0ff0c 100644 --- a/inc/Core/FilesRepository/DirectoryManager.php +++ b/inc/Core/FilesRepository/DirectoryManager.php @@ -180,18 +180,70 @@ public function get_agent_identity_directory( string $agent_slug ): string { /** * Get user layer directory path. * + * On multisite, users are network-wide so USER.md must live in a + * network-global location — the main site's uploads directory. On + * single-site installs, the path is unchanged. + * * @since 0.36.1 + * @since 0.48.0 Network-scoped on multisite (resolves to main site uploads). + * * @param int $user_id WordPress user ID. * @return string Full path to user layer directory. */ public function get_user_directory( int $user_id ): string { - $upload_dir = wp_upload_dir(); - $base = trailingslashit( $upload_dir['basedir'] ) . self::REPOSITORY_DIR; - $user_id = absint( $user_id ); + $base = $this->get_network_base_directory(); + $user_id = absint( $user_id ); return "{$base}/users/{$user_id}"; } + /** + * Get network-level directory path. + * + * Returns the network/ subdirectory under the network-global base. + * Used for NETWORK.md and other network-scoped files on multisite. + * On single-site installs, this resolves to the same base as shared/. + * + * @since 0.48.0 + * @return string Full path to network directory. + */ + public function get_network_directory(): string { + return $this->get_network_base_directory() . '/network'; + } + + /** + * Get the network-global base directory for Data Machine files. + * + * On multisite, resolves to the main site's uploads directory so that + * network-scoped files (users/, network/) live in a single location + * regardless of which subsite is active. On single-site installs, + * returns the standard uploads-based path. + * + * @since 0.48.0 + * @return string Base directory path (without trailing slash). + */ + private function get_network_base_directory(): string { + if ( ! is_multisite() ) { + $upload_dir = wp_upload_dir(); + return trailingslashit( $upload_dir['basedir'] ) . self::REPOSITORY_DIR; + } + + // On multisite, always use the main site's upload directory. + // This avoids per-site paths like wp-content/uploads/sites/7/. + $current_blog_id = get_current_blog_id(); + $main_site_id = get_main_site_id(); + + if ( $current_blog_id !== $main_site_id ) { + switch_to_blog( $main_site_id ); + $upload_dir = wp_upload_dir(); + restore_current_blog(); + } else { + $upload_dir = wp_upload_dir(); + } + + return trailingslashit( $upload_dir['basedir'] ) . self::REPOSITORY_DIR; + } + /** * Resolve effective user ID for layered memory context. * diff --git a/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php b/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php index 6b3c393c3..6d147d3bb 100644 --- a/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php +++ b/inc/Engine/AI/Directives/CoreMemoryFilesDirective.php @@ -4,7 +4,7 @@ * * Loads memory files from the MemoryFileRegistry and injects them into * every AI call. Files are resolved to their layer directories: - * shared → agents/{slug} → users/{id} + * shared → agents/{slug} → users/{id} → network/ * * The registry is the single source of truth for which files exist, * what layer they belong to, and what order they load in. @@ -51,12 +51,13 @@ public static function get_outputs( string $provider_name, array $tools, ?string // Resolve layer directories once. $layer_dirs = array( - MemoryFileRegistry::LAYER_SHARED => $directory_manager->get_shared_directory(), - MemoryFileRegistry::LAYER_AGENT => $directory_manager->resolve_agent_directory( array( + MemoryFileRegistry::LAYER_SHARED => $directory_manager->get_shared_directory(), + MemoryFileRegistry::LAYER_AGENT => $directory_manager->resolve_agent_directory( array( 'agent_id' => (int) ( $payload['agent_id'] ?? 0 ), 'user_id' => $user_id, ) ), - MemoryFileRegistry::LAYER_USER => $directory_manager->get_user_directory( $user_id ), + MemoryFileRegistry::LAYER_USER => $directory_manager->get_user_directory( $user_id ), + MemoryFileRegistry::LAYER_NETWORK => $directory_manager->get_network_directory(), ); $outputs = array(); diff --git a/inc/Engine/AI/Directives/MemoryFilesReader.php b/inc/Engine/AI/Directives/MemoryFilesReader.php index 51b4a8ac1..eb65bc135 100644 --- a/inc/Engine/AI/Directives/MemoryFilesReader.php +++ b/inc/Engine/AI/Directives/MemoryFilesReader.php @@ -52,12 +52,13 @@ public static function read( array $memory_files, string $scope_label, int $scop // Resolve all layer directories once. $layer_dirs = array( - MemoryFileRegistry::LAYER_SHARED => $directory_manager->get_shared_directory(), - MemoryFileRegistry::LAYER_AGENT => $directory_manager->resolve_agent_directory( array( + MemoryFileRegistry::LAYER_SHARED => $directory_manager->get_shared_directory(), + MemoryFileRegistry::LAYER_AGENT => $directory_manager->resolve_agent_directory( array( 'agent_id' => $agent_id, 'user_id' => $user_id, ) ), - MemoryFileRegistry::LAYER_USER => $directory_manager->get_user_directory( $user_id ), + MemoryFileRegistry::LAYER_USER => $directory_manager->get_user_directory( $user_id ), + MemoryFileRegistry::LAYER_NETWORK => $directory_manager->get_network_directory(), ); $outputs = array(); diff --git a/inc/Engine/AI/MemoryFileRegistry.php b/inc/Engine/AI/MemoryFileRegistry.php index a3932ac5b..95e333abd 100644 --- a/inc/Engine/AI/MemoryFileRegistry.php +++ b/inc/Engine/AI/MemoryFileRegistry.php @@ -25,9 +25,10 @@ class MemoryFileRegistry { /** * Valid layer identifiers. */ - const LAYER_SHARED = 'shared'; - const LAYER_AGENT = 'agent'; - const LAYER_USER = 'user'; + const LAYER_SHARED = 'shared'; + const LAYER_AGENT = 'agent'; + const LAYER_USER = 'user'; + const LAYER_NETWORK = 'network'; /** * Registered memory files. @@ -53,7 +54,7 @@ class MemoryFileRegistry { * @param array $args { * Optional. Registration arguments. * - * @type string $layer One of 'shared', 'agent', 'user'. Default 'agent'. + * @type string $layer One of 'shared', 'agent', 'user', 'network'. Default 'agent'. * @type bool $protected Whether the file is protected from deletion. Default false. * @type string $label Human-readable display label. Default derived from filename. * @type string $description Optional description of the file's purpose. @@ -68,7 +69,7 @@ public static function register( string $filename, int $priority = 50, array $ar } $layer = $args['layer'] ?? self::LAYER_AGENT; - if ( ! in_array( $layer, array( self::LAYER_SHARED, self::LAYER_AGENT, self::LAYER_USER ), true ) ) { + if ( ! in_array( $layer, array( self::LAYER_SHARED, self::LAYER_AGENT, self::LAYER_USER, self::LAYER_NETWORK ), true ) ) { $layer = self::LAYER_AGENT; } diff --git a/inc/bootstrap.php b/inc/bootstrap.php index d27e1d3b5..8ba52b646 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -89,13 +89,21 @@ 'description' => 'Accumulated knowledge. Grows over time.', ) ); -// User layer — human preferences, visible to all agents for this user. +// User layer — human preferences, network-scoped on multisite. MemoryFileRegistry::register( 'USER.md', 25, array( 'layer' => MemoryFileRegistry::LAYER_USER, 'protected' => true, 'label' => 'User Profile', 'description' => 'Information about the human the agent works with.', ) ); + +// Network layer — multisite topology, only meaningful on multisite installs. +MemoryFileRegistry::register( 'NETWORK.md', 5, array( + 'layer' => MemoryFileRegistry::LAYER_NETWORK, + 'protected' => true, + 'label' => 'Network Context', + 'description' => 'WordPress multisite network topology and shared resources.', +) ); // SiteContext is autoloaded (Core\WordPress\SiteContext) — register its cache invalidation hook here. add_action( 'init', array( \DataMachine\Core\WordPress\SiteContext::class, 'register_cache_invalidation' ) ); require_once __DIR__ . '/Engine/AI/Directives/SiteContextDirective.php'; diff --git a/inc/migrations.php b/inc/migrations.php index 3aa1f15cd..41534ccb7 100644 --- a/inc/migrations.php +++ b/inc/migrations.php @@ -973,6 +973,230 @@ function datamachine_assign_orphaned_resources_to_sole_agent(): void { } } +/** + * Build NETWORK.md scaffold content from WordPress multisite data. + * + * Generates a markdown summary of the multisite network topology + * including all sites, network-activated plugins, and shared resources. + * Returns empty string on single-site installs. + * + * @since 0.48.0 + * @return string NETWORK.md content, or empty string if not multisite. + */ +function datamachine_get_network_scaffold_content(): string { + if ( ! is_multisite() ) { + return ''; + } + + $network = get_network(); + $network_name = $network ? $network->site_name : 'WordPress Network'; + $main_site_id = get_main_site_id(); + $main_site = get_site( $main_site_id ); + $main_url = $main_site ? $main_site->domain . $main_site->path : home_url(); + + // --- Sites --- + $sites = get_sites( array( 'number' => 100 ) ); + $site_count = get_blog_count(); + + $site_lines = array(); + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + + switch_to_blog( $blog_id ); + $name = get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : 'Site ' . $blog_id; + $url = home_url(); + $theme = wp_get_theme()->get( 'Name' ) ? wp_get_theme()->get( 'Name' ) : 'Unknown'; + restore_current_blog(); + + $is_main = ( $blog_id === $main_site_id ) ? ' (main)' : ''; + $site_lines[] = sprintf( '| %s%s | %s | %s |', $name, $is_main, $url, $theme ); + } + + // --- Network-activated plugins --- + $network_plugins = get_site_option( 'active_sitewide_plugins', array() ); + $plugin_names = array(); + + foreach ( array_keys( $network_plugins ) as $plugin_file ) { + if ( 0 === strpos( $plugin_file, 'data-machine/' ) ) { + continue; + } + + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file; + if ( function_exists( 'get_plugin_data' ) && file_exists( $plugin_path ) ) { + $plugin_data = get_plugin_data( $plugin_path, false, false ); + $plugin_names[] = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : dirname( $plugin_file ); + } else { + $dir = dirname( $plugin_file ); + $plugin_names[] = '.' === $dir ? str_replace( '.php', '', basename( $plugin_file ) ) : $dir; + } + } + + // --- Build content --- + $lines = array(); + $lines[] = '# Network'; + $lines[] = ''; + $lines[] = '## Identity'; + $lines[] = '- **network_name:** ' . $network_name; + $lines[] = '- **primary_site:** ' . $main_url; + $lines[] = '- **sites_count:** ' . $site_count; + $lines[] = ''; + $lines[] = '## Sites'; + $lines[] = '| Site | URL | Theme |'; + $lines[] = '|------|-----|-------|'; + + foreach ( $site_lines as $line ) { + $lines[] = $line; + } + + $lines[] = ''; + $lines[] = '## Network Plugins'; + if ( ! empty( $plugin_names ) ) { + foreach ( $plugin_names as $name ) { + $lines[] = '- ' . $name; + } + } else { + $lines[] = '- (none)'; + } + + $lines[] = ''; + $lines[] = '## Shared Resources'; + $lines[] = '- **Users:** network-wide (see USER.md)'; + $lines[] = '- **Media:** per-site uploads'; + + return implode( "\n", $lines ) . "\n"; +} + +/** + * Migrate USER.md from site-scoped to network-scoped paths on multisite. + * + * On multisite, USER.md was previously stored per-site (under each site's + * upload dir). Since WordPress users are network-wide, USER.md should live + * in the main site's uploads directory. + * + * This migration finds the richest (largest) USER.md across all subsites + * and copies it to the new network-scoped location. Also creates NETWORK.md + * if it doesn't exist. + * + * Idempotent. Skipped on single-site installs. + * + * @since 0.48.0 + * @return void + */ +function datamachine_migrate_user_md_to_network_scope(): void { + if ( get_option( 'datamachine_user_md_network_migrated', false ) ) { + return; + } + + // Single-site: nothing to migrate, just mark done. + if ( ! is_multisite() ) { + update_option( 'datamachine_user_md_network_migrated', true, true ); + return; + } + + $directory_manager = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $fs = \DataMachine\Core\FilesRepository\FilesystemHelper::get(); + + if ( ! $fs ) { + return; + } + + // get_user_directory() now returns the network-scoped path. + // We need to find USER.md files in old per-site locations and consolidate. + $sites = get_sites( array( 'number' => 100 ) ); + + // Get all WordPress users to check for USER.md across sites. + $users = get_users( array( + 'fields' => 'ID', + 'number' => 100, + ) ); + + $migrated_users = 0; + + foreach ( $users as $user_id ) { + $user_id = absint( $user_id ); + + // New network-scoped destination (from updated get_user_directory). + $network_user_dir = $directory_manager->get_user_directory( $user_id ); + $network_user_file = trailingslashit( $network_user_dir ) . 'USER.md'; + + // If the file already exists at the network location, skip. + if ( file_exists( $network_user_file ) ) { + continue; + } + + // Search all subsites for the richest USER.md for this user. + $best_content = ''; + $best_size = 0; + + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + + switch_to_blog( $blog_id ); + $site_upload_dir = wp_upload_dir(); + restore_current_blog(); + + $site_user_file = trailingslashit( $site_upload_dir['basedir'] ) + . 'datamachine-files/users/' . $user_id . '/USER.md'; + + if ( file_exists( $site_user_file ) ) { + $size = filesize( $site_user_file ); + if ( $size > $best_size ) { + $best_size = $size; + $best_content = $fs->get_contents( $site_user_file ); + } + } + } + + if ( ! empty( $best_content ) ) { + if ( ! is_dir( $network_user_dir ) ) { + wp_mkdir_p( $network_user_dir ); + } + + $index_file = trailingslashit( $network_user_dir ) . 'index.php'; + if ( ! file_exists( $index_file ) ) { + $fs->put_contents( $index_file, "put_contents( $network_user_file, $best_content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_user_file ); + ++$migrated_users; + } + } + + // Create NETWORK.md if it doesn't exist. + $network_dir = $directory_manager->get_network_directory(); + if ( ! is_dir( $network_dir ) ) { + wp_mkdir_p( $network_dir ); + } + + $network_md = trailingslashit( $network_dir ) . 'NETWORK.md'; + if ( ! file_exists( $network_md ) ) { + $content = datamachine_get_network_scaffold_content(); + if ( ! empty( $content ) ) { + $fs->put_contents( $network_md, $content, FS_CHMOD_FILE ); + \DataMachine\Core\FilesRepository\FilesystemHelper::make_group_writable( $network_md ); + } + } + + $network_index = trailingslashit( $network_dir ) . 'index.php'; + if ( ! file_exists( $network_index ) ) { + $fs->put_contents( $network_index, " 0 ) { + do_action( + 'datamachine_log', + 'info', + 'Migrated USER.md to network-scoped paths', + array( 'users_migrated' => $migrated_users ) + ); + } +} + /** * Re-schedule all flows with non-manual scheduling on plugin activation. *